Loading content...
The iconic Clean Architecture diagram shows four concentric circles, but the diagram itself tells only part of the story. What exactly belongs in each circle? What are the design principles that govern each layer? How do they collaborate while respecting the Dependency Rule?
In this page, we'll dissect each layer with the depth it deserves. We'll see that each layer has distinct characteristics, naming conventions, and responsibilities. Understanding these distinctions is crucial for implementing Clean Architecture correctly—and for recognizing when something is in the wrong layer.
By the end of this page, you will understand what belongs in each of the four layers, the design principles that govern each, how data flows through the layers, and how to identify when code is misplaced. You'll gain the pattern recognition needed to organize any codebase cleanly.
The innermost circle contains Entities—the enterprise-wide business rules. These are the most general and highest-level rules, the policies that would exist even if there were no computer system at all.
What Are Entities?
Entities encapsulate enterprise-wide critical business rules. An entity can be an object with methods, or it can be a set of data structures and functions. It doesn't matter, so long as the entities can be used by many different applications in the enterprise.
If you're building a banking system, the concept of an Account with its rules about minimum balances, overdraft protection, and interest calculation exists independent of any software. These rules would apply whether handled by paper ledgers, desktop software, or a mobile app.
Characteristics of Entities:
Application Agnostic: Entities don't know which application is using them. They're not tailored to any particular UI or delivery mechanism.
Most Stable: Entities change only when enterprise business rules change—typically the rarest kind of change.
Self-Contained Logic: Entities contain the logic that is true everywhere in the enterprise, regardless of the specific use case.
No Framework Dependencies: Entities are pure domain objects. They don't import frameworks, ORMs, or external libraries.
Testable in Isolation: An entity can be instantiated and tested with no setup, mocking, or infrastructure.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
/** * Enterprise Business Rule: Account * * This entity represents a bank account with all the business rules * that are true enterprise-wide, regardless of application. */export class Account { private readonly minimumBalance: number = 0; private readonly overdraftLimit: number = 0; private _balance: number; private status: AccountStatus = 'active'; constructor( public readonly id: AccountId, public readonly ownerId: CustomerId, initialBalance: number, public readonly type: AccountType, options?: AccountOptions ) { if (initialBalance < 0) { throw new InvalidAccountError('Initial balance cannot be negative'); } this._balance = initialBalance; if (options?.overdraftLimit) { this.overdraftLimit = options.overdraftLimit; } } get balance(): number { return this._balance; } get availableFunds(): number { return this._balance + this.overdraftLimit; } /** * Enterprise rule: Withdraw funds with overdraft protection */ withdraw(amount: Money): void { if (amount.value <= 0) { throw new InvalidTransactionError('Withdrawal amount must be positive'); } if (this.status !== 'active') { throw new AccountInactiveError('Cannot withdraw from inactive account'); } if (amount.value > this.availableFunds) { throw new InsufficientFundsError( `Cannot withdraw ${amount.value}. Available: ${this.availableFunds}` ); } this._balance -= amount.value; } /** * Enterprise rule: Deposit funds */ deposit(amount: Money): void { if (amount.value <= 0) { throw new InvalidTransactionError('Deposit amount must be positive'); } if (this.status === 'closed') { throw new AccountClosedError('Cannot deposit to closed account'); } this._balance += amount.value; } /** * Enterprise rule: Calculate interest based on account type */ calculateMonthlyInterest(): Money { const rate = this.getInterestRate(); const interest = this._balance * (rate / 12); return new Money(Math.max(0, interest), this.currency); } private getInterestRate(): number { switch (this.type) { case 'savings': return 0.025; // 2.5% APY case 'checking': return 0.001; // 0.1% APY case 'money-market': return 0.04; // 4% APY default: return 0; } } freeze(): void { this.status = 'frozen'; } unfreeze(): void { if (this.status === 'frozen') { this.status = 'active'; } } close(): void { if (this._balance !== 0) { throw new NonZeroBalanceError('Cannot close account with non-zero balance'); } this.status = 'closed'; }} type AccountStatus = 'active' | 'frozen' | 'closed';type AccountType = 'checking' | 'savings' | 'money-market';Notice that an Entity is not just a data container. It has behavior—business rules implemented as methods. An Account doesn't just hold a balance; it enforces withdrawal rules, calculates interest, and manages its lifecycle. This distinguishes entities from anemic data models.
The second circle contains Use Cases—the application-specific business rules. While entities capture what's true enterprise-wide, use cases capture what this particular application does.
What Are Use Cases?
A use case orchestrates the flow of data to and from entities, and directs those entities to use their enterprise-wide rules to achieve the goals of the use case. A use case represents a single, specific action the user wants to perform.
Characteristics of Use Cases:
Application Specific: Unlike entities, use cases are tailored to a specific application. A TransferFunds use case might exist in the banking app but not in the ATM software (which might have CashWithdrawal instead).
Orchestration Logic: Use cases coordinate between entities, repositories, and external services to accomplish a goal. They don't implement the business rules—entities do—but they orchestrate their application.
Input/Output Ports: Use cases define what input they need and what output they produce. These are simple data structures, not HTTP requests or database rows.
No Framework Knowledge: Use cases know nothing about how they're invoked (HTTP, CLI, message queue) or how data is persisted (PostgreSQL, MongoDB, files).
Defines Dependencies as Interfaces: When a use case needs something from the outside world (like persistent storage), it defines an interface—a port—that the outer layers must implement.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
/** * Application Business Rule: Transfer Funds * * This use case orchestrates a fund transfer between accounts. * It uses entity business rules but adds application-specific logic. */ // Input DTO - What the use case needsexport interface TransferFundsInput { sourceAccountId: string; destinationAccountId: string; amount: number; currency: string; memo?: string;} // Output DTO - What the use case producesexport interface TransferFundsOutput { transactionId: string; sourceNewBalance: number; destinationNewBalance: number; timestamp: Date; status: 'completed' | 'pending' | 'failed';} // Port - What the use case needs from the outside worldexport interface AccountRepository { findById(id: string): Promise<Account | null>; save(account: Account): Promise<void>;} export interface TransactionLogger { log(transaction: TransactionRecord): Promise<void>;} export interface TransferNotifier { notifyTransferComplete( sourceOwnerId: string, destOwnerId: string, amount: Money ): Promise<void>;} export class TransferFunds { constructor( private readonly accountRepo: AccountRepository, private readonly transactionLogger: TransactionLogger, private readonly notifier: TransferNotifier ) {} async execute(input: TransferFundsInput): Promise<TransferFundsOutput> { // Validate input if (input.amount <= 0) { throw new InvalidTransferError('Transfer amount must be positive'); } if (input.sourceAccountId === input.destinationAccountId) { throw new InvalidTransferError('Cannot transfer to the same account'); } // Load entities const source = await this.accountRepo.findById(input.sourceAccountId); const destination = await this.accountRepo.findById(input.destinationAccountId); if (!source || !destination) { throw new AccountNotFoundError('One or both accounts not found'); } // Create money value object const money = new Money(input.amount, input.currency); // Execute the transfer using entity business rules // The entities enforce their own rules (overdraft, account status, etc.) source.withdraw(money); destination.deposit(money); // Generate transaction const transactionId = generateTransactionId(); const timestamp = new Date(); // Persist changes await this.accountRepo.save(source); await this.accountRepo.save(destination); // Log the transaction (for audit) await this.transactionLogger.log({ id: transactionId, type: 'transfer', sourceAccountId: input.sourceAccountId, destinationAccountId: input.destinationAccountId, amount: money, timestamp, memo: input.memo, }); // Notify account holders (fire and forget) this.notifier.notifyTransferComplete( source.ownerId, destination.ownerId, money ).catch(e => console.error('Notification failed:', e)); return { transactionId, sourceNewBalance: source.balance, destinationNewBalance: destination.balance, timestamp, status: 'completed', }; }}The third circle contains Interface Adapters—the code that converts data from the format most convenient for use cases and entities to the format most convenient for external agencies like the web, databases, or third-party services.
What Are Interface Adapters?
Think of adapters as translators. On the inbound side, a controller receives an HTTP request and translates it into a use case input. On the outbound side, a repository takes a domain entity and translates it into database rows.
Types of Adapters:
Characteristics of Adapters:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
/** * Adapter: HTTP Controller for Fund Transfers * * This controller translates HTTP requests into use case calls * and use case responses into HTTP responses. */import { Request, Response, NextFunction } from 'express';import { TransferFunds, TransferFundsInput } from '../../use-cases/transfer-funds'; export class TransferController { constructor(private readonly transferFunds: TransferFunds) {} async handle(req: Request, res: Response, next: NextFunction): Promise<void> { try { // Translate HTTP request to use case input const input: TransferFundsInput = { sourceAccountId: req.body.from_account, destinationAccountId: req.body.to_account, amount: parseFloat(req.body.amount), currency: req.body.currency || 'USD', memo: req.body.memo, }; // Validate HTTP-specific concerns if (!req.body.from_account || !req.body.to_account) { res.status(400).json({ error: 'Bad Request', message: 'from_account and to_account are required' }); return; } // Execute use case const output = await this.transferFunds.execute(input); // Translate use case output to HTTP response res.status(200).json({ success: true, data: { transaction_id: output.transactionId, source_balance: output.sourceNewBalance, destination_balance: output.destinationNewBalance, completed_at: output.timestamp.toISOString(), }, }); } catch (error) { // Translate domain errors to HTTP errors if (error instanceof InsufficientFundsError) { res.status(422).json({ error: 'Unprocessable Entity', message: 'Insufficient funds for transfer', }); } else if (error instanceof AccountNotFoundError) { res.status(404).json({ error: 'Not Found', message: 'One or both accounts not found', }); } else { next(error); // Let error middleware handle unknown errors } } }}1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
/** * Adapter: PostgreSQL Repository for Accounts * * This adapter implements the AccountRepository port defined * by use cases using PostgreSQL for persistence. */import { Pool, PoolClient } from 'pg';import { AccountRepository } from '../../use-cases/ports/account-repository';import { Account, AccountId, CustomerId, AccountType } from '../../entities/account'; export class PostgresAccountRepository implements AccountRepository { constructor(private readonly pool: Pool) {} async findById(id: string): Promise<Account | null> { const result = await this.pool.query( `SELECT id, owner_id, balance, account_type, overdraft_limit, status, created_at FROM accounts WHERE id = $1`, [id] ); if (result.rows.length === 0) { return null; } return this.toDomainEntity(result.rows[0]); } async save(account: Account): Promise<void> { await this.pool.query( `INSERT INTO accounts (id, owner_id, balance, account_type, overdraft_limit, status) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (id) DO UPDATE SET balance = EXCLUDED.balance, status = EXCLUDED.status`, [ account.id, account.ownerId, account.balance, account.type, account.overdraftLimit, account.status, ] ); } async findByOwnerId(ownerId: string): Promise<Account[]> { const result = await this.pool.query( 'SELECT * FROM accounts WHERE owner_id = $1', [ownerId] ); return result.rows.map(row => this.toDomainEntity(row)); } // Translation layer: Database row → Domain Entity private toDomainEntity(row: DatabaseAccountRow): Account { return new Account( new AccountId(row.id), new CustomerId(row.owner_id), parseFloat(row.balance), row.account_type as AccountType, { overdraftLimit: parseFloat(row.overdraft_limit), status: row.status, } ); }} interface DatabaseAccountRow { id: string; owner_id: string; balance: string; // Postgres returns DECIMAL as string account_type: string; overdraft_limit: string; status: string; created_at: Date;}Notice that adapters contain no business logic. The controller doesn't decide whether the transfer is valid—it just translates HTTP to use case input. The repository doesn't calculate balances—it just saves whatever the entity tells it. If you find yourself adding 'if' statements about business rules in an adapter, that logic belongs in an entity or use case.
The outermost circle contains Frameworks and Drivers—the glue code that connects your application to the outside world. This is where you configure Express, set up database connections, initialize dependency injection containers, and wire everything together.
What Belongs Here?
Characteristics of the Frameworks Layer:
Purely Technical: This layer contains no business logic whatsoever—only technical glue.
Highly Volatile: Frameworks change frequently. This layer absorbs that volatility.
Thin and Focused: Code here should be minimal. Most logic belongs in adapters or deeper.
Replaceable: You should be able to swap Express for Fastify by changing only this layer.
Composition Root: This is typically where dependency injection happens—where all the pieces are assembled.
1234567891011121314151617181920212223242526272829303132333435
/** * Frameworks Layer: Express Application Setup * * This file contains only framework configuration and wiring. * No business logic lives here. */import express, { Express } from 'express';import helmet from 'helmet';import cors from 'cors';import { createContainer } from './container';import { createRoutes } from './routes';import { errorHandler } from './middleware/error-handler';import { requestLogger } from './middleware/request-logger'; export function createApp(): Express { const app = express(); // Framework middleware app.use(helmet()); app.use(cors()); app.use(express.json()); app.use(requestLogger); // Build dependency container const container = createContainer(); // Register routes (adapters are injected via container) const routes = createRoutes(container); app.use('/api/v1', routes); // Error handling app.use(errorHandler); return app;}12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
/** * Frameworks Layer: Dependency Injection Container * * This is the "Composition Root" where all pieces are assembled. * Inner layers are instantiated and wired together here. */import { Pool } from 'pg';import { config } from './config'; // Adaptersimport { PostgresAccountRepository } from '../adapters/repositories/postgres-account-repository';import { PostgresTransactionLogger } from '../adapters/loggers/postgres-transaction-logger';import { EmailNotificationService } from '../adapters/notifiers/email-notification-service';import { TransferController } from '../adapters/controllers/transfer-controller'; // Use Casesimport { TransferFunds } from '../use-cases/transfer-funds';import { GetAccountBalance } from '../use-cases/get-account-balance';import { CreateAccount } from '../use-cases/create-account'; export interface Container { // Controllers transferController: TransferController; // Add other controllers as needed} export function createContainer(): Container { // Infrastructure const dbPool = new Pool({ connectionString: config.databaseUrl, max: 20, }); // Build adapters (implementations of ports) const accountRepo = new PostgresAccountRepository(dbPool); const transactionLogger = new PostgresTransactionLogger(dbPool); const notifier = new EmailNotificationService(config.smtpHost); // Build use cases (injecting their dependencies) const transferFunds = new TransferFunds( accountRepo, transactionLogger, notifier ); // Build controllers (injecting use cases) const transferController = new TransferController(transferFunds); return { transferController, };}The container.ts file is the 'Composition Root'—the single place where all dependencies are wired together. This is the only place in your application that knows about all the concrete implementations. Everything else works with abstractions.
Understanding how data flows between layers is crucial. While the Dependency Rule governs source code dependencies, data must still flow in both directions—from external systems to the core and back.
Inbound Data Flow (Request):
HTTP Request → Controller → Use Case Input DTO → Use Case → Entities
Outbound Data Flow (Response):
Entities → Use Case → Use Case Output DTO → Presenter/Controller → HTTP Response
Key Observations:
DTOs at Every Boundary: Data crossing a boundary uses DTOs specific to that boundary. HTTP DTOs are different from Use Case DTOs.
Entities Never Cross Boundaries: Domain entities stay within the core. What crosses are data structures—DTOs—that carry the relevant information.
Adapters Do All Translation: Controllers translate HTTP → Use Case format. Repositories translate Use Case format → Database format.
Use Cases Are the Pivot: Use cases orchestrate the flow, receiving input DTOs and producing output DTOs.
Each layer has characteristic mistakes that developers make. Recognizing these patterns helps you maintain clean boundaries.
| Layer | Common Mistake | Correct Approach |
|---|---|---|
| Entities | Adding ORM annotations to domain entities | Keep entities pure; create separate DB models in adapter layer |
| Entities | Anemic entities with only getters/setters | Entities should contain behavior, not just data |
| Entities | Importing use case or adapter code | Entities import nothing from outer layers |
| Use Cases | Accepting HTTP Request objects | Define Input DTOs specific to the use case |
| Use Cases | Directly calling database libraries | Define repository interfaces; let adapters implement them |
| Use Cases | Containing business logic that belongs in entities | Move invariant-enforcing logic to entities |
| Adapters | Implementing business validation | Adapters translate; entities and use cases validate |
| Adapters | Calling other adapters directly | Go through use cases to coordinate between adapters |
| Adapters | Using Entity classes for database models | Create separate DB model classes |
| Frameworks | Containing any business logic | Frameworks layer is purely technical wiring |
| Frameworks | Instantiating use cases in controllers | Use dependency injection via composition root |
One of the most common mistakes is creating 'anemic' entities that are just data containers with getters and setters, while business logic lives in services. This defeats the purpose of entities. If your Account entity has no methods—just properties—and all balance-checking logic lives in a TransferService, your domain model is anemic.
Beyond the basic structure, several patterns govern how layers interact effectively:
Pattern 1: Presenter Pattern
Instead of the controller formatting output, a Presenter handles output formatting. The use case calls an Output Port (interface), and the Presenter implements it:
interface TransferPresenter {
presentSuccess(output: TransferOutput): void;
presentError(error: TransferError): void;
}
class HtmlTransferPresenter implements TransferPresenter {
presentSuccess(output: TransferOutput): void {
this.viewModel = {
message: `Transfer of $${output.amount} complete`,
newBalance: formatCurrency(output.sourceNewBalance),
// HTML-specific formatting
};
}
}
Pattern 2: Gateway Pattern
For external services, Gateways encapsulate the complexity of third-party APIs:
// Port defined in use case layer
interface PaymentGateway {
charge(amount: Money, source: PaymentSource): Promise<ChargeResult>;
}
// Adapter implements the port
class StripePaymentGateway implements PaymentGateway {
private stripe: Stripe;
async charge(amount: Money, source: PaymentSource): Promise<ChargeResult> {
// All Stripe-specific logic encapsulated here
const charge = await this.stripe.charges.create({ ... });
return this.toChargeResult(charge);
}
}
Pattern 3: Factory Pattern for Complex Entity Creation
When entity creation is complex, factories in the use case or entity layer handle it:
class OrderFactory {
static createOrder(customer: Customer, items: CartItem[]): Order {
// Complex creation logic, validation, defaults
const order = new Order(generateId(), customer);
for (const item of items) {
order.addItem(this.toOrderItem(item));
}
return order;
}
}
We've explored each layer of Clean Architecture in depth. Let's consolidate the key insights:
What's Next:
Now that we understand what belongs in each layer, we'll examine how to structure a project to embody Clean Architecture: folder organization, naming conventions, and practical patterns for organizing real-world codebases.
You now have a deep understanding of the four layers of Clean Architecture—Entities, Use Cases, Adapters, and Frameworks. You know what belongs in each, how they interact, and what mistakes to avoid. In the final page, we'll put it all together with Clean Architecture project structure.