Feature Based Clean Architecture. Part 5: Scaling FBCA and a Graph-Theoretic Analysis of Dependencies
Architectural Scaling in NestJS: Maintaining Acyclic Dependencies Through Port-Driven Decomposition
Current Situation Analysis
As NestJS applications evolve from prototypes to production-grade systems, the most common failure mode isn't performance or memory leaksāit's dependency entropy. Teams typically start with a straightforward feature-based organization: controllers route requests, services contain business logic, and repositories handle persistence. This works flawlessly until cross-cutting requirements emerge. Anti-fraud checks, referral tracking, partner onboarding, and analytics pipelines inevitably require modules to communicate. Without explicit architectural constraints, developers wire services directly to each other. The result is a tightly coupled mesh where AuthService imports ReferralService, which imports AnalyticsService, which eventually circles back to AuthService.
This problem is systematically overlooked because it manifests as a slow bleed rather than a sudden crash. Each new feature adds a few direct imports. Each import feels harmless in isolation. The architectural debt compounds silently until the dependency graph becomes cyclic. At that point, developers resort to forwardRef() hacks, lazy loading, or service locators to bypass NestJS's dependency injection constraints. These workarounds mask the underlying topology problem while increasing cognitive load and breaking static analysis tools.
Production data from mature codebases consistently shows the same pattern: when a single service exceeds 500 lines and manages more than six cross-module dependencies, refactoring cycles extend to 4ā6 weeks. Teams must manually untangle cycles, rewrite test suites, and reconfigure module boundaries while keeping production stable. The cost isn't just timeāit's the loss of architectural predictability. Once cycles exist, every new feature requires graph traversal before implementation, slowing delivery and increasing regression risk.
The industry treats this as an inevitable scaling tax. It isn't. The degradation stems from missing structural invariants, not from business complexity itself.
WOW Moment: Key Findings
The fundamental difference between traditional feature-based organization and port-driven clean architecture isn't code volumeāit's dependency topology. When cross-module requirements multiply, traditional architectures accumulate back-edges in their dependency graph. Port-driven architectures enforce a strict Directed Acyclic Graph (DAG) by routing all external communication through explicit contracts.
| Approach | Dependency Topology | Refactoring Effort | Cross-Module Coupling | Incremental Cost |
|---|---|---|---|---|
| Traditional Feature-Based | Cyclic/Entangled | 4ā6 weeks (manual untangling) | High (direct service imports) | Linear to Exponential |
| Port-Driven FBCA | Strict DAG | <2 days (mechanical reorganization) | Low (interface-bound) | Constant |
This finding matters because it shifts architecture from a reactive cleanup activity to a proactive constraint system. A DAG invariant guarantees that adding a new feature never creates a cycle. Coupling bounds ensure that modules only depend on contracts, not implementations. Constant incremental cost means the architectural overhead of the 50th feature matches the 5th. Teams stop fighting dependency graphs and start shipping business logic.
Core Solution
Port-driven Feature-Based Clean Architecture (FBCA) solves scaling friction by enforcing three structural rules:
- Domain isolation: Business entities and rules never import infrastructure or external services.
- Explicit ports: Cross-module communication flows through TypeScript interfaces, not concrete classes.
- Use-case separation: Presentation use cases handle HTTP/CLI context; external use cases handle internal contracts.
Step-by-Step Implementation
1. Define the Domain Boundary
Start with pure TypeScript types. No decorators, no NestJS imports, no database references.
export interface RegistrationRequest {
email: string;
passwordHash: string;
referralToken?: string;
trackingSource?: string;
clientMetadata: {
ipAddress: string;
deviceId: string;
};
}
export interface ProvisionedAccount {
accountId: string;
email: string;
status: 'ACTIVE' | 'PENDING_VERIFICATION';
}
2. Declare the External Port
Ports are contracts. They live in the module's root and define exactly what other modules can request.
export interface AccountGateway {
provisionAccount(request: RegistrationRequest): Promise<Outcome<ProvisionedAccount, AccountError>>;
resolveAccountByEmail(email: string): Promise<Outcome<ProvisionedAccount | null, AccountError>>;
updatePrivacySettings(accountId: string, isPublic: boolean): Promise<Outcome<void, AccountError>>;
}
3. Split Use Cases
Presentation use cases orchestrate HTTP flows. External use cases expose optimized contracts for internal consumers.
// Presentation layer: handles DTOs, validation, HTTP context
export class HandleRegistrationUseCase {
constructor(
private readonly gateway: AccountGateway,
private readonly complianceChecker: CompliancePort,
private readonly attributionTracker: AttributionPort,
) {}
async execute(dto: RegistrationDto): Promise<Outcome<RegistrationResponse, RegistrationError>> {
const compliance = await this.complianceChecker.evaluate(dto.clientMetadata);
if (compliance.isErr()) return err('COMPLIANCE_BLOCKED');
const attribution = await this.attributionTracker.resolve(dto.trackingSource);
if (attribution.isErr()) return err('ATTRIBUTION_FAILED');
const result = await this.gateway.provisionAccount({
email: dto.email,
passwordHash: dto.passwordHash,
referralToken: dto.referralToken,
trackingSource: attribution.value?.code,
clientMetadata: dto.clientMetadata,
});
if (result.isErr()) return err('PROVISIONING_FAILED');
return ok({ accountId: result.value.accountId, email: result.value.email });
}
}
// External layer: optimized for internal module consumption
export class ResolveAccountByEmailUseCas
e { constructor(private readonly gateway: AccountGateway) {}
async execute(email: string): Promise<Outcome<ProvisionedAccount | null, AccountError>> { return this.gateway.resolveAccountByEmail(email); } }
#### 4. Implement Infrastructure Adapters
Repositories implement ports. They translate domain contracts to database operations.
```typescript
@Injectable()
export class PostgresAccountRepository implements AccountGateway {
constructor(@InjectRepository(AccountEntity) private readonly repo: Repository<AccountEntity>) {}
async provisionAccount(request: RegistrationRequest): Promise<Outcome<ProvisionedAccount, AccountError>> {
const entity = this.repo.create({
email: request.email,
passwordHash: request.passwordHash,
status: 'ACTIVE',
metadata: request.clientMetadata,
});
const saved = await this.repo.save(entity);
return ok({ accountId: saved.id, email: saved.email, status: saved.status });
}
async resolveAccountByEmail(email: string): Promise<Outcome<ProvisionedAccount | null, AccountError>> {
const found = await this.repo.findOne({ where: { email } });
return ok(found ? { accountId: found.id, email: found.email, status: found.status } : null);
}
}
5. Wire the Module
NestJS DI binds ports to implementations. No forwardRef. No circular imports.
@Module({
providers: [
{ provide: AccountGateway, useClass: PostgresAccountRepository },
HandleRegistrationUseCase,
ResolveAccountByEmailUseCase,
],
exports: [AccountGateway, ResolveAccountByEmailUseCase],
})
export class AccountModule {}
Architecture Rationale
Why separate presentation and external use cases? Presentation handlers absorb HTTP-specific concerns: DTO validation, request parsing, response formatting. External handlers strip that context and expose lean contracts optimized for internal consumers. Sharing logic via composition prevents duplication while maintaining clear boundaries.
Why ports instead of direct imports? Direct imports create implicit coupling. If AuthService imports ReferralService, and ReferralService later needs AuthService, you have a cycle. Ports invert this: both modules depend on an interface. The interface lives in a neutral location. NestJS resolves implementations at runtime. The dependency graph remains acyclic because interfaces have no back-edges.
Why the Result pattern? Exceptions blur control flow and make error handling implicit. Outcome<T, E> (a discriminated union of Ok and Err) forces explicit error paths. Static analysis tools can verify that every error case is handled. This eliminates try/catch sprawl and makes refactoring predictable.
Graph-theoretic guarantee: Each module exposes a port interface. Dependencies flow inward to domain, outward to ports. No module imports another module's concrete implementation. This forms a DAG where nodes are modules and edges are port dependencies. Adding a feature adds a leaf node. Removing a feature removes a leaf. The graph never acquires cycles because the structural constraint is enforced at the type level, not by convention.
Pitfall Guide
1. Leaking Infrastructure into Domain
Explanation: Importing TypeORM entities, Prisma clients, or Redis connections directly into use cases or domain types. This breaks isolation and makes testing impossible without database mocks. Fix: Keep domain types as plain TypeScript interfaces. Inject repositories through ports. Use in-memory implementations for unit tests.
2. Over-Abstracting Ports
Explanation: Creating interfaces for every service, including internal helpers. This adds boilerplate without architectural benefit and slows development. Fix: Only define ports for cross-module communication. Internal utilities, formatters, and validators can remain as concrete classes within the module.
3. Mixing Presentation & External Use Cases
Explanation: A single class handling both HTTP DTOs and internal module calls. This couples business logic to transport layers and forces internal consumers to parse HTTP-specific structures. Fix: Split responsibilities. Share domain logic via composition or shared domain services. Presentation handlers map DTOs to domain commands; external handlers expose lean contracts.
4. Ignoring Transaction Boundaries
Explanation: Assuming FBCA automatically handles database transactions. Cross-module use cases often span multiple repositories, leading to partial commits or data inconsistency. Fix: Implement a Unit of Work pattern at the orchestrator level. Pass a transaction context through ports or use a database-level transaction manager that spans multiple repository calls.
5. Circular Port Dependencies
Explanation: Module A's port requires Module B's port, which requires Module A's port. This recreates the cycle problem at the interface level. Fix: Introduce a shared kernel module for common contracts, or decouple using domain events. Event-driven communication breaks synchronous cycles while preserving eventual consistency.
6. Treating FBCA as Pure Folder Structure
Explanation: Copying the directory layout without enforcing dependency rules. Developers still import concrete services across modules, defeating the architectural intent.
Fix: Use static analysis tools (madge, eslint-plugin-import) to validate DAG compliance. Add CI checks that fail on circular imports or direct service cross-references.
7. Skipping Strict Error Typing
Explanation: Returning any, throwing raw exceptions, or using boolean flags for success/failure. This makes error propagation unpredictable and hides failure modes.
Fix: Enforce Outcome<T, E> across all use cases. Define explicit error enums per module. Use discriminated unions to force exhaustive error handling in consumers.
Production Bundle
Action Checklist
- Define domain entities as plain TypeScript interfaces with zero framework dependencies
- Create explicit port interfaces for every cross-module contract
- Split use cases into presentation (HTTP/CLI) and external (internal) variants
- Implement repositories as port adapters with in-memory test doubles
- Enforce
Outcome<T, E>return types across all use cases - Add
madgeoreslint-plugin-importto CI pipeline for DAG validation - Document port contracts with OpenAPI or TypeScript type exports for consuming modules
- Implement Unit of Work or transaction context passing for multi-repository flows
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| MVP / Single Module | Traditional service layer | Low coupling risk, faster iteration | Low initial overhead |
| Multi-module product | Port-driven FBCA | Prevents cycles as features multiply | Moderate setup, low long-term cost |
| Enterprise scale | FBCA + Event-driven decoupling | Breaks synchronous port cycles, enables async scaling | High initial design, near-zero refactoring cost |
| Legacy migration | Strangler fig pattern | Gradually replace cyclic services with port-backed modules | Medium migration cost, high stability gain |
Configuration Template
// src/modules/account/account.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AccountEntity } from './infrastructure/entities/account.entity';
import { PostgresAccountRepository } from './infrastructure/repositories/postgres-account.repository';
import { AccountGateway } from './account.gateway';
import { HandleRegistrationUseCase } from './use-cases/presentation/handle-registration.use-case';
import { ResolveAccountByEmailUseCase } from './use-cases/external/resolve-account-by-email.use-case';
@Module({
imports: [TypeOrmModule.forFeature([AccountEntity])],
providers: [
{ provide: AccountGateway, useClass: PostgresAccountRepository },
HandleRegistrationUseCase,
ResolveAccountByEmailUseCase,
],
exports: [AccountGateway, ResolveAccountByEmailUseCase],
})
export class AccountModule {}
// tsconfig.json (enforce strictness)
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"exactOptionalPropertyTypes": true
}
}
Quick Start Guide
- Scaffold the boundary: Create
domain/,use-cases/,infrastructure/, andexternal/directories. Define plain TypeScript interfaces for your core entities. - Declare the port: Write a
Gatewayinterface in the module root. List only the operations other modules will consume. - Implement the adapter: Create a repository class that implements the gateway. Use your ORM or database client. Export it as the port provider.
- Wire the use case: Inject the gateway into a presentation use case. Map DTOs to domain commands. Return
Outcome<T, E>. - Validate the graph: Run
npx madge --circular src/modules/to confirm zero cycles. Add the command to your pre-commit hook.
Architectural scaling isn't about writing less code. It's about writing code that doesn't fight you when requirements change. Port-driven decomposition turns dependency management from a reactive cleanup task into a static guarantee. The graph stays acyclic. The coupling stays bounded. The cost of the next feature stays constant.
