| name | monorepo-architecture |
| description | TypeScript monorepo structure - module boundaries, dependency direction, god objects, abstraction discipline, configuration management, interface design. Use when creating packages, refactoring cross-package imports, or reviewing architecture. |
| keywords | monorepo, module boundaries, circular dependency, barrel file, god object, feature envy, abstraction, Rule of Three, composition over inheritance, configuration, dependency management, workspace protocol, interface design, options object, Clean Architecture, SOLID |
| related | error-handling-patterns, typescript-type-safety, domain-driven-design, safe-refactoring, dependency-injection-patterns |
Monorepo Architecture Patterns
Production-grade rules for TypeScript monorepos with 15+ packages. Covers module boundaries, class design, abstraction discipline, config management, dependency hygiene, and interface design.
Reference: docs/deepresearch/reports/TypeScript Monorepo Architecture Patterns.md
Non-Negotiable Rules
| # | Rule | Rationale |
|---|
| 1 | Dependencies point inward only (infra -> application -> domain) | Prevents circular deps; enables independent deployment |
| 2 | Public API = package index.ts only; no deep imports (@pkg/foo/internal/bar) | Encapsulation; safe refactoring |
| 3 | No circular dependencies between packages | Use madge --circular or depcruise to detect |
| 4 | No barrel files with export * (the "barrel of death") | Defeats tree-shaking; hides coupling |
| 5 | Classes <= 500 LOC, <= 20 public methods | God object = split by SRP |
| 6 | Rule of Three: abstract on 3rd identical occurrence, not before | Wrong abstraction is 5-10x costlier than duplication |
| 7 | One validated config object per app; parse at startup, never at import time | Fail fast; typed; single source of truth |
| 8 | process.env in <= 1 file per package (config module) | Scattered env reads = untraceable config |
| 9 | Functions with >4 params -> options object | Readable callsites; easy extension |
| 10 | Internal packages use workspace:*; external deps only if >200 LOC to implement | Controls dependency surface |
Pattern 1: Module Boundaries
WRONG - UI depends on core, circular imports, barrel of death
import { apiClient } from '@myapp/core';
import { User } from '@myapp/domain';
import { Button } from '@myapp/ui';
export * from './utils';
export * from './types';
export * from './constants';
CORRECT - Layered architecture with dependency inversion
export interface UserRepository {
findById(id: string): Promise<User>;
}
import type { UserRepository } from '@myapp/domain';
export class PostgresUserRepo implements UserRepository { ... }
import { useUser } from '@myapp/application';
Ownership rule: A package belongs where its abstraction lives. Concrete implementations go in the outer layer.
Smells: >5 cross-package imports in one file; any export * barrel.
Pattern 2: God Objects & Feature Envy
WRONG - One class doing everything
class UserService {
getUser(id: string) { ... }
saveUser(user: User) { ... }
sendWelcomeEmail(user: User) { ... }
calculateLifetimeValue(user: User) { ... }
formatUserForUI(user: User) { ... }
}
CORRECT - Single Responsibility, Tell-Don't-Ask
export class UserService {
constructor(
private repo: UserRepository,
private email: EmailService,
) {}
async register(userData: CreateUserDto) { ... }
}
await emailService.sendWelcome(newUser);
await billingService.recordLifetimeValue(newUser);
Feature Envy test: Does the method use >50% of data/methods from another object? Move it there.
Refactoring sequence: (1) Identify envied methods -> Move Method, (2) Extract Class for clusters, (3) Introduce facade for callers during migration.
Pattern 3: Abstraction Discipline (Rule of Three)
WRONG - Premature abstraction
abstract class BasePaymentProcessor {
protected abstract validate(...): void;
protected abstract charge(...): void;
}
class StripeProcessor extends BasePaymentProcessor { ... }
class PaypalProcessor extends BasePaymentProcessor { ... }
CORRECT - Duplicate first, abstract on third occurrence
const formatCurrency = (amount: number, locale: string) => ...;
export const money = {
format: (amount: number, locale = 'en-US') => ...,
parse: (raw: string) => ...,
};
Decision framework:
- Can't name the abstraction with a clear domain term? -> Duplicate.
- Business concept used in >=3 places with same variation points? -> Abstract.
- Uncertain? -> Duplicate. Wrong abstraction costs 5-10x more.
Smells: Boolean params that change behavior (process(..., isRecurring: boolean)); inheritance depth >2; abstract classes with <=2 concrete subclasses.
Pattern 4: Configuration Management
WRONG - Scattered process.env reads
const apiUrl = process.env.API_URL ?? 'https://default.com';
const dbUrl = process.env.DATABASE_URL!;
CORRECT - Single validated config at startup
import { z } from 'zod';
export const configSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
DATABASE_URL: z.string().url(),
STRIPE_SECRET: z.string().min(1),
});
export type Config = z.infer<typeof configSchema>;
const raw = { ...process.env, ...loadEnvFile() };
export const config = configSchema.parse(raw);
Already enforced in AegisCore: See config.ts pattern in CLAUDE.md rule #3. This pattern reinforces why: fail-fast at startup, not at runtime when a missing var is first accessed.
Pattern 5: Dependency Management
WRONG - Uncontrolled dependencies
{
"dependencies": {
"lodash": "^4.17.21",
"moment": "^2.30.1",
"left-pad": "^1.3.0"
}
}
CORRECT - Minimal, workspace-aware
{
"dependencies": {
"@aegis/domain": "workspace:*",
"@aegis/config": "workspace:*",
"zod": "^3.23.8",
"date-fns": "^3.6.0"
}
}
Add a dependency only if: (1) battle-tested and solves real pain, OR (2) implementing yourself would exceed ~200 LOC + ongoing maintenance.
Smells: >30 direct deps per package; duplicate transitive deps (bun pm ls shows duplicates); version drift across packages for same library.
Pattern 6: Interface Design
WRONG - Long parameter lists
function processPayment(
amount: number,
userId: string,
currency: string,
isRecurring: boolean,
discountCode?: string,
metadata?: Record<string, any>,
onSuccess?: () => void,
) { ... }
CORRECT - Options object for 4+ params
interface ProcessPaymentOptions {
amount: number;
userId: string;
currency?: string;
isRecurring?: boolean;
discountCode?: string;
metadata?: Record<string, unknown>;
onSuccess?: () => void;
}
function processPayment(options: ProcessPaymentOptions) { ... }
Threshold: <=3-4 positional params is fine. >4 -> options object. Builder pattern only for step-wise validation with many optional fields (rare in TS).
Smells: >5 parameters; multiple boolean flags; inconsistent naming across codebase (getUser vs fetchUserData).
Common Anti-Patterns Checklist
Before submitting code, verify:
See Also
- [[error-handling-patterns]] - Error handling within and across package boundaries
- [[typescript-type-safety]] - Type safety fundamentals (unknown vs any, import type, satisfies, generics)
- [[domain-driven-design]] - DDD strategic/tactical patterns (bounded contexts, aggregates, domain events)
- [[safe-refactoring]] - Safe refactoring process (characterization tests, baby steps, Expand-Migrate-Contract, Strangler Fig)