Shared Packages in Monorepos: Patterns

Shared Packages Best Practices: Lessons from Production

The Full Journey

In this series, we’ve covered:

Today, I’m sharing the hard-earned lessons from 6 months of using shared packages in production. These are the rules that saved us from disaster, and the mistakes we made so you don’t have to.


The Golden Rule of Shared Packages

After 6 months and countless discussions, our team converged on one principle:

“Extract infrastructure, not business logic.”

Let me explain why this matters.

✅ Good Candidates for Shared Packages

1. Infrastructure Code (technology wrappers)

// @radarkit/logger - Logging wrapper
// @radarkit/kafka-client - Event bus wrapper
// @radarkit/database - Prisma utilities
// @radarkit/redis - Cache wrapper
// @radarkit/monitoring - Metrics and tracing

Why: These are how you do things. Every service logs, publishes events, and queries databases. One implementation ensures consistency.

2. Type Definitions (contracts between services)

// @radarkit/types
export interface DomainEvent { ... }
export interface User { ... }
export interface Source { ... }

Why: Types define contracts. When services communicate, they must speak the same language.

3. Cross-cutting Utilities (used everywhere)

// @radarkit/validation
export const isValidUrl = (url: string): boolean => { ... }
export const isValidEmail = (email: string): boolean => { ... }

Why: Pure functions with no business logic. URL validation is URL validation, regardless of domain.

❌ Bad Candidates (Keep These Local)

1. Business Logic (domain-specific)

// ❌ DON'T extract
// @radarkit/subscription-calculator
export const calculatePrice = (user: User, plan: Plan): number => {
  // Complex business rules specific to your domain
}

Why: Business logic changes frequently and is domain-specific. Sharing it creates tight coupling.

2. Service-Specific Implementation

// ❌ DON'T extract
// @radarkit/auth-strategies
export class JwtStrategy { ... }
export class OAuthStrategy { ... }

Why: Only auth-service needs this. Extracting creates unnecessary complexity.

3. Experimental Code

// ❌ DON'T extract (yet)
// New ML-based ranking algorithm
// Wait until stable and used by 2+ services

Why: Premature extraction creates maintenance burden. Wait until patterns emerge.


The Rule of Three

This is my #1 decision framework:

Code used in 1 place → Keep it local
Code used in 2 places → Start discussing extraction
Code used in 3+ places → MUST extract to package

Real Example from RadarKit

Month 1: Only auth-service needs JWT validation

// auth-service/src/auth/jwt.validator.ts
export const validateJwt = (token: string) => { ... }

Decision: Keep local

Month 2: sources-service also needs JWT validation (protecting APIs)

// sources-service/src/middleware/auth.middleware.ts
export const validateJwt = (token: string) => { ... }  // Copy-pasted

Decision: Start discussion ⚠️

Month 3: scraper-service needs it too (authenticated scraping)

// scraper-service/src/auth/validator.ts
export const validateJwt = (token: string) => { ... }  // Third copy!

Decision: EXTRACT NOW 🚨

// packages/auth-utils/src/jwt.ts
export const validateJwt = (token: string) => { ... }

Versioning Strategy: The Non-Negotiable Rules

Semantic versioning isn’t optional. It’s what prevents 3 AM production incidents.

Semantic Versioning 101

MAJOR.MINOR.PATCH
  |     |     |
  |     |     └─ Bug fixes (backward compatible)
  |     └─────── New features (backward compatible)
  └───────────── Breaking changes

Real-World Examples

PATCH (1.0.0 → 1.0.1)

// Before (buggy)
export const createLogger = (name: string) => {
  return winston.createLogger({
    level: 'info',  // 🐛 Hardcoded, ignores env var
  });
}

// After (fixed)
export const createLogger = (name: string) => {
  return winston.createLogger({
    level: process.env.LOG_LEVEL || 'info',  // ✅ Fixed
  });
}

Impact: All services can upgrade immediately. No code changes needed.

MINOR (1.0.1 → 1.1.0)

// Before
export const createLogger = (name: string) => { ... }

// After - ADD new optional parameter
export const createLogger = (
  name: string, 
  options?: { level?: string }  // ✅ New, but optional
) => { ... }

Impact: Existing code still works. New feature available if needed.

// Old code - still works
const logger = createLogger('auth-service');

// New code - uses new feature
const logger = createLogger('auth-service', { level: 'debug' });

MAJOR (1.1.0 → 2.0.0)

// Before (1.x)
export const createLogger = (name: string) => { ... }

// After (2.0.0) - BREAKING: required parameter
export const createLogger = (
  name: string, 
  environment: string  // ⚠️ Now REQUIRED
) => { ... }

Impact: All services must update their code:

// Before
const logger = createLogger('auth-service');  // ❌ Breaks with 2.0.0

// After
const logger = createLogger('auth-service', process.env.NODE_ENV);  // ✅

Migration Strategy for Breaking Changes

When we released @radarkit/logger@2.0.0:

Step 1: Announce the change

## @radarkit/logger 2.0.0 - BREAKING CHANGES

### What Changed
- `createLogger()` now requires `environment` parameter
- Removed deprecated `Logger` class

### Migration Guide
```typescript
// Before (1.x)
const logger = createLogger('service-name');

// After (2.0.0)
const logger = createLogger('service-name', process.env.NODE_ENV);
```

### Why This Change
- Better environment-specific logging
- Clearer configuration

### Timeline
- April 1: v2.0.0 released
- April 1-15: Both v1.x and v2.x supported
- April 15: v1.x deprecated
- May 1: v1.x support ends

Step 2: Gradual migration

// Some services still on 1.x
"@radarkit/logger": "^1.2.0"

// Others adopt 2.x when ready
"@radarkit/logger": "^2.0.0"

Step 3: Support period


Package Organization Patterns

packages/
├── logger/              ← Just logging
├── kafka-client/        ← Just Kafka
├── redis-client/        ← Just Redis
├── monitoring/          ← Just metrics/tracing
└── validation/          ← Just validation utils

Pros:

Pattern 2: Domain Packages (Sometimes)

packages/
├── infrastructure/      ← All infrastructure (logger, kafka, redis)
├── types/              ← All types
└── utils/              ← All utilities

Pros:

Cons:

My recommendation: Start with Pattern 1. It scales better.

Pattern 3: Layered Packages (Advanced)

packages/
├── core/               ← Base utilities (no dependencies)
│   ├── types/
│   └── constants/
│
├── infrastructure/     ← Depends on core
│   ├── logger/        (uses @radarkit/types)
│   ├── kafka/         (uses @radarkit/types)
│   └── database/      (uses @radarkit/types)
│
└── domain/            ← Depends on infrastructure
    ├── auth/          (uses @radarkit/logger)
    └── validation/    (uses @radarkit/logger)

Dependency flow:

core → infrastructure → domain → services

When to use: Large organizations with 20+ services.


Testing Strategies

Test Packages in Isolation

// packages/logger/__tests__/logger.test.ts
import { createLogger } from '../src';

describe('Logger', () => {
  beforeEach(() => {
    // Reset environment
    delete process.env.LOG_LEVEL;
  });

  it('should use default log level', () => {
    const logger = createLogger('test');
    expect(logger.level).toBe('info');
  });

  it('should respect LOG_LEVEL env var', () => {
    process.env.LOG_LEVEL = 'debug';
    const logger = createLogger('test');
    expect(logger.level).toBe('debug');
  });

  it('should include service name in metadata', () => {
    const logger = createLogger('my-service');
    expect(logger.defaultMeta.service).toBe('my-service');
  });

  it('should handle errors gracefully', () => {
    const logger = createLogger('test');
    expect(() => {
      logger.error('Test error', new Error('Something failed'));
    }).not.toThrow();
  });
});

Integration Tests in Services

// services/auth-service/__tests__/integration/logging.test.ts
import { createLogger } from '@radarkit/logger';

describe('Logging Integration', () => {
  it('should log with correct service name', () => {
    const logger = createLogger('auth-service');
    
    // Capture console output
    const consoleSpy = jest.spyOn(console, 'log');
    
    logger.info('Test message', { userId: '123' });
    
    expect(consoleSpy).toHaveBeenCalledWith(
      expect.stringContaining('auth-service')
    );
    expect(consoleSpy).toHaveBeenCalledWith(
      expect.stringContaining('userId')
    );
  });
});

E2E Tests Across Services

// __tests__/e2e/event-flow.test.ts
describe('Event Flow E2E', () => {
  it('should publish and consume events with correct types', async () => {
    // Start services
    const authService = await startService('auth');
    const sourcesService = await startService('sources');
    
    // Publish event from auth
    await authService.createUser({ email: 'test@example.com' });
    
    // Verify sources received it
    await eventually(() => {
      const user = sourcesService.findUser('test@example.com');
      expect(user).toBeDefined();
    });
  });
});

Documentation: Make Packages Easy to Use

Every Package Needs a README

# @radarkit/logger

Structured logging for RadarKit microservices.

## Installation
```bash
npm install @radarkit/logger
```

## Quick Start
```typescript
import { createLogger } from '@radarkit/logger';

const logger = createLogger('my-service');

logger.info('User logged in', { userId: '123' });
logger.error('Operation failed', { error: err.message });
```

## API

### createLogger(serviceName, options?)

Creates a Winston logger instance.

**Parameters:**
- `serviceName` (string): Name of your service
- `options.level` (string, optional): Log level. Default: `process.env.LOG_LEVEL || 'info'`
- `options.environment` (string, optional): Environment. Default: `process.env.NODE_ENV`

**Returns:** Winston Logger instance

**Example:**
```typescript
const logger = createLogger('auth-service', {
  level: 'debug',
  environment: 'development'
});
```

## Environment Variables

- `LOG_LEVEL`: Set log level (`debug`, `info`, `warn`, `error`)
- `NODE_ENV`: Environment (`development`, `production`)

## Log Format

**Development:**

10:23:45 [auth-service] info: User logged in { “userId”: “123” }