Hey devs, welcome back to Architecture Patterns, post two in the series.
Last time we drew a line between domain and I/O and called that line a port. That line solves a real problem - your business logic stops depending on Postgres or SendGrid. But it leaves a question open - once you've drawn that one boundary, how do you organize everything else? Where do use cases live? Where do controllers live? What's allowed to depend on what?
That's where Clean Architecture comes in. It's not a rival to Ports and Adapters rather, it's Ports and Adapters with the inside of the hexagon actually labeled.

Quick refresher, since we're building directly on it.
The domain defines ports - interfaces written in domain language. Adapters implement those ports and live outside the domain. The domain never imports an adapter, it only ever calls a port. That gave us one clean boundary - domain on one side, infrastructure on the other.
Clean Architecture, introduced by Robert Martin in 2012, takes that same idea and adds structure to both sides. Instead of domain and everything else, you get four concentric rings, each with a specific job, each with one rule about what it's allowed to know about.
Picture concentric circles. Innermost to outermost:
1. Entities
The enterprise-wide business rules. The stuff that would still be true even if you rebuilt this entire application from scratch in a different language. No framework code, no I/O, not even awareness that a use case exists above it.
2. Use Cases
Application-specific business rules. This is where you orchestrate entities to do something a user actually wants - reserve a book, cancel a subscription, place an order. Use cases know about entities. They do not know about HTTP, databases, or UI frameworks.
3. Interface Adapters
Controllers, presenters, gateways. This ring converts data between the shape the use case wants and the shape the outside world wants. A REST controller translating a JSON body into a use case input is interface adapter work. So is a repository implementation translating a domain entity into a SQL row.
4. Frameworks & Drivers
The outermost ring. Express, NestJS, your ORM, your database engine, your message broker. Volatile, swappable, replaceable. Glue, not logic.
If ring 3 is where Ports and Adapters' adapters live, ring 1 and 2 together are roughly where the *domain *from last post lives - just split into two more precise layers.

Everything else in Clean Architecture is commentary on one sentence:
Source code dependencies can only point inward.
Entities know nothing about use cases. Use cases know nothing about controllers. Controllers know nothing about which database driver you picked. An inner ring is never allowed to import, reference, or even be aware that an outer ring exists.
This is the same dependency inversion trick from last post, just applied at every boundary instead of one. When ring 2 needs something from ring 4 - say, persistence - it doesn't reach outward. It defines an interface inward-facing, and ring 4 implements it. Sound familiar? That interface is a port. Clean Architecture doesn't rename the trick, it just tells you exactly where to apply it, ring by ring.

New example this time, We're building reservations for a library - a member can reserve a book, but only if they have fewer than 3 active reservations and the book is currently available.
The snippets below are written as pseudocode on purpose - the point of each ring is the boundary it draws, not the language you draw it in. The same four-ring split holds whether you're working in TypeScript, Go, Python, or Java.
Ring 1 - Entities
// entities/Book
class Book:
id
title
available
// entities/Reservation
class Reservation:
id
memberId
bookId
expiresAt
Plain objects. No decorators, no database concerns, no idea a web server even exists.
Ring 2 - Use Cases
// use-cases/ReserveBook
interface BookRepository:
findById(bookId) -> Book or null
markUnavailable(bookId)
interface ReservationRepository:
countActiveByMember(memberId) -> count
save(reservation)
class ReserveBookUseCase:
constructor(books: BookRepository, reservations: ReservationRepository)
function execute(memberId, bookId) -> Reservation:
book = books.findById(bookId)
if book is null or book.available is false:
throw Error("Book is not available")
if reservations.countActiveByMember(memberId) >= 3:
throw Error("Reservation limit reached")
expiresAt = now() + 48 hours
reservation = new Reservation(generateId(), memberId, bookId, expiresAt)
books.markUnavailable(bookId)
reservations.save(reservation)
return reservation
Notice BookRepository and ReservationRepository are defined right here, inside the use case file. That's the inward-facing port from last post, just living in ring 2 now instead of floating loosely in the domain.
Ring 3 - Interface Adapters
// interface-adapters/PostgresBookRepository
class PostgresBookRepository implements BookRepository:
function findById(bookId):
row = db.query("SELECT * FROM books WHERE id = ?", bookId)
return row exists ? new Book(row.id, row.title, row.available) : null
function markUnavailable(bookId):
db.execute("UPDATE books SET available = false WHERE id = ?", bookId)
// interface-adapters/ReservationController
class ReservationController:
constructor(reserveBook: ReserveBookUseCase)
function handle(request) -> response:
try:
reservation = reserveBook.execute(request.body.memberId, request.body.bookId)
return { status: 201, body: reservation }
catch err:
return { status: 400, body: { error: err.message } }
The repository translates SQL rows into entities. The controller translates HTTP into a use-case call. Neither of them makes a business decision - that already happened in ring 2.
Ring 4 - Frameworks & Drivers
// frameworks/server
app = createWebServer()
controller = new ReservationController(
new ReserveBookUseCase(
new PostgresBookRepository(),
new PostgresReservationRepository()
)
)
app.post("/reservations", (request, response) -> {
result = controller.handle(request)
response.send(result.status, result.body)
})
Express, Fastify, Fiber, Gin, Spring - whatever your framework of choice is, it lives here, and only here. Wiring everything together - this is called the composition root - also lives here. It's the one place allowed to know about every ring at once, because it's not making decisions, it's just assembling the machine.

Everything above looks clean on paper, so it's worth being explicit about what you're actually buying with it - and what it costs you when you skip it.
Without these boundaries, nothing stops a route handler from running a SQL query, applying a business rule, and formatting an email inside the same function. It'll work. It'll even ship fast at first. But the reservation limit, the SQL, and the HTTP plumbing all end up tangled in one place, so changing any one of them risks breaking the other two - and you can't test the business rule without spinning up a database and an HTTP client to get to it. That's the cost. Here's what it looks like next to the layered version above.
Before: one folder, one giant file, every ring tangled together
src/
reservations // routes, SQL, business rules, all in one file
Change the reservation limit from 3 to 5, and you're scrolling past route handlers and raw SQL to find the one line that matters. Test that limit, and you need a running database instance and an HTTP client.
After: folders that mirror the rings
src/
entities/
Book
Reservation
use-cases/
ReserveBook
interface-adapters/
PostgresBookRepository
ReservationController
frameworks/
server
Now the reservation limit lives in exactly one place - ReserveBookUseCase - and you can test it with fake repositories, zero database, zero HTTP.
class InMemoryBookRepository implements BookRepository:
constructor(books: map of id -> Book)
function findById(id):
return books.get(id) or null
function markUnavailable(id):
books.get(id).available = false
class InMemoryReservationRepository implements ReservationRepository:
reservations = []
function countActiveByMember(memberId):
return count of reservations where reservation.memberId equals memberId
function save(reservation):
reservations.add(reservation)
useCase = new ReserveBookUseCase(
new InMemoryBookRepository({ "book-1": new Book("book-1", "Dune", true) }),
new InMemoryReservationRepository()
)
reservation = useCase.execute("member-1", "book-1")
assert reservation.bookId equals "book-1"
Same folder structure, same ring boundaries, and the test runs in milliseconds.
Rule 1: Use cases don't know HTTP exists.
// WRONG: use case reaching outward into ring 3/4
class ReserveBookUseCase:
function execute(request):
memberId = request.body.memberId // <- ring 2 now depends on ring 4
// ...
If your use case's method signature mentions a request or response object, or anything web-framework-shaped, the dependency rule already broke. Pass plain values in, get plain values or entities back out.
Rule 2: Entities don't reach for use cases, and use cases don't reach for adapters.
// WRONG: an entity calling out to infrastructure
class Reservation:
function save():
db.execute("INSERT INTO reservations ...", this) // entity now depends on ring 4 directly
Saving is a ring 2 + ring 3 concern, driven by a repository interface, not something an entity does to itself. An entity that knows how to persist itself has quietly collapsed every ring into one.
Rule 3: The composition root is the only place allowed to see every ring.
Everywhere else, imports should only ever point inward. frameworks/server is the one exception - it's where concrete adapters get handed to use cases, where use cases get handed to controllers. If you find database imports creeping into a controller file instead of staying in the composition root, that's the dependency rule leaking.

In the next post, we'll look at where Domain-Driven Design fits on top of all this - aggregates, value objects, and how to stop your entities from turning into glorified data bags.
Code, learn, refactor, repeat
Subscribe to get posts like this straight to your inbox - no noise, just quality content.