Clean Architecture Implementation
A practical guide for building maintainable, testable systems in modern software projects

Clean Architecture is not a buzzword. It is a set of tradeoffs and patterns that help you keep your codebase changeable as requirements evolve. In today’s world of fast-moving product roadmaps, frequent pivots, and diverse deployment targets, the ability to isolate business rules from frameworks and infrastructure is a direct competitive advantage. This post walks through what Clean Architecture really means in practice, shows how to implement it with concrete examples, and shares hard-earned lessons from projects where it saved us from spiraling complexity.
Where Clean Architecture fits today
Clean Architecture, popularized by Robert C. Martin, focuses on arranging code so that core business rules do not depend on delivery mechanisms like web frameworks, databases, or external services. This approach is widely used in backend systems, monoliths that need modularization, and services where long-term maintainability matters more than initial speed. Teams building domain-driven systems, event-driven platforms, or microservices adopt it when they find that framework-coupled code slows them down.
Compared to alternatives, Clean Architecture sits between pure functional architectures and framework-first approaches. It is more structured than ad-hoc MVC or “service layer” patterns but less prescriptive than functional effect systems. It pairs well with Dependency Injection (DI), SOLID principles, and Hexagonal Architecture. It is not a silver bullet and can be overkill for short-lived scripts or prototypes. But for products with years of life ahead, it pays dividends in testability and reduced change friction.
Core concepts and how they map to code
The central idea is dependency inversion: high-level policy (business logic) should not depend on low-level details (databases, frameworks). Instead, both depend on abstractions defined in the domain layer. The typical layers are:
- Domain: Entities, business rules, and interfaces for repositories or gateways. No framework dependencies.
- Application: Use cases, commands, queries, and orchestration. Depends on domain interfaces.
- Infrastructure: Implementation of repositories, external API clients, message brokers. Depends on application and domain interfaces.
- Presentation: Controllers, handlers, or API definitions. Often part of infrastructure but should delegate to application use cases.
In practice, you separate the layers by project/module boundaries, enforce dependency direction via package rules, and keep interfaces in the domain. A common pitfall is placing DTOs or framework-specific types in the domain layer. Avoid that; keep domain models pure.
A minimal folder structure
Below is a language-agnostic structure that can be adapted for Java, C#, TypeScript, Go, or Kotlin. The key is that domain defines interfaces, application depends on domain, and infrastructure depends on both.
src/
domain/
entities/
Order.ts
OrderStatus.ts
repositories/
OrderRepository.ts
events/
OrderCreated.ts
application/
usecases/
CreateOrder.ts
GetOrder.ts
commands/
queries/
infrastructure/
persistence/
PostgresOrderRepository.ts
messaging/
KafkaOrderEventPublisher.ts
web/
OrderController.ts
shared/
errors/
ApplicationError.ts
utils/
Result.ts
Practical example: e-commerce order creation
Imagine an order creation flow where we enforce invariants (e.g., an order must have a valid customer), persist it, and publish a domain event. We want this logic testable without a database or message broker.
Domain layer
The domain defines an Order entity and a repository interface. The repository interface is a contract that infrastructure implements later.
// src/domain/entities/Order.ts
export type OrderStatus = 'Pending' | 'Confirmed' | 'Cancelled';
export interface OrderItem {
productId: string;
quantity: number;
unitPrice: number;
}
export class Order {
public readonly id: string;
public readonly customerId: string;
public readonly items: OrderItem[];
public status: OrderStatus;
public readonly createdAt: Date;
private constructor(props: {
id: string;
customerId: string;
items: OrderItem[];
status: OrderStatus;
createdAt: Date;
}) {
this.id = props.id;
this.customerId = props.customerId;
this.items = props.items;
this.status = props.status;
this.createdAt = props.createdAt;
}
static create(props: {
id: string;
customerId: string;
items: OrderItem[];
}): Order {
if (!props.customerId) throw new Error('Customer is required');
if (!props.items || props.items.length === 0) throw new Error('Order must have items');
const total = props.items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
if (total <= 0) throw new Error('Order total must be positive');
return new Order({
id: props.id,
customerId: props.customerId,
items: props.items,
status: 'Pending',
createdAt: new Date(),
});
}
confirm(): void {
if (this.status !== 'Pending') throw new Error('Order not pending');
this.status = 'Confirmed';
}
cancel(): void {
if (this.status !== 'Pending' && this.status !== 'Confirmed') throw new Error('Order cannot be cancelled');
this.status = 'Cancelled';
}
}
// src/domain/repositories/OrderRepository.ts
import { Order } from '../entities/Order';
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
Application layer
The application use case orchestrates business rules and depends on the domain interfaces. It does not know about databases or HTTP.
// src/application/usecases/CreateOrder.ts
import { Order, OrderItem } from '../../domain/entities/Order';
import { OrderRepository } from '../../domain/repositories/OrderRepository';
import { randomUUID } from 'crypto';
export type CreateOrderCommand = {
customerId: string;
items: Array<{ productId: string; quantity: number; unitPrice: number }>;
};
export class CreateOrder {
constructor(private orderRepo: OrderRepository) {}
async execute(cmd: CreateOrderCommand): Promise<{ orderId: string }> {
const orderId = randomUUID();
const order = Order.create({
id: orderId,
customerId: cmd.customerId,
items: cmd.items.map(i => ({ productId: i.productId, quantity: i.quantity, unitPrice: i.unitPrice })),
});
// Additional business rules could live here, e.g., customer eligibility, inventory checks
await this.orderRepo.save(order);
return { orderId };
}
}
Infrastructure layer
Here we implement the repository using a concrete database. Note: the infrastructure depends on the interface defined in the domain. We avoid leaking database types into the domain.
// src/infrastructure/persistence/PostgresOrderRepository.ts
import { Pool } from 'pg';
import { Order, OrderItem } from '../../domain/entities/Order';
import { OrderRepository } from '../../domain/repositories/OrderRepository';
export class PostgresOrderRepository implements OrderRepository {
constructor(private pool: Pool) {}
async save(order: Order): Promise<void> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
await client.query(
`INSERT INTO orders (id, customer_id, status, created_at) VALUES ($1, $2, $3, $4)`,
[order.id, order.customerId, order.status, order.createdAt]
);
for (const item of order.items) {
await client.query(
`INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES ($1, $2, $3, $4)`,
[order.id, item.productId, item.quantity, item.unitPrice]
);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
async findById(id: string): Promise<Order | null> {
const res = await this.pool.query('SELECT * FROM orders WHERE id = $1', [id]);
if (res.rows.length === 0) return null;
const row = res.rows[0];
const itemsRes = await this.pool.query('SELECT * FROM order_items WHERE order_id = $1', [id]);
const items: OrderItem[] = itemsRes.rows.map(r => ({
productId: r.product_id,
quantity: r.quantity,
unitPrice: r.unit_price,
}));
const order = Order.create({
id: row.id,
customerId: row.customer_id,
items,
});
// Order.create sets status to Pending; in real code we need to map from DB.
// For brevity, we skip full rehydration here.
return order;
}
}
Presentation layer
The web adapter maps HTTP to application commands. It should be thin.
// src/infrastructure/web/OrderController.ts
import { Request, Response } from 'express';
import { CreateOrder, CreateOrderCommand } from '../../application/usecases/CreateOrder';
import { OrderRepository } from '../../domain/repositories/OrderRepository';
export class OrderController {
constructor(private orderRepo: OrderRepository) {}
create = async (req: Request, res: Response) => {
const cmd: CreateOrderCommand = req.body;
const useCase = new CreateOrder(this.orderRepo);
const result = await useCase.execute(cmd);
res.status(201).json(result);
};
}
Wiring dependencies
Dependency Injection ensures that the application uses the infrastructure implementation without coupling.
// src/infrastructure/config/dependencies.ts
import { Pool } from 'pg';
import { PostgresOrderRepository } from '../persistence/PostgresOrderRepository';
import { OrderController } from '../web/OrderController';
export function buildDependencies() {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const orderRepo = new PostgresOrderRepository(pool);
const orderController = new OrderController(orderRepo);
return { orderRepo, orderController };
}
Wiring HTTP routes
// src/infrastructure/web/routes.ts
import express from 'express';
import { buildDependencies } from '../config/dependencies';
const { orderController } = buildDependencies();
const router = express.Router();
router.post('/orders', orderController.create);
export default router;
Async patterns and error handling
In real projects, Clean Architecture benefits from a consistent result type or error model. A common approach is to wrap use case results in a Result<T> type to avoid throwing exceptions across boundaries. This improves testability and makes errors part of the domain vocabulary.
// src/shared/utils/Result.ts
export type Result<T, E = ApplicationError> = { ok: true; value: T } | { ok: false; error: E };
export class ApplicationError extends Error {
constructor(message: string, public code?: string, public metadata?: unknown) {
super(message);
this.name = 'ApplicationError';
}
}
// Example usage in a use case
export class ConfirmOrder {
constructor(private orderRepo: OrderRepository) {}
async execute(orderId: string): Promise<Result<void>> {
const order = await this.orderRepo.findById(orderId);
if (!order) return { ok: false, error: new ApplicationError('Order not found', 'ORDER_NOT_FOUND') };
try {
order.confirm();
await this.orderRepo.save(order);
return { ok: true, value: undefined };
} catch (e) {
return { ok: false, error: new ApplicationError('Failed to confirm order', 'ORDER_CONFIRM_FAILED') };
}
}
}
For async workflows, keep I/O boundaries explicit. If the use case calls external services, create a gateway interface in the domain and implement it in infrastructure. This allows you to swap HTTP calls for message queues without touching business logic.
Honest evaluation: strengths, weaknesses, and tradeoffs
Strengths
- Testability: Business rules can be tested with in-memory fakes or mocks. No database or network required.
- Framework independence: Swapping Express for Fastify, or Postgres for MongoDB, stays isolated to infrastructure.
- Clarity: Explicit layers make it easier for new team members to navigate and reason about the codebase.
- Evolution: Adding features like audit logs or event publishing often fits naturally as new infrastructure adapters.
Weaknesses
- Initial overhead: More files, interfaces, and layers slow down early development. For throwaway prototypes, it is overkill.
- Learning curve: Developers used to “fat controllers” or active-record patterns need time to internalize dependency inversion.
- Potential over-engineering: Not every service needs four layers. Be pragmatic and start simpler, then add structure as complexity grows.
- Boilerplate: DTO mapping and interface definitions can feel repetitive. Good tooling and code generation can help.
When to use it
- Long-lived services or monoliths with multiple domains
- Teams that need parallel development (domain and infrastructure can evolve separately)
- Systems where compliance, auditability, or strict testing requirements exist
When to skip it
- Short-lived scripts or prototypes with unclear scope
- Very small APIs where framework coupling is acceptable and speed matters more
- Projects with heavy framework features that are hard to abstract (e.g., highly coupled ORM migrations)
Personal experience: lessons from the trenches
In a recent multi-tenant billing system, we started with a typical service layer inside an Express app. As requirements grew, the code became fragile: changing a payment provider rippled through controllers and validators, and tests needed a real database. We refactored to Clean Architecture over several weeks.
The most valuable changes were:
- Extracting domain entities and repository interfaces. This alone uncovered hidden assumptions in our validation logic.
- Moving side effects (email, messaging) behind gateways. Tests became faster and less flaky.
- Adopting a
Result<T>pattern for use cases. This forced us to handle failures explicitly and improved error tracking.
Common mistakes we made:
- Leaking database IDs into the domain. We solved this by using domain identifiers and mapping only at the boundary.
- Putting DTOs in the domain layer. We created a separate shared/transport layer to keep domain models pure.
- Over-abstraction. We initially introduced interfaces for every utility. We scaled back to interfaces for core ports only.
When Clean Architecture proved its worth was during a pivot to event-driven invoicing. Because we already had domain events and gateway interfaces, we added an event publisher in infrastructure and updated consumers without touching business logic. That saved weeks of rework.
Getting started: workflow and mental models
Start with a small vertical slice: one use case with a single entity, repository interface, and an in-memory fake. Build the domain model first, write tests, then add infrastructure and presentation.
Project setup workflow
- Define your domain model. Entities should capture business invariants. Avoid framework types.
- Define ports (interfaces) for repositories and gateways. Keep them in the domain layer.
- Implement use cases that orchestrate business rules and depend only on ports.
- Add infrastructure adapters that implement ports. Wire them with DI at the edge.
- Add presentation adapters that translate HTTP or CLI input into commands and call use cases.
- Introduce shared error and result utilities to unify failure handling.
Example folder structure (TypeScript/Node)
billing-service/
src/
domain/
entities/
Invoice.ts
InvoiceLine.ts
repositories/
InvoiceRepository.ts
events/
InvoiceCreated.ts
application/
usecases/
CreateInvoice.ts
queries/
GetInvoiceById.ts
infrastructure/
persistence/
PostgresInvoiceRepository.ts
messaging/
KafkaEventPublisher.ts
web/
InvoiceController.ts
shared/
errors/
ApplicationError.ts
utils/
Result.ts
di.ts
tests/
domain/
Invoice.test.ts
application/
CreateInvoice.test.ts
infrastructure/
PostgresInvoiceRepository.test.ts
package.json
tsconfig.json
Dependencies and configuration
Keep configuration at the edges. Use environment variables or config files in infrastructure, and pass settings into constructors. Avoid global singletons in the domain.
// src/infrastructure/config/app.ts
import express from 'express';
import { buildDependencies } from './dependencies';
const app = express();
app.use(express.json());
const { invoiceController } = buildDependencies();
app.post('/invoices', invoiceController.create);
app.listen(process.env.PORT || 3000, () => {
console.log(`Server listening on port ${process.env.PORT || 3000}`);
});
A note on async and concurrency
If your use case involves multiple async operations (e.g., fetching customer data, validating limits, saving the invoice), keep operations isolated and compose them within the use case. Avoid transaction sprawl across multiple repositories unless you introduce a unit-of-work abstraction. In many cases, a single repository method can encapsulate the transaction.
Error handling in routes
A thin presentation layer should map Result<T> to HTTP status codes. This decouples transport concerns from domain outcomes.
// Example mapping in a controller method
async function handleCreate(req, res) {
const result = await useCase.execute(req.body);
if (!result.ok) {
const err = result.error;
const status = err.code === 'ORDER_NOT_FOUND' ? 404 : 400;
return res.status(status).json({ error: err.message, code: err.code });
}
res.status(201).json(result.value);
}
What makes Clean Architecture stand out
- Maintainability: Changes tend to be localized. Adding a new delivery mechanism (e.g., a CLI command) does not require duplicating business logic.
- Testability: It encourages fast, deterministic tests. You can simulate failures and edge cases without spinning up infrastructure.
- Developer experience: Once the pattern clicks, navigation becomes intuitive. Business logic lives where you expect it.
- Outcomes: Teams report fewer regressions and easier onboarding. Refactoring becomes safer because domain logic is isolated.
Free learning resources
- Clean Architecture book by Robert C. Martin – The canonical source for the ideas behind domain-centric design. https://www.oreilly.com/library/view/clean-architecture-a/9780134494272/
- Microsoft’s guide on clean architecture – Practical patterns for .NET and C#. https://learn.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/
- Domain-Driven Design (DDD) quick reference – Helpful companion to Clean Architecture. https://martinfowler.com/bliki/DDD_Aggregates.html
- Martin Fowler’s catalog of patterns – Useful for identifying where to place concerns. https://refactoring.guru/
- Node.js TypeScript starter with Clean Architecture – A real-world example repo to explore structure. https://github.com/alexperović/node-clean-architecture
Summary: who should use it and who might skip it
Use Clean Architecture if you are building a system that will change frequently, needs rigorous testing, or will outlive a single tech stack. It is ideal for teams working on monoliths that need better modularity, domains with complex business rules, or services where operational reliability demands clear separation of concerns.
Skip it if you are building a small API with minimal logic, a short-lived integration, or a prototype where speed of delivery is more valuable than long-term maintainability. In these scenarios, a leaner pattern like a simple service layer might suffice.
Takeaway: Clean Architecture is a pragmatic set of boundaries, not dogma. Start with a single vertical slice, keep dependencies pointing inward, and let the complexity of your domain guide how much structure you add. It shines where change is constant and business rules are the heart of your product.




