Feature Based Clean Architecture. Part 1: How a NestJS Application Evolves Into an Unmaintainable State
Architectural Decay in NestJS: Preventing Service Bloat Through Domain-Driven Constraints
Current Situation Analysis
The standard NestJS project layout, heavily promoted in official documentation, organizes code by technical feature or resource: modules/auth/, modules/users/, modules/products/. Each module typically contains a controller, service, DTOs, and entity definitions. This structure feels intuitive during initial development. It aligns with CRUD thinking, reduces boilerplate, and allows teams to ship MVPs rapidly.
The problem emerges when business logic compounds. Real products do not grow linearly; they grow dimensionally. A simple registration flow eventually requires anti-abuse checks, referral tracking, marketing attribution, email verification, and third-party analytics. When these requirements are added to a flat module structure, developers naturally extend the nearest service. AuthService begins querying marketing tables. UserService starts handling payment webhooks. NotificationService accumulates business rules that belong in the domain layer.
This degradation is overlooked because it happens incrementally. Each individual change passes code review. Each service method remains under 50 lines. But the cumulative effect is severe: cross-module dependencies multiply, testing becomes integration-heavy, and refactoring triggers cascading breakages. Teams eventually hit a wall where adding a single feature requires touching five unrelated modules. The perceived solution is often premature microservice extraction. Splitting a monolith because of architectural coupling, rather than independent scaling requirements, multiplies deployment complexity, introduces distributed transaction failures, and increases infrastructure costs by 3-5x without resolving the underlying design flaw.
Industry data from engineering productivity studies consistently shows that teams using flat, resource-based module layouts experience a 40-60% increase in cycle time after 12-18 months of active development. The cost isn't just in developer hours; it's in delayed feature releases, increased defect rates, and the operational tax of managing tightly coupled distributed systems.
WOW Moment: Key Findings
The structural choice made at project inception dictates the cost of change at scale. When business requirements cross technical boundaries, flat module layouts force services to become orchestrators. Domain-driven constraints isolate business rules, making them testable, replaceable, and extractable without rewriting infrastructure.
| Approach | Cross-Module Coupling | Unit Test Isolation | Refactoring Velocity | Microservice Migration Cost |
|---|---|---|---|---|
| Flat Resource Modules | High (Direct service imports) | Low (Requires DB mocks) | Slow (Ripple effects) | High (Shared state/DB) |
| Domain-Driven Constraints | Low (Port/Adapter boundaries) | High (Pure logic testing) | Fast (Context isolation) | Low (Extractable boundaries) |
This comparison reveals a critical insight: architectural decay is not inevitable. It is a direct consequence of allowing infrastructure concerns and cross-cutting business rules to bleed into shared services. By enforcing explicit boundaries between domain logic, application orchestration, and infrastructure implementation, teams maintain linear scaling costs regardless of feature count.
Core Solution
The remedy is not abandoning NestJS, but restructuring how modules are composed. Instead of organizing by technical resource, organize by bounded context. Each context exposes explicit contracts (ports) and hides implementation details (adapters). This enforces dependency inversion and prevents business logic from leaking into controllers or infrastructure layers.
Step 1: Define the Bounded Context Structure
Replace the flat modules/ directory with a context-aware layout. Each context contains its own domain models, application use cases, infrastructure adapters, and 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.
// cont
exts/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.
```typescript
// 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.
7. Premature Microservice Extraction
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
- Audit existing modules for cross-cutting business logic and refactor into bounded contexts
- Replace direct repository injections with domain ports and infrastructure adapters
- Implement explicit command objects to decouple API DTOs from domain logic
- Move side effects (analytics, notifications) to outbox tables or message queues
- Add architectural linting rules to prevent cross-context direct imports
- Write unit tests for domain models and use cases without database mocks
- Document context boundaries and event contracts in a shared architecture guide
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 withdomain/,application/,infrastructure/, andinterfaces/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.
