Clean Architecture in Practice (Beyond the Circles Diagram)
Clean architecture is frequently described through its concentric circles diagram but rarely explained in practical implementation terms. Here's what it actually looks like in a real codebase.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
The Diagram Isn't the Architecture
If you've encountered clean architecture, you've almost certainly seen the concentric circles diagram: Entities at the center, then Use Cases, then Interface Adapters, then Frameworks and Drivers on the outside. An arrow labeled "Dependency Rule" pointing inward.
The diagram is accurate, but it explains what clean architecture is without explaining how to build it or when it's the right choice. Teams that implement clean architecture from the diagram alone often end up with a folder structure that looks right but a dependency structure that doesn't enforce anything useful.
This post explains what clean architecture is actually trying to achieve, how to implement it in a real codebase, and — just as importantly — when it's overkill.
What Clean Architecture Is Actually Trying to Do
Clean architecture (and its close relatives: hexagonal architecture, onion architecture, ports and adapters) exists to solve one fundamental problem: infrastructure should not dictate domain design.
In most codebases, the framework shapes everything. Your domain model extends the ORM's base class. Your business logic lives in route handlers. Your tests require a running database because the database schema is baked into the domain objects. Changing your ORM means touching your business logic. Switching from REST to GraphQL requires rewriting application services. Testing a business rule requires bootstrapping the entire web framework.
Clean architecture inverts this dependency structure. The domain — your entities, your business rules, your use cases — is at the center and has zero dependencies on the outside world. The database, the web framework, the message queue — these are implementation details that plug in to the domain through defined interfaces. The domain doesn't know about infrastructure. Infrastructure knows about the domain.
When this is done correctly, you can:
- Test your business logic in complete isolation from the database, network, and framework
- Swap your database driver without touching domain code
- Expose your domain through multiple interfaces (REST API, GraphQL, CLI, event consumer) without duplicating logic
- Change frameworks without rewriting your application
The Dependency Rule
The one rule that defines clean architecture: source code dependencies can only point inward. Code in an outer layer can depend on code in an inner layer. Code in an inner layer must never depend on code in an outer layer.
In concrete terms:
- Your
Orderentity (inner) must not import from your Express router (outer) - Your
CreateOrderUseCase(inner) must not import from your Prisma ORM model (outer) - Your Prisma repository implementation (outer) can implement an interface defined in the domain (inner)
This inversion is what makes infrastructure replaceable and domains testable. The domain defines what it needs; the infrastructure provides it.
Practical Layer Structure
Let me walk through what this looks like in a TypeScript application:
Domain Layer (innermost)
This contains your entities and business rules. No framework imports. No ORM decorators. No database types.
// domain/order.ts
export class Order {
private readonly items: OrderItem[] = []
constructor(
public readonly id: string,
public readonly customerId: string,
private status: OrderStatus
) {}
addItem(product: Product, quantity: number): void {
if (this.status !== OrderStatus.Draft) {
throw new Error('Cannot modify a non-draft order')
}
this.items.push(new OrderItem(product, quantity))
}
submit(): void {
if (this.items.length === 0) {
throw new Error('Cannot submit an empty order')
}
this.status = OrderStatus.Submitted
}
getTotal(): Money {
return this.items.reduce((sum, item) => sum.add(item.getSubtotal()), Money.zero())
}
}
The Order entity enforces business rules. It doesn't know about databases, HTTP, or any framework.
Application Layer (use cases)
This orchestrates domain objects to fulfill a specific use case. It depends on the domain and defines interfaces (ports) for anything it needs from the outside world.
// application/createOrder.ts
export interface OrderRepository {
save(order: Order): Promise<void>
findById(id: string): Promise<Order | null>
}
export interface ProductRepository {
findById(id: string): Promise<Product | null>
}
export class CreateOrderUseCase {
constructor(
private readonly orderRepo: OrderRepository,
private readonly productRepo: ProductRepository
) {}
async execute(command: CreateOrderCommand): Promise<string> {
const order = new Order(generateId(), command.customerId, OrderStatus.Draft)
for (const item of command.items) {
const product = await this.productRepo.findById(item.productId)
if (!product) throw new Error(`Product ${item.productId} not found`)
order.addItem(product, item.quantity)
}
await this.orderRepo.save(order)
return order.id
}
}
Notice that OrderRepository and ProductRepository are interfaces defined in the application layer. The application layer doesn't know about Prisma or PostgreSQL.
Infrastructure Layer (adapters)
This implements the interfaces defined by the application layer.
// infrastructure/prismaOrderRepository.ts
export class PrismaOrderRepository implements OrderRepository {
constructor(private readonly prisma: PrismaClient) {}
async save(order: Order): Promise<void> {
await this.prisma.order.upsert({
where: { id: order.id },
update: this.toRecord(order),
create: this.toRecord(order)
})
}
async findById(id: string): Promise<Order | null> {
const record = await this.prisma.order.findUnique({
where: { id },
include: { items: true }
})
return record ? this.toDomain(record) : null
}
private toRecord(order: Order) { /* ... */ }
private toDomain(record: OrderRecord): Order { /* ... */ }
}
This is the adapter. It knows about Prisma. The domain doesn't.
Interface Layer (controllers, route handlers)
// interface/orderController.ts
export class OrderController {
constructor(private readonly createOrder: CreateOrderUseCase) {}
async create(req: Request, res: Response): Promise<void> {
const orderId = await this.createOrder.execute({
customerId: req.body.customerId,
items: req.body.items
})
res.status(201).json({ id: orderId })
}
}
The controller knows about HTTP. The use case doesn't.
Dependency Injection: The Wiring
The layers are connected at the composition root — typically the application startup code:
// main.ts (composition root)
const prisma = new PrismaClient()
const orderRepo = new PrismaOrderRepository(prisma)
const productRepo = new PrismaProductRepository(prisma)
const createOrderUseCase = new CreateOrderUseCase(orderRepo, productRepo)
const orderController = new OrderController(createOrderUseCase)
The composition root is the only place that knows about all the layers. It wires them together by injecting concrete implementations into the interfaces.
This structure makes testing trivial: replace PrismaOrderRepository with InMemoryOrderRepository and your use case tests run without a database.
When Clean Architecture Is Worth the Overhead
Clean architecture adds boilerplate. Every external dependency requires an interface definition. The composition root adds explicit wiring that a framework might otherwise hide. For small projects, this overhead is not justified.
Use clean architecture when:
- Your domain has genuine business logic that benefits from isolation and testing
- The system is long-lived and will outlast its current technology choices
- Multiple teams or services need to interact with the same domain without tight coupling
- You're building something where replacing the database or framework is a realistic possibility
Skip it when:
- You're building a CRUD application with minimal business logic
- The project is small and short-lived
- The team doesn't have experience with dependency injection patterns
- Speed of initial development is the dominant concern
A CRUD API that creates, reads, updates, and deletes records with no business rules doesn't need clean architecture. It needs a framework that makes CRUD fast and a database that stores things reliably. Don't add layers of abstraction to something that doesn't have the complexity to justify them.
Clean architecture's value is proportional to domain complexity and system longevity. For the systems where it fits, the testability, replaceability, and clarity it provides are genuinely powerful. For the systems where it doesn't fit, it's ceremony without benefit.
If you're evaluating whether clean architecture is appropriate for your system or want help implementing it, let's talk.