Loading learning content...
Understanding the principles of Clean Architecture is essential, but translating those principles into a concrete project structure is where the rubber meets the road. How do you organize folders? What goes where? How do you enforce the Dependency Rule at the file system level?
In this final page of the Clean Architecture module, we bridge theory and practice. We'll examine multiple approaches to structuring Clean Architecture projects, discuss trade-offs between them, and provide concrete templates you can adapt to your own projects.
By the end of this page, you will know how to structure a project to enforce Clean Architecture boundaries, understand different organizational strategies and their trade-offs, and have concrete templates for both simple and complex applications.
There are two fundamental approaches to organizing a Clean Architecture project:
Layer-First Organization:
Folders represent architectural layers, with features spread across them:
src/
├── entities/
│ ├── account.ts
│ ├── order.ts
│ └── user.ts
├── use-cases/
│ ├── transfer-funds.ts
│ ├── place-order.ts
│ └── register-user.ts
├── adapters/
│ ├── controllers/
│ ├── repositories/
│ └── gateways/
└── frameworks/
├── express-app.ts
└── database.ts
Feature-First Organization:
Folders represent features, with layers inside each:
src/
├── accounts/
│ ├── entities/
│ ├── use-cases/
│ ├── adapters/
│ └── routes.ts
├── orders/
│ ├── entities/
│ ├── use-cases/
│ ├── adapters/
│ └── routes.ts
└── shared/
├── infrastructure/
└── common/
Most real-world projects use a hybrid: feature-first at the top level, with layers inside each feature. Shared code (common entities, infrastructure) lives in a shared module. This balances cohesion within features with clear layer boundaries.
Based on extensive experience with Clean Architecture projects, here is a recommended structure that balances all concerns:
src/
├── core/ # The inner circles
│ ├── entities/ # Enterprise business rules
│ │ ├── account/
│ │ │ ├── account.ts
│ │ │ ├── account.test.ts
│ │ │ └── money.ts # Value objects
│ │ ├── order/
│ │ │ ├── order.ts
│ │ │ ├── order-item.ts
│ │ │ └── order.test.ts
│ │ └── shared/
│ │ ├── entity-base.ts
│ │ └── value-object.ts
│ │
│ └── use-cases/ # Application business rules
│ ├── accounts/
│ │ ├── transfer-funds/
│ │ │ ├── transfer-funds.ts
│ │ │ ├── transfer-funds.test.ts
│ │ │ └── index.ts
│ │ └── ports/ # Abstractions needed by this domain
│ │ ├── account-repository.ts
│ │ └── notification-service.ts
│ └── orders/
│ ├── place-order/
│ │ ├── place-order.ts
│ │ └── place-order.test.ts
│ └── ports/
│ └── order-repository.ts
│
├── adapters/ # Interface adapters
│ ├── controllers/ # HTTP controllers
│ │ ├── accounts/
│ │ │ ├── transfer-controller.ts
│ │ │ └── balance-controller.ts
│ │ └── orders/
│ │ └── order-controller.ts
│ ├── repositories/ # Database implementations
│ │ ├── postgres/
│ │ │ ├── account-repository.ts
│ │ │ └── order-repository.ts
│ │ └── in-memory/ # For testing
│ │ ├── account-repository.ts
│ │ └── order-repository.ts
│ ├── gateways/ # External service implementations
│ │ ├── stripe-payment-gateway.ts
│ │ └── sendgrid-email-service.ts
│ └── presenters/ # Output formatters
│ ├── json/
│ └── html/
│
├── infrastructure/ # Frameworks & drivers
│ ├── http/
│ │ ├── express-app.ts
│ │ ├── routes.ts
│ │ └── middleware/
│ ├── database/
│ │ ├── connection.ts
│ │ └── migrations/
│ ├── config/
│ │ └── index.ts
│ └── container/
│ └── index.ts # Composition root
│
├── main.ts # Application entry point
└── types/ # Shared type definitions
└── index.ts
The 'core' folder contains everything that's framework-agnostic. If you extracted just the 'core' folder, you'd have pure business logic that could run anywhere. This makes the boundary crystal clear and enables reuse across applications.
Folder structure alone doesn't enforce the Dependency Rule. Developers under pressure can still import from the wrong place. Here are concrete mechanisms for enforcement:
1. ESLint Import Restrictions
123456789101112131415161718192021222324252627282930313233343536373839404142434445
module.exports = { plugins: ['import'], rules: { 'import/no-restricted-paths': ['error', { zones: [ // Entities cannot import from use-cases or outer layers { target: './src/core/entities/**/*', from: './src/core/use-cases', message: 'Entities cannot import from use-cases' }, { target: './src/core/entities/**/*', from: './src/adapters', message: 'Entities cannot import from adapters' }, { target: './src/core/entities/**/*', from: './src/infrastructure', message: 'Entities cannot import from infrastructure' }, // Use-cases cannot import from adapters or infrastructure { target: './src/core/use-cases/**/*', from: './src/adapters', message: 'Use-cases cannot import from adapters' }, { target: './src/core/use-cases/**/*', from: './src/infrastructure', message: 'Use-cases cannot import from infrastructure' }, // Adapters cannot import from infrastructure (except types) { target: './src/adapters/**/*', from: './src/infrastructure', except: ['./src/infrastructure/database/types.ts'], message: 'Adapters cannot import from infrastructure' } ] }] }};2. TypeScript Path Aliases
Path aliases make imports cleaner and can help enforce boundaries by making wrong imports more obvious:
1234567891011
{ "compilerOptions": { "baseUrl": ".", "paths": { "@entities/*": ["src/core/entities/*"], "@use-cases/*": ["src/core/use-cases/*"], "@adapters/*": ["src/adapters/*"], "@infrastructure/*": ["src/infrastructure/*"] } }}Now imports look like:
// In a use-case - ALLOWED
import { Account } from '@entities/account';
// In a use-case - FORBIDDEN (would trigger ESLint error)
import { Pool } from '@infrastructure/database';
3. Architectural Fitness Functions (CI/CD)
Automate architecture verification in your build pipeline:
123456789101112131415161718192021222324252627282930313233343536373839
import { analyzeProject } from 'dependency-cruiser'; describe('Architecture Rules', () => { let dependencies: DependencyGraph; beforeAll(async () => { dependencies = await analyzeProject('./src'); }); test('core/entities should not depend on outer layers', () => { const violations = dependencies .filesIn('src/core/entities') .dependingOn(['src/core/use-cases', 'src/adapters', 'src/infrastructure']); expect(violations).toHaveLength(0); }); test('core/use-cases should not depend on adapters or infrastructure', () => { const violations = dependencies .filesIn('src/core/use-cases') .dependingOn(['src/adapters', 'src/infrastructure']); expect(violations).toHaveLength(0); }); test('adapters should not depend on infrastructure', () => { const violations = dependencies .filesIn('src/adapters') .dependingOn(['src/infrastructure']) .excluding('types.ts'); // Allow type imports expect(violations).toHaveLength(0); }); test('no circular dependencies', () => { const cycles = dependencies.findCycles(); expect(cycles).toHaveLength(0); });});The Composition Root is the single location where all components are assembled. It's the only place that knows about all concrete implementations. Everything else works with abstractions.
Why One Place?
Centralizing composition provides:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
/** * Composition Root * * This is the ONLY file that knows about concrete implementations. * All other files depend only on abstractions. */import { Pool } from 'pg';import { config } from '../config'; // Entities (no instantiation needed - they're created by use cases) // Adapters - Concrete implementationsimport { PostgresAccountRepository } from '@adapters/repositories/postgres/account-repository';import { PostgresOrderRepository } from '@adapters/repositories/postgres/order-repository';import { StripePaymentGateway } from '@adapters/gateways/stripe-payment-gateway';import { SendGridEmailService } from '@adapters/gateways/sendgrid-email-service';import { TransferController } from '@adapters/controllers/accounts/transfer-controller';import { OrderController } from '@adapters/controllers/orders/order-controller'; // Use Casesimport { TransferFunds } from '@use-cases/accounts/transfer-funds';import { PlaceOrder } from '@use-cases/orders/place-order'; export interface Container { // Use cases (for testing) transferFunds: TransferFunds; placeOrder: PlaceOrder; // Controllers (for routing) transferController: TransferController; orderController: OrderController;} export function createContainer(): Container { // ============================================ // Infrastructure // ============================================ const dbPool = new Pool({ connectionString: config.databaseUrl, max: config.dbPoolMax, }); // ============================================ // Build Adapters Layer // ============================================ const accountRepo = new PostgresAccountRepository(dbPool); const orderRepo = new PostgresOrderRepository(dbPool); const paymentGateway = new StripePaymentGateway(config.stripeKey); const emailService = new SendGridEmailService(config.sendgridKey); // ============================================ // Build Use Cases Layer // ============================================ const transferFunds = new TransferFunds( accountRepo, emailService ); const placeOrder = new PlaceOrder( orderRepo, accountRepo, paymentGateway, emailService ); // ============================================ // Build Controllers Layer // ============================================ const transferController = new TransferController(transferFunds); const orderController = new OrderController(placeOrder); return { transferFunds, placeOrder, transferController, orderController, };} // For testing: create container with mock adaptersexport function createTestContainer(overrides: Partial<TestAdapters> = {}): Container { const accountRepo = overrides.accountRepository ?? new InMemoryAccountRepository(); const orderRepo = overrides.orderRepository ?? new InMemoryOrderRepository(); const paymentGateway = overrides.paymentGateway ?? new MockPaymentGateway(); const emailService = overrides.emailService ?? new MockEmailService(); // ... wire up with test dependencies}For larger projects, you might use a DI container (like tsyringe, InversifyJS, or TypeDI). These automate the wiring based on decorators or configuration. However, manual wiring in a single file is often clearer for medium-sized projects.
Consistent naming helps developers quickly understand what a file contains and which layer it belongs to. Here are recommended conventions:
Entities (core/entities/)
Account, Order, UserMoney, Email, AddressUse Cases (core/use-cases/)
TransferFunds, PlaceOrder, RegisterUserTransferFundsInput, TransferFundsOutputPorts (core/use-cases/*/ports/)
AccountRepository, OrderRepositoryNotificationService, PaymentGatewayAdapters (adapters/)
[Feature]Controller - TransferController, OrderController[Technology][Entity]Repository - PostgresAccountRepository[Provider][Capability] - StripePaymentGateway, SendGridEmailServiceInfrastructure (infrastructure/)
express-app.ts, database.ts, routes.ts| Layer | Convention | Examples |
|---|---|---|
| Entity | [DomainConcept] | Account, Order, Money, Email |
| Use Case | [VerbPhrase] | TransferFunds, PlaceOrder, RegisterUser |
| Use Case Input | [UseCaseName]Input | TransferFundsInput, PlaceOrderInput |
| Use Case Output | [UseCaseName]Output | TransferFundsOutput, PlaceOrderOutput |
| Port (Repository) | [Entity]Repository | AccountRepository, OrderRepository |
| Port (Service) | [Capability]Service | NotificationService, EmailService |
| Port (Gateway) | [Capability]Gateway | PaymentGateway, ShippingGateway |
| Controller | [Feature]Controller | TransferController, OrderController |
| Repository Impl | [Tech][Entity]Repository | PostgresAccountRepository, MongoOrderRepository |
| Gateway Impl | [Provider][Capability] | StripePaymentGateway, TwilioSmsService |
Clean Architecture naturally supports a layered testing strategy that aligns with the testing pyramid:
Unit Tests: Entities and Use Cases
The core layers are tested in complete isolation. No database, no HTTP, no external services. Tests run in milliseconds.
123456789101112131415161718192021222324252627
describe('Account Entity', () => { test('should not allow withdrawal exceeding available funds', () => { const account = new Account( new AccountId('123'), new CustomerId('456'), 100, 'checking' ); expect(() => account.withdraw(new Money(150, 'USD'))) .toThrow(InsufficientFundsError); }); test('should allow withdrawal within overdraft limit', () => { const account = new Account( new AccountId('123'), new CustomerId('456'), 100, 'checking', { overdraftLimit: 50 } ); account.withdraw(new Money(140, 'USD')); expect(account.balance).toBe(-40); });});12345678910111213141516171819202122232425262728293031323334353637383940414243
describe('TransferFunds Use Case', () => { let accountRepo: InMemoryAccountRepository; let notifier: MockNotificationService; let useCase: TransferFunds; beforeEach(() => { accountRepo = new InMemoryAccountRepository(); notifier = new MockNotificationService(); useCase = new TransferFunds(accountRepo, notifier); }); test('should transfer funds between accounts', async () => { // Arrange accountRepo.save(new Account('A', 'owner1', 100, 'checking')); accountRepo.save(new Account('B', 'owner2', 50, 'checking')); // Act const result = await useCase.execute({ sourceAccountId: 'A', destinationAccountId: 'B', amount: 30, currency: 'USD', }); // Assert expect(result.status).toBe('completed'); expect(result.sourceNewBalance).toBe(70); expect(result.destinationNewBalance).toBe(80); expect(notifier.notifications).toHaveLength(1); }); test('should fail when source has insufficient funds', async () => { accountRepo.save(new Account('A', 'owner1', 20, 'checking')); accountRepo.save(new Account('B', 'owner2', 50, 'checking')); await expect(useCase.execute({ sourceAccountId: 'A', destinationAccountId: 'B', amount: 30, currency: 'USD', })).rejects.toThrow(InsufficientFundsError); });});Integration Tests: Adapters
Adapter tests verify that implementations correctly interact with their external systems. These tests may require a running database or mock servers, but business logic is not re-tested here.
describe('PostgresAccountRepository', () => {
let pool: Pool;
let repository: PostgresAccountRepository;
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
await pool.query('DELETE FROM accounts');
});
beforeEach(() => {
repository = new PostgresAccountRepository(pool);
});
test('should save and retrieve account', async () => {
const account = new Account('test-1', 'owner-1', 100, 'checking');
await repository.save(account);
const retrieved = await repository.findById('test-1');
expect(retrieved?.balance).toBe(100);
});
});
End-to-End Tests: Full System
E2E tests verify the entire stack—from HTTP request to database and back. These are the slowest but catch integration issues between layers.
describe('Transfer API', () => {
test('POST /api/transfers should transfer funds', async () => {
// Setup test accounts in database
await setupTestAccounts();
const response = await request(app)
.post('/api/transfers')
.send({
from_account: 'test-account-a',
to_account: 'test-account-b',
amount: 50,
currency: 'USD',
});
expect(response.status).toBe(200);
expect(response.body.data.source_balance).toBe(50);
});
});
With Clean Architecture, the testing pyramid emerges naturally: Many fast unit tests for entities and use cases (milliseconds). Some integration tests for adapters (seconds). Few E2E tests for critical paths (seconds to minutes). The bulk of your test suite runs in under a second.
Implementing Clean Architecture in production involves practical considerations beyond the ideal structure:
Pragmatic Trade-offs
Performance Considerations
Team Considerations
Most teams don't start with Clean Architecture—they migrate to it. Here's a proven approach:
Phase 1: Identify the Core
Find the most important business logic. What are the key entities? What are the critical use cases? These are candidates for extraction.
Phase 2: Extract Entities
Create plain domain objects without framework dependencies. Initially, they might just wrap existing models:
// Before: ORM-coupled entity
@Entity()
class Account {
@PrimaryGeneratedColumn()
id: string;
// ... ORM stuff mixed with domain logic
}
// After: Separate domain entity
class Account {
constructor(public readonly id: string, ...) {}
// Pure domain logic
}
// Plus: ORM model in adapter layer
@Entity()
class AccountModel {
// ORM stuff only, no domain logic
}
Phase 3: Extract Use Cases
Pull business logic out of controllers into dedicated use cases. Define ports for dependencies:
// Before: Logic in controller
app.post('/transfer', async (req, res) => {
const source = await db.query('SELECT ...');
const dest = await db.query('SELECT ...');
// ... 50 lines of business logic ...
res.json({ success: true });
});
// After: Controller calls use case
app.post('/transfer', async (req, res) => {
const result = await transferFunds.execute({
sourceAccountId: req.body.from,
destinationAccountId: req.body.to,
amount: req.body.amount,
});
res.json(result);
});
Phase 4: Create Adapters
Implement the ports defined by use cases. This often means wrapping existing database code:
class PostgresAccountRepository implements AccountRepository {
async findById(id: string): Promise<Account | null> {
const row = await existingQueryFunction(id);
return row ? new Account(row.id, row.balance, ...) : null;
}
}
Phase 5: Wire Up
Create the composition root. Start with manual wiring; add a DI container if needed.
Phase 6: Add Enforcement
Add ESLint rules and architecture tests. Enforce boundaries going forward.
Don't try to migrate everything at once. Migrate feature by feature, starting with the most critical or most problematic. Each migrated feature is a learning experience that informs the next. The old and new code can coexist during transition.
We've covered the practical implementation of Clean Architecture—how to organize projects, enforce boundaries, and apply the patterns in real-world contexts. Let's consolidate:
Module Complete:
You've now completed the Clean Architecture module. You understand its origins and philosophy, the Dependency Rule, the four layers in detail, and practical implementation patterns. You're equipped to implement Clean Architecture in new projects or migrate existing projects toward these principles.
Congratulations! You now have a comprehensive understanding of Clean Architecture—from Uncle Bob's original principles through to practical project structure and enforcement. This knowledge will serve you in building maintainable, testable, and adaptable systems that can evolve gracefully over years and decades.