es remain stable regardless of external technology shifts. Domain entities should encapsulate state and behavior, exposing methods that represent business operations rather than raw data accessors.
// domain/entities/Subscription.ts
export class Subscription {
private readonly id: string;
private status: 'active' | 'suspended' | 'expired';
private readonly renewalDate: Date;
constructor(id: string, renewalDate: Date) {
this.id = id;
this.status = 'active';
this.renewalDate = renewalDate;
}
get isActive(): boolean {
return this.status === 'active' && this.renewalDate > new Date();
}
suspend(): void {
if (!this.isActive) {
throw new Error('Cannot suspend an inactive subscription');
}
this.status = 'suspended';
}
renew(newDate: Date): void {
this.renewalDate = newDate;
this.status = 'active';
}
}
Rationale: By encapsulating state transitions and validation within the entity, we prevent business rules from scattering across controllers, services, or UI components. The class exposes behavior, not data. This design aligns with object-oriented principles while remaining framework-agnostic. AI prompts targeting this layer should explicitly exclude imports from @types/express, pg, or UI frameworks to maintain purity.
Step 2: Implement the Application Orchestrator
The application layer coordinates domain objects and infrastructure dependencies. It contains no business rules, only workflow logic. This layer acts as the system's nervous system, translating external requests into domain operations. It manages transactions, handles cross-cutting concerns like logging, and ensures that domain invariants are respected during execution.
// application/ports/SubscriptionRepository.ts
export interface SubscriptionRepository {
findById(id: string): Promise<Subscription | null>;
save(subscription: Subscription): Promise<void>;
}
// application/services/SubscriptionManager.ts
export class SubscriptionManager {
constructor(private readonly repo: SubscriptionRepository) {}
async handleRenewalRequest(subscriptionId: string, newDate: Date): Promise<void> {
const sub = await this.repo.findById(subscriptionId);
if (!sub) {
throw new Error('Subscription not found');
}
sub.renew(newDate);
await this.repo.save(sub);
}
}
Rationale: Dependency injection via constructor ensures the orchestrator depends on abstractions, not concrete storage mechanisms. This makes unit testing trivial and allows infrastructure swaps without touching business logic. The application layer remains thin by design; it delegates validation and state changes to the domain. When using AI generation, prompt specifically for orchestration logic: "Create a service that coordinates repository calls and domain methods without embedding business rules."
Step 3: Build Replaceable Infrastructure Adapters
External systems (databases, third-party APIs, message queues, file storage) must be treated as implementation details. They implement the contracts defined in the application layer, never the other way around. This inversion of control ensures that high-level modules dictate the shape of low-level modules.
// infrastructure/adapters/PostgresSubscriptionRepo.ts
import { SubscriptionRepository } from '../../application/ports/SubscriptionRepository';
import { Subscription } from '../../domain/entities/Subscription';
export class PostgresSubscriptionRepo implements SubscriptionRepository {
constructor(private readonly client: any) {} // e.g., pg.Pool
async findById(id: string): Promise<Subscription | null> {
const result = await this.client.query(
'SELECT id, status, renewal_date FROM subscriptions WHERE id = $1',
[id]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return new Subscription(row.id, new Date(row.renewal_date));
}
async save(subscription: Subscription): Promise<void> {
// Map domain state to DB schema and execute UPDATE
// Implementation details remain isolated here
}
}
Rationale: The repository pattern abstracts persistence mechanics. Switching from PostgreSQL to DynamoDB, Redis, or a mock implementation requires zero changes to the domain or application layers. This adheres to the Dependency Inversion Principle: high-level modules should not depend on low-level modules; both should depend on abstractions. AI generation for this layer should be scoped to specific database drivers or API SDKs, with explicit instructions to conform to the pre-defined interface.
Step 4: Isolate Presentation Logic
The UI layer must remain passive. It captures user input, delegates to the application layer, and renders responses. Business validation, state management, and error handling belong elsewhere. Presentation components should never instantiate repositories or execute domain logic directly.
// presentation/components/RenewalForm.tsx
import { useState } from 'react';
import { SubscriptionManager } from '../../application/services/SubscriptionManager';
interface RenewalFormProps {
manager: SubscriptionManager;
subscriptionId: string;
}
export function RenewalForm({ manager, subscriptionId }: RenewalFormProps) {
const [date, setDate] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await manager.handleRenewalRequest(subscriptionId, new Date(date));
// Trigger success state or navigation
} catch (err) {
// Handle error via context or toast
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} required />
<button type="submit" disabled={loading}>
{loading ? 'Processing...' : 'Renew'}
</button>
</form>
);
}
Rationale: By injecting the orchestrator directly into the component, we eliminate hidden dependencies and global state pollution. The component focuses solely on rendering and event delegation. This separation ensures that UI framework upgrades or redesigns never impact core business logic. AI prompts for this layer should explicitly forbid business rule implementation and enforce prop-driven data flow.
Pitfall Guide
-
Leaking Domain Rules into Controllers
Explanation: Developers frequently move validation logic into HTTP handlers or API routes to save time. This scatters business rules across multiple endpoints, making consistency impossible to maintain and increasing regression risk.
Fix: Enforce a strict rule: controllers only parse requests and format responses. All validation and state transitions must occur within domain entities or value objects. Use middleware to handle authentication and rate limiting, never business logic.
-
Coupling Infrastructure to Business Logic
Explanation: Importing database drivers, ORM methods, or external API clients directly into service classes creates hard dependencies. Changing storage technology or third-party providers requires rewriting core logic.
Fix: Define repository interfaces in the application layer. Implement them in the infrastructure layer. Use dependency injection to wire them at runtime. Never allow domain or application layers to import from infrastructure.
-
Over-Orchestrating the Application Layer
Explanation: Application services sometimes accumulate business rules, transforming into "god classes" that duplicate domain logic. This violates single responsibility, increases cognitive load, and makes testing cumbersome.
Fix: Keep application services strictly procedural. If a service contains if/else branches evaluating business state or calculating pricing, that logic belongs in the domain layer. Application services should only coordinate, not decide.
-
Skipping Interface Contracts
Explanation: AI models generate concrete implementations by default. Without explicit interfaces, components become tightly coupled, preventing mocking, parallel development, and infrastructure swaps.
Fix: Always define contracts first. Use TypeScript interfaces or abstract classes to establish boundaries before generating implementations. Treat interfaces as the source of truth for cross-layer communication.
-
Treating AI Output as Immutable
Explanation: Developers often accept AI-generated code without architectural review. This leads to inconsistent patterns, hidden dependencies, and violation of established boundaries.
Fix: Implement a mandatory review step focused on architectural compliance. Use linters and custom ESLint rules to enforce layer boundaries automatically. Prompt AI with explicit constraints: "Do not import from X layer; adhere to Y interface."
-
Ignoring Error Boundary Propagation
Explanation: Errors thrown in the domain layer often bubble up unhandled, crashing the application or returning vague HTTP 500 responses. This degrades user experience and complicates debugging.
Fix: Implement a centralized error handling middleware that maps domain exceptions to appropriate HTTP status codes and user-friendly messages. Never expose stack traces in production. Use custom error classes with type guards for precise handling.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Early-stage MVP (0-3 months) | Lightweight layering with shared contracts | Balances speed with future-proofing; prevents immediate technical debt | Low initial overhead, high long-term ROI |
| Enterprise-scale system | Strict hexagonal architecture with explicit ports/adapters | Enables parallel team development, compliance auditing, and infrastructure swaps | Higher upfront cost, drastically reduces scaling friction |
| Multi-tenant SaaS | Domain-driven design with tenant-aware repositories | Isolates tenant data logically while sharing infrastructure efficiently | Moderate cost, prevents data leakage and scaling bottlenecks |
| AI-heavy workflow automation | Event-driven application layer with message brokers | Decouples processing from UI, enabling reliable retries and scaling | Higher infrastructure cost, improves system resilience |
Configuration Template
A production-ready dependency injection setup using a lightweight container. This ensures boundaries are enforced at runtime and simplifies testing.
// src/config/container.ts
import { Container } from 'tsyringe';
import { SubscriptionRepository } from '../application/ports/SubscriptionRepository';
import { PostgresSubscriptionRepo } from '../infrastructure/adapters/PostgresSubscriptionRepo';
import { SubscriptionManager } from '../application/services/SubscriptionManager';
import { Pool } from 'pg';
export function configureContainer(): void {
const dbPool = new Pool({ connectionString: process.env.DATABASE_URL });
Container.register<SubscriptionRepository>('SubscriptionRepository', {
useClass: PostgresSubscriptionRepo,
});
Container.register('DatabasePool', { useValue: dbPool });
Container.register<SubscriptionManager>('SubscriptionManager', {
useClass: SubscriptionManager,
});
}
export { Container };
Quick Start Guide
- Initialize the project structure: Create
domain/, application/, infrastructure/, and presentation/ directories. Add a config/ folder for DI setup and a tests/ directory mirroring the source structure.
- Define contracts first: Write TypeScript interfaces for repositories and services in the
application/ports/ directory. Run tsc --noEmit to verify type safety and ensure no cross-layer imports exist.
- Generate implementations: Use AI prompts scoped to specific layers (e.g., "Implement PostgresSubscriptionRepo adhering to the SubscriptionRepository interface"). Review output for boundary compliance and replace AI-generated mocks with proper test doubles.
- Wire dependencies: Execute
configureContainer() at application startup. Resolve orchestrators via Container.resolve() and pass them to UI components or API routes. Avoid global singletons; prefer explicit dependency passing.
- Validate boundaries: Run a custom ESLint rule or script that scans imports. Ensure
domain/ never imports from infrastructure/ or application/. Commit only after validation passes and CI confirms architectural integrity.