API interfaces.
src/
βββ contexts/
β βββ user-onboarding/
β βββ domain/
β β βββ models/
β β βββ ports/
β β βββ services/
β βββ application/
β β βββ use-cases/
β βββ infrastructure/
β β βββ adapters/
β βββ interfaces/
β βββ api/
βββ shared/
β βββ events/
β βββ exceptions/
βββ main.ts
Step 2: Implement Domain Ports and Models
Domain logic must remain framework-agnostic. Define interfaces that describe what the system needs, not how it's implemented.
// contexts/user-onboarding/domain/ports/user-repository.port.ts
export interface IUserRepository {
findByEmail(email: string): Promise<User | null>;
create(user: CreateUserCommand): Promise<User>;
updateReferralCount(userId: string, delta: number): Promise<void>;
}
// contexts/user-onboarding/domain/models/user.model.ts
export class User {
constructor(
public readonly id: string,
public readonly email: string,
public readonly referralCount: number,
public readonly createdAt: Date,
) {}
public canAcceptReferral(limit: number): boolean {
return this.referralCount < limit;
}
}
Step 3: Build the Application Use Case
Application services orchestrate flow without containing business rules. They coordinate ports, handle validation, and manage transaction boundaries.
// contexts/user-onboarding/application/use-cases/register-user.use-case.ts
import { Injectable } from '@nestjs/common';
import { IUserRepository } from '../../domain/ports/user-repository.port';
import { User } from '../../domain/models/user.model';
import { RegisterUserCommand } from './register-user.command';
@Injectable()
export class RegisterUserUseCase {
constructor(private readonly userRepository: IUserRepository) {}
async execute(command: RegisterUserCommand): Promise<User> {
const existing = await this.userRepository.findByEmail(command.email);
if (existing) {
throw new Error('DUPLICATE_EMAIL');
}
const newUser = await this.userRepository.create({
email: command.email,
hashedPassword: command.hashedPassword,
referralCode: command.referralCode,
});
return newUser;
}
}
Step 4: Implement Infrastructure Adapters
Infrastructure code lives outside the domain. It translates framework-specific tools (TypeORM, Prisma, external APIs) into domain contracts.
// contexts/user-onboarding/infrastructure/adapters/typeorm-user.adapter.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IUserRepository } from '../../domain/ports/user-repository.port';
import { UserEntity } from './user.entity';
import { User } from '../../domain/models/user.model';
import { CreateUserCommand } from '../../application/use-cases/register-user.command';
@Injectable()
export class TypeORMUserAdapter implements IUserRepository {
constructor(
@InjectRepository(UserEntity)
private readonly repo: Repository<UserEntity>,
) {}
async findByEmail(email: string): Promise<User | null> {
const entity = await this.repo.findOne({ where: { email } });
return entity ? this.toDomain(entity) : null;
}
async create(command: CreateUserCommand): Promise<User> {
const entity = this.repo.create({
email: command.email,
password: command.hashedPassword,
referral_code: command.referralCode,
});
const saved = await this.repo.save(entity);
return this.toDomain(saved);
}
private toDomain(entity: UserEntity): User {
return new User(
entity.id,
entity.email,
entity.referral_count,
entity.created_at,
);
}
}
Step 5: Wire the Context Module
NestJS modules become composition roots. They bind ports to adapters and expose use cases to controllers.
// contexts/user-onboarding/interfaces/api/user-onboarding.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../../infrastructure/adapters/user.entity';
import { TypeORMUserAdapter } from '../../infrastructure/adapters/typeorm-user.adapter';
import { RegisterUserUseCase } from '../../application/use-cases/register-user.use-case';
import { UserOnboardingController } from './user-onboarding.controller';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
controllers: [UserOnboardingController],
providers: [
{
provide: 'IUserRepository',
useClass: TypeORMUserAdapter,
},
RegisterUserUseCase,
],
exports: [RegisterUserUseCase],
})
export class UserOnboardingModule {}
Architectural Rationale:
- Dependency Inversion: Controllers depend on use cases. Use cases depend on ports. Adapters implement ports. This prevents infrastructure from dictating business logic.
- Explicit Boundaries: Each context owns its data model and rules. Cross-context communication happens via events or explicit application services, not direct repository calls.
- Testability: Domain models and use cases are pure TypeScript. They require no database, no NestJS decorators, and no HTTP mocks. Unit tests run in milliseconds.
- Extractability: When a context outgrows the monolith, it can be extracted as a standalone service with minimal refactoring. The port/adapter layer already isolates infrastructure concerns.
Pitfall Guide
1. Repository Injection in Application Layer
Explanation: Injecting TypeORM repositories directly into use cases or controllers couples business logic to a specific ORM. Switching databases or adding caching requires rewriting application code.
Fix: Always inject domain ports (IUserRepository). Implement adapters in the infrastructure layer. Use NestJS custom providers to bind interfaces to concrete classes.
2. Circular Module Dependencies
Explanation: When AuthModule imports UserModule and UserModule imports AuthModule, NestJS throws dependency resolution errors. This usually happens when business rules are scattered across modules.
Fix: Extract shared contracts into a shared/ or domain/ package. Use event-driven communication for cross-context triggers. If direct calls are necessary, apply forward references sparingly and refactor toward explicit boundaries.
3. God Service Syndrome
Explanation: A single service handles registration, password reset, profile updates, and analytics tracking. It becomes impossible to test, refactor, or scale independently.
Fix: Split by bounded context. UserOnboardingUseCase handles registration. UserProfileUseCase handles updates. AnalyticsDispatcher handles events. Each has a single responsibility and explicit inputs/outputs.
4. Ignoring Transaction Boundaries
Explanation: Wrapping registration, referral validation, analytics logging, and email dispatch in a single database transaction locks rows for seconds. Under load, this causes connection pool exhaustion and deadlocks.
Fix: Use the Unit of Work pattern for core domain operations. Offside effects (analytics, notifications) to an outbox table or message queue. Commit the transaction, then publish events asynchronously.
5. DTO-Driven Domain Modeling
Explanation: Letting API request shapes dictate domain logic. When the frontend adds a field, the domain model changes. Business rules become entangled with presentation concerns.
Fix: Map DTOs to domain commands at the controller edge. Domain models should only contain data required for business rules. Use explicit command objects (RegisterUserCommand) instead of raw DTOs in use cases.
6. Implicit Cross-Context Calls
Explanation: Directly importing ReferralService inside AuthService creates hidden dependencies. Refactoring one module breaks the other. Testing requires mocking unrelated services.
Fix: Use explicit interfaces or domain events. If AuthService needs referral validation, it should call IReferralValidator port. If it needs to notify analytics, it should publish UserRegisteredEvent.
Explanation: Splitting modules into separate services because of coupling, not scaling needs. This introduces network latency, distributed transactions, and operational overhead without solving the root cause.
Fix: Refactor the monolith first. Enforce context boundaries. Extract only when a context has independent scaling requirements, different release cycles, or distinct team ownership.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| MVP / Early Stage | Flat modules with explicit service boundaries | Faster iteration, lower boilerplate | Low initial cost, high refactoring risk later |
| Growing Product (10+ features) | Domain-driven contexts with ports/adapters | Prevents service bloat, enables parallel team work | Moderate setup cost, linear scaling |
| Enterprise Scale / Multi-team | Bounded contexts + event-driven communication | Isolates failure domains, supports independent deployments | High initial investment, lowest long-term operational cost |
| Legacy Monolith | Strangler fig pattern with context extraction | Gradual migration without downtime | Medium cost, reduces technical debt incrementally |
Configuration Template
// shared/architecture/boundary-linter.ts
import { Rule } from 'eslint';
export const noCrossContextImports: Rule.RuleModule = {
meta: {
type: 'problem',
docs: { description: 'Prevents direct imports across bounded contexts' },
},
create(context) {
return {
ImportDeclaration(node) {
const source = node.source.value as string;
const currentFile = context.getFilename();
const currentContext = currentFile.match(/contexts\/([^/]+)/)?.[1];
if (currentContext && source.includes(`contexts/${currentContext}/`)) return;
if (source.includes('contexts/') && !source.includes('shared/')) {
context.report({
node,
message: `Cross-context import detected. Use ports or events instead.`,
});
}
},
};
},
};
// contexts/user-onboarding/infrastructure/adapters/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('users')
export class UserEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column()
password: string;
@Column({ nullable: true })
referral_code: string;
@Column({ default: 0 })
referral_count: number;
@CreateDateColumn()
created_at: Date;
}
Quick Start Guide
- Initialize Context Structure: Create
src/contexts/ and scaffold a new bounded context with domain/, application/, infrastructure/, and interfaces/ subdirectories.
- Define Domain Contracts: Write TypeScript interfaces for repositories and services. Keep them framework-agnostic and focused on business capabilities.
- Implement Adapters: Create infrastructure classes that implement domain contracts. Use TypeORM/Prisma entities here, not in the domain layer.
- Wire NestJS Module: Register custom providers to bind ports to adapters. Export use cases for controllers to consume.
- Enforce Boundaries: Add ESLint rules to block cross-context imports. Run unit tests against domain logic without database dependencies. Verify deployment pipeline passes before merging.