nternal 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 ResolveAccountByEmailUseCase {
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.
@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
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/, and external/ directories. Define plain TypeScript interfaces for your core entities.
- Declare the port: Write a
Gateway interface 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.