Domain-Driven Design for small projects
Domain-Driven Design for small projects: practical applications and recommendations for adopting the concepts without going overboard.
The CRUD that became a cathedral
I once watched a team take a customer registry app — literally four tables and a form — and turn it into a DDD monument. It had an aggregate root, repository, domain event, application service, infrastructure layer, and a Domain/ folder with more files than the system had features. To create a customer, the request passed through seven layers. Nobody knew where to put a new validation. The code wasn't wrong for using DDD; it was wrong for using heavy DDD on a problem that didn't ask for it. Architecture cargo cult is expensive and silent.
Here I separate what's worth stealing from Domain-Driven Design in a small project from what you can ignore guilt-free. If you still confuse the basic pieces, it's worth understanding the differences between DTO, entity and value object first — because half the practical value of DDD in a small project is in modeling those three things right.
DDD is two projects under one name
The biggest source of confusion is treating DDD as a single thing. It isn't. It's two nearly independent sets: the strategic (bounded contexts, ubiquitous language, subdomain mapping) and the tactical (entities, value objects, aggregates, repositories, domain services). When someone says "let's use DDD" and opens an Infrastructure/ folder, they're usually talking only about the tactical — and the tactical in excess.
In a small project, the strategic is where almost all the return lives, and it's practically free. The tactical has valuable parts and parts that are pure overhead. The common mistake is importing the entire tactical pattern table and ignoring the strategic, which is exactly the part that costs little and pays a lot.
What to steal even in a small project
Four DDD ideas pay rent at any project size, including a CRUD:
- Ubiquitous language: name things in code by the name the business uses. If the client says "policy", don't have a
Contractclass. This costs nothing and eliminates a whole class of mental-translation bugs. - Bounded context: even in a monolith, draw boundaries. "Billing" and "Catalog" can live in the same deploy, but they shouldn't share the same
Productmodel — the product billing sees is different from the one catalog sees. - Entity vs value object: distinguish what has identity from what is just a value. An
Orderhas identity (two orders with the same items are different orders). AMoneyor anAddressis a value object — compared by value, ideally immutable. - Domain logic out of the controller: business rules live in the model, not in the controller nor in an anemic service that just passes things along. This alone fixes 80% of the CRUDs that turn into a mess.
# value object: immutable, compared by value
@dataclass(frozen=True)
class Money:
cents: int
currency: str
def add(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("different currencies")
return Money(self.cents + other.cents, self.currency)
Notice Money carries the rule (don't add different currencies) inside itself. That's tactical DDD at its best: the domain protects itself, and no controller has to remember to validate.
What to skip without guilt
And here's the part nobody tells you: most of the DDD tactical catalog is overhead in a small project.
- Event sourcing: storing all state as a sequence of events is powerful and very expensive to maintain. In a CRUD, you want a
customerstable, not an event store with projections. Only consider it if full change auditing is a real business requirement. - CQRS: splitting read and write models solves a scale problem you probably don't have. A single model with normal queries serves 99% of small projects.
- Aggregates everywhere: an aggregate root makes sense when there are complex invariants across several entities that must change together in a transaction. Forcing an aggregate per table is ceremony. Most CRUDs have zero internal invariants — it's just data.
- An abstract repository on top of every table: if your repository only does
saveandfind_by_idpassing through to the ORM, it's a layer of indirection with no gain. The ORM is already your repository.
The question for each tactical pattern is: what domain invariant or what real complexity does it protect? If the answer is "none, but it's DDD", cut it.
The cargo cult trap
The way DDD goes wrong in a small project almost always has the same signature: someone read the blue book, liked the diagrams, and replicated the layers without the problem that justifies them. The symptom is a domain rich in folders and poor in rules — you open Domain/ and find anemic entities, with getters and setters, and all the business logic scattered across application services that become a new kind of controller, just further away.
The honest test: if you remove half the layers and the code gets easier to understand without losing any business rule, those layers were cargo cult. DDD isn't about having the right folders. It's about the model reflecting the domain and the logic living where the rule lives.
Pragmatic adoption
The recipe that works in a small project is incremental and starts with the cheap stuff:
- Adopt the ubiquitous language today — it's just renaming things.
- Draw the bounded contexts on paper, even if everything lives in a monolith.
- Model entities and value objects with the logic inside them, taking rules out of the controller.
- Add a heavy tactical pattern only when a concrete complexity appears — a real invariant across entities, a real auditing need, a real read bottleneck.
Start simple and let the domain's complexity pull the patterns, not the other way around. A growing CRUD will warn you when it actually needs an aggregate. Before that, an aggregate is just an expensive word for a table.
Frequently asked questions
Is DDD worth it in a small project?
The strategic part and the entity/value object modeling, yes — they cost little and improve clarity a lot. The heavy tactical catalog (event sourcing, CQRS, aggregates everywhere), no — it's overhead. DDD in a small project is stealing the cheap ideas and ignoring the expensive ones.
Do I need the Domain/Application/Infrastructure layers in a CRUD?
Almost never. That separation solves coupling in complex systems. In a small CRUD, it usually adds indirection without protecting any rule. Keep domain logic in the models and out of the controllers; that already delivers the essentials without three layers of ceremony.
What's the difference between strategic and tactical DDD?
The strategic is about boundaries and language: bounded contexts, ubiquitous language, subdomain mapping — the high-level design. The tactical is about the building blocks inside a context: entities, value objects, aggregates, repositories. In a small project the strategic pays more and costs less.
How do I know if I'm cargo-culting DDD?
If you added patterns because "that's how DDD is done", not because a concrete problem demanded it, it's cargo cult. The test: remove the layer and see if any business rule or invariant is lost. If nothing is lost and the code gets clearer, the layer was decoration.
What to take to your next project
DDD in a small project isn't adopting the whole book — it's having judgment about which ideas pay rent at your problem size. Steal the ubiquitous language, the bounded contexts, the entity/value object distinction and the rule of keeping logic out of the controller; those things are nearly free and improve any CRUD. Skip event sourcing, CQRS and compulsory aggregates until real complexity justifies them. And be suspicious of any layer you can't justify with a concrete invariant — it's probably there by imitation, not by need. Let the domain pull the complexity, never the other way around.
- 01 UTF-8 and encoding problems: why accents break and how to fix them What UTF-8 is, code points, mojibake, and why accented text turns to garbage — the mechanism explained end to end.
- 02 MIT, Apache, and GPL Licenses: Practical Differences for Developers MIT, Apache 2.0, and GPL are not the same thing. Understand permissive vs copyleft, patent protection, and compatibility before shipping code to production.