Most software does not fail because it was impossible to build. It fails because after a year or two every change takes longer than the last, small fixes break unrelated features, and nobody dares touch the core. Clean architecture and the SOLID principles are a set of ideas for preventing exactly that, by keeping the parts of a system that change for different reasons apart from each other.
Why codebases decay
Decay almost always comes from coupling: business rules that know about HTTP requests, database rows and UI components. When pricing logic lives inside a controller that also parses JSON and writes SQL, you can’t test the pricing without a web server and a database. You can’t switch the database without touching the pricing, and you can’t reuse the pricing in a background job at all.
Good architecture is about drawing boundaries so that the most valuable code, your business rules, depends on as little as possible.
The dependency rule
Clean architecture, as described by Robert C. Martin, arranges code in concentric layers:
- Entities in the centre: core business objects and rules, such as an order and how its total is calculated.
- Use cases around them: application-specific workflows, such as placing an order or issuing a refund.
- Interface adapters: controllers, presenters and repositories that convert data between the use cases and the outside world.
- Frameworks and drivers on the outside: the web framework, the database, the UI, third-party services.
The single rule that makes it work: source code dependencies point inward only. The inner layers know nothing about the outer ones. A use case never imports the database client. Instead it declares an interface describing what it needs, a port, and an outer layer provides the implementation, an adapter. This is why the style is also called ports and adapters, or hexagonal architecture.
A folder structure that makes the layers visible might look like this:
src/
domain/ entities and value objects; no framework imports
order.ts
application/ use cases and the ports they need
ports.ts
place-order.ts
infrastructure/ adapters: database, email, payment provider
postgres-order-repository.ts
email-notifier.ts
interface/ HTTP handlers, CLI commands, UI
http/orders-controller.ts
main.ts composition root: wires adapters into use casesA complete example in TypeScript
Start with the entity. It contains pure business rules and imports nothing from frameworks:
// domain/order.ts
export type OrderLine = { sku: string; quantity: number; unitPriceCents: number }
export class Order {
private constructor(
readonly id: string,
readonly customerId: string,
readonly lines: readonly OrderLine[]
) {}
static create(id: string, customerId: string, lines: OrderLine[]): Order {
if (lines.length === 0) throw new Error('An order needs at least one line')
if (lines.some((line) => line.quantity <= 0)) throw new Error('Quantities must be positive')
return new Order(id, customerId, lines)
}
get totalCents(): number {
return this.lines.reduce((sum, line) => sum + line.quantity * line.unitPriceCents, 0)
}
}The use case describes the workflow and declares the ports it depends on. Note that the interfaces are owned by the application layer, not by the database code:
// application/ports.ts
import type { Order } from '../domain/order'
export interface OrderRepository {
save(order: Order): Promise<void>
}
export interface Notifier {
orderPlaced(order: Order): Promise<void>
}
// application/place-order.ts
import { Order, type OrderLine } from '../domain/order'
import type { Notifier, OrderRepository } from './ports'
export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly notifier: Notifier
) {}
async execute(input: { customerId: string; lines: OrderLine[] }) {
const order = Order.create(crypto.randomUUID(), input.customerId, input.lines)
await this.orders.save(order)
await this.notifier.orderPlaced(order)
return { orderId: order.id, totalCents: order.totalCents }
}
}An adapter in the outer layer implements the port with real technology, and a composition root wires everything together at startup:
// infrastructure/postgres-order-repository.ts
import type { Pool } from 'pg'
import type { OrderRepository } from '../application/ports'
import type { Order } from '../domain/order'
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly db: Pool) {}
async save(order: Order) {
await this.db.query('INSERT INTO orders (id, customer_id, total_cents) VALUES ($1, $2, $3)', [order.id, order.customerId, order.totalCents])
}
}
// main.ts
const placeOrder = new PlaceOrder(new PostgresOrderRepository(pool), new EmailNotifier(mailer))The payoff shows up in testing. The use case can be tested in milliseconds with simple in-memory fakes, with no database, no mail server and no mocking library:
import { expect, test } from 'vitest'
import { PlaceOrder } from './place-order'
import type { Order } from '../domain/order'
test('places an order and notifies the customer', async () => {
const saved: Order[] = []
const notified: Order[] = []
const placeOrder = new PlaceOrder({ save: async (order) => void saved.push(order) }, { orderPlaced: async (order) => void notified.push(order) })
const result = await placeOrder.execute({
customerId: 'customer-1',
lines: [{ sku: 'TS-1042', quantity: 2, unitPriceCents: 1500 }],
})
expect(result.totalCents).toBe(3000)
expect(saved).toHaveLength(1)
expect(notified).toHaveLength(1)
})The SOLID principles, one at a time
SOLID is a set of five object-oriented design principles that support the same goal at the level of individual classes and modules.
S: Single Responsibility Principle
A module should have one reason to change, meaning it should answer to one group of people or one concern. An invoice class that calculates tax, renders a PDF and sends email will change whenever finance, design or IT change their minds. Split it into a calculator, a renderer and a sender.
O: Open/Closed Principle
Code should be open for extension but closed for modification: you add behaviour by adding new code, not by editing tested code. A common technique is the strategy pattern:
interface DiscountRule {
appliesTo(order: Order): boolean
amountCents(order: Order): number
}
const rules: DiscountRule[] = [new LoyaltyDiscount(), new BulkOrderDiscount()]
export const discountCents = (order: Order) => rules.filter((rule) => rule.appliesTo(order)).reduce((sum, rule) => sum + rule.amountCents(order), 0)
// A new promotion is a new class in the list, not another branch in a growing if/elseL: Liskov Substitution Principle
Any implementation of an interface must be usable wherever the interface is expected without surprising the caller. A ReadOnlyOrderRepository that implements OrderRepository but throws on save() breaks this. Callers that rely on the contract will fail at runtime. If something cannot honour the whole contract, it needs a different, smaller interface.
I: Interface Segregation Principle
Clients should not depend on methods they don’t use. Prefer several small, focused interfaces, such as OrderReader and OrderWriter, over one large interface with twenty methods. Small interfaces are easier to implement, easier to fake in tests and easier to change.
D: Dependency Inversion Principle
High-level policy should depend on abstractions, not on concrete details. The PlaceOrder use case above depends on the OrderRepository interface, not on PostgreSQL, so the database can change without the business logic noticing. This principle is the engine of the dependency rule.
Design patterns worth knowing
- Repository: hides how entities are stored behind a collection-like interface.
- Strategy: swaps interchangeable algorithms, such as pricing, shipping or discount rules, behind one interface.
- Adapter: wraps a third-party API in an interface that your application defines.
- Factory: centralizes complex object creation and validation, as
Order.createdoes. - Observer and domain events: let other parts of the system react to "order placed" without the order knowing who is listening.
- Decorator: adds caching, logging or retries around an existing implementation without changing it.
When not to over-engineer
Clean architecture is a tool, not a religion. A small CRUD app with little business logic does not need four layers and a dozen interfaces. The ceremony would cost more than it saves. Apply the ideas in proportion to the complexity and expected lifetime of the code:
- Always keep business rules out of controllers and UI components, even in small projects. It costs almost nothing.
- Introduce ports and adapters where you have real reasons: external services you may replace, logic that needs fast tests, or several entry points such as API, jobs and CLI.
- Don’t create an interface that has exactly one implementation and no test fake. Add it when the second need appears (you aren’t gonna need it, or YAGNI).
- Refactor towards the architecture incrementally, one module at a time, guided by where change actually hurts.
Key takeaways
- Maintainability comes from managing dependencies: keep business rules independent of frameworks and infrastructure.
- Dependencies point inward. Use cases declare ports and outer layers provide adapters.
- SOLID applies the same thinking to classes and modules: one responsibility, extension over modification, honest contracts, small interfaces, and dependence on abstractions.
- Match the amount of architecture to the complexity of the problem.
Conclusion
Clean architecture is ultimately about protecting your most valuable code, the rules that make your business work, from the churn of frameworks, databases and delivery mechanisms. Keep dependencies pointing inward, keep modules focused, and test business logic without infrastructure. Your codebase will then stay fast to change in its fifth year as well as its first.
Further reading
- #Architecture
- #Clean Architecture
- #Design Patterns
- #SOLID
- #TypeScript




