Pragmatic Domain-Driven Design for Product Teams

Pragmatic Domain-Driven Design for Product Teams

Author avatar

Senior Software Engineer

Paul Cushing

Published

1/24/2026

Applying DDD concepts in real codebases without over-modeling or process overhead.

I went through a phase, early in my career, where I'd read the Domain-Driven Design book and decided everything was a domain. Every table got an aggregate root. Every little CRUD form got a repository, a factory, and three layers of abstraction it absolutely did not need. I was so proud of how "principled" it all was, and so blind to the fact that I'd made a settings page take two weeks to build.

Domain-Driven Design — DDD for short — is, at its heart, a simple idea: your code should speak the same language as the business it serves, and the hard parts of the business deserve careful modeling. That's it. The mistake I made, and the one I see teams make constantly, is treating it as a tax you pay everywhere instead of an investment you make where it pays off. So here's how I apply it now, after the over-engineering phase wore off.

Model where complexity lives

You do not need aggregate roots for every table. You need rich models only where business rules are genuinely volatile and mistakes are genuinely expensive. Everywhere else, a plain CRUD form and a thin service is not just acceptable — it's the right call.

The trick is learning to tell the two apart. A table that stores a user's display name and avatar? That's data. It has almost no rules; it changes only when someone edits their profile; getting it wrong costs you a slightly awkward UI. Don't build a cathedral around it. But a billing engine that has to handle proration, refunds, tax, and a dozen edge cases that the finance team argues about in Slack every week? That's a domain. The rules are complex, they change often, and getting them wrong costs you real money and real trust. That's where the careful modeling — the invariants, the explicit state transitions, the rich objects that refuse to enter an invalid state — earns every minute you spend on it.

I think of it as spending your complexity budget. You have a finite amount of "careful design" energy on any project. Pour it into the two or three places where the business is actually hard, and keep everything else boring on purpose.

Keep language aligned

If product says "workspace member" and your code says teamUser, drift has already started — and it compounds. Shared vocabulary cuts onboarding time and prevents a whole category of subtle defects that come from two people thinking they're talking about the same thing when they aren't.

This is the part of DDD I'd keep even if I threw out everything else. It costs nothing and it pays off forever. When the names in your code match the names people actually say in meetings, a new engineer can sit in a product discussion and follow it, then open the codebase and recognize what they heard. When the names drift, every conversation needs a translation layer in someone's head — and translation layers leak. Someone eventually assumes teamUser and "workspace member" are slightly different concepts, builds a feature on that assumption, and now you've got a bug that no test catches because the confusion is in the language, not the logic.

So when product renames something, rename it in the code too. Yes, it's a tedious refactor. Do it anyway. The shared vocabulary — what DDD calls the ubiquitous language, though you never have to use that phrase out loud — is the cheapest reliability investment you'll ever make.

Pair workflows with invariants

For every meaningful user workflow, write down what must always be true. Then encode those invariants in your service methods and your integration tests, so the rules live in the code instead of in the head of whoever wrote it first.

An invariant is just a rule that can never be violated, no matter what path the code takes to get there. "An order can't ship before it's paid." "A workspace always has at least one admin." "A subscription's end date is never before its start date." These sound obvious written down — and that's exactly the point. They're obvious right up until a new feature, written by someone who wasn't in the original conversation, quietly breaks one of them.

So I make them explicit. For each workflow, I list the things that must hold true, and then I make the code enforce them rather than trust them. The service method that removes a workspace member checks that it isn't the last admin, and refuses if it is. And then — this is the part that makes it stick — there's an integration test that tries to remove the last admin and asserts that it fails. That test is a love letter to the next engineer. It says, out loud and in code, "this rule matters, and here's what happens if you break it." Months later, when someone refactors that path, the test catches the violation they didn't know they were creating.


That's pragmatic DDD as I've come to practice it: model the hard parts carefully, keep the boring parts boring, speak the business's language everywhere, and write your rules down where the computer can enforce them. You'll notice none of that requires adopting a framework or rewriting your architecture. It's mostly judgment — knowing where to spend, and where to relax.

If you're staring at a codebase wondering where to start, start with the language. Open a recent product doc, open the code next to it, and just notice where the words stop matching. That gap is where your next subtle bug is already forming — and closing it is a good, honest afternoon's work.

Related Insights

View Archive