export interface RouteMetrics {
baseDurationMinutes: number;
distanceKm: number;
}
export interface VehicleProfile {
id: string;
carriesHazmat: boolean;
regionCode: string;
}
export interface TunnelRestriction {
zoneId: string;
isProhibited: boolean;
}
/**
- Pure domain function. Zero infrastructure dependencies.
- Calculates adjusted ETA based on regulatory rest requirements.
*/
export function computeAdjustedEta(
metrics: RouteMetrics,
vehicle: VehicleProfile,
restrictions: TunnelRestriction[]
): number {
let adjustedDuration = metrics.baseDurationMinutes;
if (vehicle.carriesHazmat) {
// Regulatory requirement: 2-hour break every 4 hours of transit
const transitHours = metrics.baseDurationMinutes / 60;
const mandatoryBreaks = Math.floor(transitHours / 4);
adjustedDuration += mandatoryBreaks * 120;
// Tunnel avoidance penalty (simulated routing delay)
const restrictedZones = restrictions.filter(r => r.isProhibited);
adjustedDuration += restrictedZones.length * 15; // 15 min detour per restricted zone
}
return Math.round(adjustedDuration);
}
**Rationale:** By isolating the ETA calculation into a pure function, we eliminate the need for mocks during unit testing. The function's contract is explicit, its behavior is deterministic, and it can be verified with simple input/output assertions. This is the foundation of testable architecture.
### Step 2: Define Ports (Interfaces)
Ports are abstract contracts that describe what the application needs from the outside world. They live in the application or domain layer and are implemented by adapters in the infrastructure layer.
```typescript
// ports/VehicleRegistry.ts
import { VehicleProfile } from '../domain/HazardousCargoPolicy';
export interface VehicleRegistry {
findById(vehicleId: string): Promise<VehicleProfile | null>;
}
// ports/GeospatialNavigator.ts
import { RouteMetrics } from '../domain/HazardousCargoPolicy';
export interface GeospatialNavigator {
calculatePath(
origin: [number, number],
destination: [number, number],
avoidZones: string[]
): Promise<RouteMetrics>;
}
// ports/RoutePersistence.ts
export interface RoutePersistence {
storeCalculatedRoute(
vehicleId: string,
etaMinutes: number,
timestamp: Date
): Promise<void>;
}
Rationale: Ports invert dependency direction. Instead of business logic depending on infrastructure, infrastructure depends on business contracts. This enables swapping implementations (e.g., Prisma β Drizzle, Mapbox β OSRM) without touching domain code. It also clarifies testing boundaries: adapters are tested separately, while the application layer is tested using lightweight test doubles.
Step 3: Build the Application Orchestrator
The application layer coordinates workflows. It retrieves data via ports, passes it to domain functions, and persists results. It contains no business rules, only orchestration logic.
// application/RouteCalculationOrchestrator.ts
import { computeAdjustedEta, TunnelRestriction } from '../domain/HazardousCargoPolicy';
import { VehicleRegistry } from '../ports/VehicleRegistry';
import { GeospatialNavigator } from '../ports/GeospatialNavigator';
import { RoutePersistence } from '../ports/RoutePersistence';
export class RouteCalculationOrchestrator {
constructor(
private readonly vehicleRegistry: VehicleRegistry,
private readonly navigator: GeospatialNavigator,
private readonly routePersistence: RoutePersistence,
private readonly restrictionProvider: { getTunnelRestrictions(region: string): Promise<TunnelRestriction[]> }
) {}
async execute(
vehicleId: string,
destination: [number, number]
): Promise<number> {
const vehicle = await this.vehicleRegistry.findById(vehicleId);
if (!vehicle) {
throw new Error('VEHICLE_NOT_FOUND');
}
const restrictions = vehicle.carriesHazmat
? await this.restrictionProvider.getTunnelRestrictions(vehicle.regionCode)
: [];
const prohibitedZones = restrictions
.filter(r => r.isProhibited)
.map(r => r.zoneId);
const routeMetrics = await this.navigator.calculatePath(
[0, 0], // origin would be fetched from vehicle state in production
destination,
prohibitedZones
);
const finalEta = computeAdjustedEta(routeMetrics, vehicle, restrictions);
await this.routePersistence.storeCalculatedRoute(vehicleId, finalEta, new Date());
return finalEta;
}
}
Rationale: The orchestrator acts as a thin coordination layer. It handles error boundaries, data transformation, and sequencing, but delegates all rule evaluation to the domain. This separation ensures that infrastructure failures (timeouts, connection drops) are caught at the edge, while business logic remains insulated and predictable.
Step 4: Wire Infrastructure Adapters
Adapters implement ports using concrete technologies. They translate external data formats into domain contracts and handle infrastructure-specific concerns like retries, caching, and connection pooling.
// infrastructure/PrismaVehicleAdapter.ts
import { PrismaClient } from '@prisma/client';
import { VehicleRegistry } from '../ports/VehicleRegistry';
import { VehicleProfile } from '../domain/HazardousCargoPolicy';
export class PrismaVehicleAdapter implements VehicleRegistry {
constructor(private readonly db: PrismaClient) {}
async findById(vehicleId: string): Promise<VehicleProfile | null> {
const record = await this.db.vehicle.findUnique({
where: { id: vehicleId },
select: { id: true, carriesHazmat: true, regionCode: true }
});
if (!record) return null;
return {
id: record.id,
carriesHazmat: record.carriesHazmat,
regionCode: record.regionCode
};
}
}
Rationale: Adapters are the only layer allowed to import infrastructure libraries. They map external schemas to domain models, ensuring that the rest of the application remains technology-agnostic. This pattern also simplifies testing: adapters can be replaced with in-memory implementations or contract tests, while domain and application layers use pure mocks.
Pitfall Guide
Architectural decoupling introduces new responsibilities. Missteps during implementation can negate the benefits or introduce unnecessary complexity. The following pitfalls are drawn from production deployments and CI/CD telemetry.
1. Leaking Infrastructure Types into the Domain Layer
Explanation: Importing PrismaClient, RedisClient, or HTTP response objects into domain functions breaks isolation. The domain becomes coupled to specific libraries, defeating the purpose of ports.
Fix: Define domain interfaces explicitly. Use plain TypeScript types or Zod schemas for validation. Never pass raw infrastructure payloads to domain functions; map them at the adapter boundary.
2. Over-Abstracting Simple Operations
Explanation: Creating ports for every minor operation leads to interface bloat and indirection overhead. Not every database query warrants a port.
Fix: Apply ports only to boundaries that change independently or require testing isolation. Simple CRUD operations can remain in repositories if they don't contain business logic. Reserve abstraction for workflows with regulatory, pricing, or routing rules.
3. Testing Adapters as Domain Logic
Explanation: Writing unit tests that verify adapter behavior using domain assertions mixes concerns. Adapters should be tested via contract tests or integration suites, not pure unit tests.
Fix: Use property-based testing for adapters. Verify that they correctly map external responses to domain contracts. Keep unit tests focused on domain functions and orchestrator sequencing.
4. Ignoring Error Boundary Contracts
Explanation: Infrastructure failures (timeouts, rate limits, schema mismatches) propagate as unhandled exceptions if not explicitly modeled.
Fix: Define explicit error types at the port level. Use result objects or discriminated unions to represent success/failure states. The orchestrator should catch infrastructure errors and translate them into domain-appropriate responses.
5. Synchronous Blocking in Async Flows
Explanation: Mixing synchronous domain calculations with async infrastructure calls without proper sequencing causes race conditions or stale data.
Fix: Keep domain functions synchronous and pure. Perform all async operations in the orchestrator or adapters. Use Promise.all for independent calls, and await sequentially for dependent workflows.
6. Caching Logic Embedded in Business Rules
Explanation: Embedding cache invalidation or TTL logic inside domain functions couples business rules to performance optimizations.
Fix: Move caching to adapters or a dedicated caching layer. The domain should only care about data correctness, not data freshness. Use cache-aside or write-through patterns at the infrastructure boundary.
7. Missing Dependency Injection Configuration
Explanation: Manually wiring dependencies in every file leads to tight coupling and makes testing difficult.
Fix: Centralize dependency injection. Use a lightweight factory pattern or a DI container. Register adapters, ports, and orchestrators in a single composition root. This enables easy swapping of implementations for testing and deployment.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-compliance routing (Hazmat, medical, financial) | Hexagonal / Ports & Adapters | Isolates regulatory rules from infrastructure changes; enables rapid audit and testing | Higher initial setup, lower long-term maintenance |
| Rapid prototyping / MVP | Layered Architecture (Controller β Service β Repository) | Faster iteration; acceptable when rules are simple and infra is stable | Lower initial cost, higher refactoring risk as rules complexify |
| Microservices with shared domain contracts | Modular Monolith with explicit boundaries | Reduces network overhead while preserving testability and deployment independence | Moderate setup, optimal for teams scaling beyond 3 services |
| Event-driven / Serverless workloads | Ports & Adapters + Event Sourcing | Decouples state management from compute; adapters handle external integrations asynchronously | Higher architectural complexity, excellent scalability |
Configuration Template
// src/composition-root.ts
import { PrismaClient } from '@prisma/client';
import { PrismaVehicleAdapter } from './infrastructure/PrismaVehicleAdapter';
import { MapboxNavigatorAdapter } from './infrastructure/MapboxNavigatorAdapter';
import { RouteCalculationOrchestrator } from './application/RouteCalculationOrchestrator';
export function createRouteCalculator() {
const db = new PrismaClient();
const vehicleRegistry = new PrismaVehicleAdapter(db);
const navigator = new MapboxNavigatorAdapter(process.env.MAPBOX_TOKEN);
const routePersistence = new PrismaRouteAdapter(db);
const restrictionProvider = {
getTunnelRestrictions: async (region: string) => {
// Fetch from Redis, DB, or external compliance API
return [];
}
};
return new RouteCalculationOrchestrator(
vehicleRegistry,
navigator,
routePersistence,
restrictionProvider
);
}
// Usage in API handler or CLI
const calculator = createRouteCalculator();
const eta = await calculator.execute('truck-8842', [40.7128, -74.0060]);
Quick Start Guide
- Initialize project structure: Create
domain/, ports/, application/, and infrastructure/ directories. Keep domain/ free of external imports.
- Define domain contracts: Write pure functions for business rules. Use TypeScript interfaces for input/output shapes. Run unit tests with
vitest or jest using only plain objects.
- Create port interfaces: Specify exactly what data the application requires. Avoid method names that imply infrastructure (e.g., use
findById instead of queryDatabase).
- Wire the composition root: Instantiate adapters, pass them to the orchestrator, and export a factory function. Keep infrastructure setup isolated from business logic.
- Validate boundaries: Run
madge or dependency-cruiser to verify that domain/ has zero dependencies on infrastructure/. Fix any circular or upward dependencies before deployment.
Architectural discipline is not about avoiding AI-generated code; it's about establishing boundaries that AI cannot inherently respect. By enforcing ports, isolating domain logic, and centralizing infrastructure wiring, teams transform brittle, coupled functions into resilient, testable systems. The upfront investment in structure pays dividends in CI/CD velocity, refactoring safety, and long-term maintainability.