Clean Architecture and DDD in practice: what I’d keep — and the layers I stopped drawing

Casey Holt

Casey Holt

September 18, 2026

Clean Architecture and DDD in practice: what I’d keep — and the layers I stopped drawing

I have drawn the concentric circles. Entities in the middle, use cases around them, controllers and presenters on the next ring, frameworks on the outside. I have also shipped a “clean architecture” folder tree that took a week to add a column because the column had to pass through a DTO, a domain model, a repository interface, a repository impl, a mapper, and a presenter model that was the DTO again. Uncle Bob’s diagram is a good sermon about dependency direction. It is a bad floor plan for a team of five with a deadline.

Domain-Driven Design is the other poster I have used well and badly. Aggregates, bounded contexts, ubiquitous language — those words earned their keep on a billing domain. The tactical pattern catalog — factories, specifications, repositories for every noun — did not. Here is the subset I still keep, and the layers I stopped drawing after they cost me a quarter.

What I still steal from the books

A language the business will argue about. If finance says “void” and support says “cancel,” I will not hide that in a status enum with twelve values. I will pick words and I will write them in the code. That is DDD even if I never say aggregate. Event names in the past tense — InvoiceVoided — still help me more than a doStuff() service.

A boundary where two models disagree. Catalog products are not billing line items even if they share a SKU string. I will keep two types. I will map at the edge. I will not create a shared “Enterprise Entity” that makes both teams sad. That mapping is the anti-corruption layer without the ceremony: a function, a small package, not a framework.

Dependency direction. The domain should not import Stripe’s SDK. The webhook handler can. If I need to test proration, I want values, not a live API. That is the useful part of Clean Architecture. I implement it as: money math in a package that does not know HTTP. I do not implement it as eight projects in a .NET solution because a template said so.

Aggregates when I have a consistency rule that must hold in one transaction. An invoice and its lines: I do not want a line that points at a voided invoice. That cluster is an aggregate. A user and their last 400 notifications are not. I will not load a god object because the book said “aggregate root.”

Notebook with concentric architecture circles next to a laptop

The layers I stopped drawing

A use-case class per application operation, each with an interface, each with a request and response DTO that mirrors the controller. I did this in a Kotlin service. The use case called one repository method and mapped a record. We had invented a tax on every endpoint. I now write a handler or a Rails controller that calls a command object only when the command has a real invariant or a real orchestration. “List invoices” is a query. It can be a function that returns rows. It does not need ListInvoicesUseCase.

A repository interface in the domain with an implementation in “infra” when there is one database and one query style. I still like the idea of a port. I will write the port when I have two implementations or when the test story requires it. I will not write IInvoiceRepository for a single Postgres and a single sqlc query. The interface becomes a place to hide a 40-line SQL string that should have been reviewed as SQL.

Presenters. I have almost never needed a presenter layer in an API. JSON serialization is the presenter. For HTML I have templates. For a CLI I have fmt. A InvoicePresenter that turns a domain object into a view model that is identical to the domain object is a file I delete.

Entities that are anemic twins of tables plus a service that has all the behavior. That is not DDD. That is an ORM with extra steps. If the object cannot protect an invariant, it is a record. Call it a record. Put the rule in a function with a name.

A “domain events” bus inside the monolith that nobody consumes except the tests. Events are good when a second listener is real or when I need an audit. A bus I invented so the architecture looked event-driven is a debugging hobby.

The one clean architecture I shipped, and what survived the deadline

We had a contract to rebuild a quoting tool. The lead had a template: domain / application / infrastructure / presentation. I went along. Week one felt adult. Week three, product asked for a “quick” discount type that depended on a Salesforce field. The field lived in infra. The rule wanted to live in domain. We either leaked the CRM into the center or we invented a DiscountPolicyPort that was a thin lie over a REST call. We did the port. Then we did three more ports. Then the deadline arrived and we put the rule in the controller because the customer was waiting.

What survived: the ubiquitous language document (a page, not a wiki), the quote aggregate that refused a discount after lock, and the rule that Salesforce stays at the edge. What did not survive: the folder purity. The controller still has a bit of orchestration. I am not proud of every line. I am proud that a locked quote cannot sprout a new discount without going through one function we test. That function is the architecture. The rings were a costume.

If I were starting that tool again I would use the same aggregate and the same edge mapping. I would start in one module. I would add a port when the second implementation showed up — a fixture, a second CRM — not on day one.

Small team pairing at a table with a simple domain sketch on paper

How this shows up in Rails, Spring, and Go — the stacks I actually use

In Rails I keep fat models from becoming dumping grounds by using PORO commands for the dangerous writes: Quotes::Lock, Billing::VoidInvoice. I do not ban Active Record in the domain. I ban callbacks that call HTTP. I use events (ActiveSupport or a table) when a second listener exists. I do not pretend Rails is not the framework. Clean Architecture that denies the framework is how you rewrite validates poorly.

In Spring Boot I will have a module for the domain if the service is big. I will not have a package per onion layer across the whole app. I have seen com.company.invoice.domain next to com.company.invoice.application next to com.company.invoice.infra for every noun. That is a fractal of ceremony. Prefer package-by-feature: invoice contains the aggregate, the controller, the JPA bits, with a small api surface other features may import. Feature cohesion beats layer cohesion when the team is small.

In Go I use internal packages. internal/billing does not import internal/http. cmd/api wires them. That is Clean Architecture in twenty minutes. I do not generate a use-case interface for every handler. sqlc lives next to the queries. The domain is functions and types that do not import database/sql when the rule is pure. When the rule is “does this row exist,” I let it see the store. Purity that requires loading the world into memory is not purity. It is a leak with extra RAM.

DDD tactical patterns I treat as optional

Factories: yes when construction is a story (a quote from a draft plus a catalog snapshot). No when it is new plus three fields.

Value objects: yes for money, for tax IDs, for ranges that have rules. No for UserId wrappers in a language that makes them noisy unless the team already likes that. I have been on teams where UserId saved us from mixing IDs. I have been on teams where it was a religion. Match the language. In F# and Kotlin, cheap. In Python, a dataclass if it carries a rule, not a vibe.

Specifications: almost never. A function that returns bool is a specification. A class hierarchy of specs is how you get a query builder you cannot explain to SQL.

Domain services: when an operation does not belong to one aggregate — transferring between two accounts — and I do not want a god account. I name it after the operation, not AccountDomainService.

Sagas and process managers: when I already have multiple commit points. Not as a default for “we might go async.”

A working order I use on a new domain

  1. Talk until we can write ten events and three invariants in a doc. If we cannot, we do not need layers. We need a conversation. Sometimes that conversation is a workshop; sometimes it is a page.
  2. Write the dangerous write path as a script with a test. Protect the invariant in the smallest type that can hold it.
  3. Put I/O at the edges. Do not draw four rings. Draw one arrow: inward for rules, outward for adapters.
  4. Add a port when a test or a second vendor demands it. Not before.
  5. Split a bounded context when two languages fight in the same model — not when we want a new repo.

That order has survived contact with a deadline better than any template. The books are still worth reading. Evans is still worth reading for the language part. Vernon is useful if you already have a mess of aggregates. The clean-architecture repo from a conference speaker is useful as a warning: count the files you touch to add a field. If the number is funny, the architecture is funny.

What I tell a team that wants “to do DDD”

Start with the language and one invariant. Ship. If someone asks for the full tactical pattern set in week one, they are asking for a course, not a product. I will run a course. I will not let the course become the milestone.

If the team is coming from a big-ball-of-mud Rails app, I will not replace it with a big-ball-of-mud plus interfaces. I will pick the muddiest write path — usually money — and I will make that path boring and tested. That is DDD in practice. The rest of the app can stay ordinary. Ordinary is a compliment. Most screens are ordinary. Architecture is for the parts that ruin your week when they are wrong.

I keep Clean Architecture as a dependency rule, not as a directory standard. I keep DDD as a way to talk and to protect a few clusters of data. I stopped drawing the extra layers when I could not name the change they made cheaper. Naming the reason is the same habit I want in class design. The posters can stay on the wall. The code has to ship on Tuesday.

More articles for you