Skip to main content
HomeBlogServicesLinks
RSS

Clean Architecture: Organizing Your Codebase Around the Boundary

30 Jun 2026
  • Architecture
  • Learning
  • Engineering
  • Tech

Table of Contents

  • Introduction
  • Picking Up Where We Left Off
  • Uncle Bob's Four Rings
  • The Dependency Rule
  • A Library Reservation System, Layered
  • Before vs After
  • Three Rules to Live By
  • Quick Recap
  • Reference

Introduction

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.

Image1

Picking Up Where We Left Off

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.

Uncle Bob's Four Rings

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.

Image2

The Dependency Rule

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.

Image3

A Library Reservation System, Layered

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.

Image4

Before vs After

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.

Three Rules to Live By

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.

Image5

Quick Recap

  • Clean Architecture doesn't replace Ports and Adapters - it labels the inside of the hexagon with four rings: Entities, Use Cases, Interface Adapters, Frameworks & Drivers
  • The Dependency Rule: source code dependencies only point inward, never outward
  • Entities hold enterprise-wide business rules and know nothing about the layers above them
  • Use Cases orchestrate entities to do something a user wants, and define ports for anything they need from the outside
  • Interface Adapters translate between the use case's plain-value world and the outside world's shape (HTTP, SQL rows)
  • Frameworks & Drivers is where your web framework, your ORM, and your database engine actually live - swappable, replaceable, dumb
  • The composition root is the one place allowed to know about every ring at once
  • Folder structure that mirrors the rings makes violations visible at a glance

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

Reference

  • The Clean Architecture - by Robert C. Martin
  • Get Your Hands Dirty on Clean Architecture - by Tom Hombergs

Comments

Add a new comment
Supports markdown

Enjoyed this one?

Subscribe to get posts like this straight to your inbox - no noise, just quality content.

We care about your data. Read our privacy policy.

Stay Connected

GitHub •LinkedIn •X •Daily.dev •Email

© 2026 Chiristo. Feel free to share or reference this post with proper credit