One Model to Rule Them All: Domain-Driven Design, The Fellowship of the Codebase
Or: How I learned to stop worrying and name my classes after things that actually exist in the real world.

I want to tell you about the day I realized our UserManager class had 47 methods.
Forty. Seven.
It handled login, password resets, profile updates, billing, email preferences, audit logs, and — I am not making this up — PDF generation. It was not a class. It was a UserManager in the same way Sauron was just a landlord. Technically true. Wildly underselling the chaos.
That was the day I started taking Domain-Driven Design seriously.
And because I've rewatched the extended editions of Lord of the Rings more times than I've attended productive sprint planning meetings, I will be explaining DDD entirely through Middle-earth. Buckle up.
The One Ring Problem (a.k.a. The God Object)
Every codebase has one. You know it. I know it. The CI/CD pipeline knows it and chooses not to say anything.
It's the class — or worse, the service — that does everything. The OrderService that also sends emails, calculates tax, updates inventory, logs to three different places, and somehow still has a method called DoTheThing() that nobody dares delete because git blame says it was written by someone who left in 2019.
This is The One Ring. It calls to everyone. New devs add to it because everything they need is already there. Senior devs add to it because refactoring it is a two-sprint effort that Product will never prioritize. It corrupts all who wield it.
DDD says: destroy it. Not refactor it. Not extract a few methods. Melt it in the fires of Mount Doom, which in this metaphor is a new solution folder with actual bounded contexts and a Git commit message that says "here we go."
Bounded Contexts: The Kingdoms of Middle-earth
The core insight of DDD — the thing that actually changed how I write software — is this:
The same word means different things in different parts of your system, and that's okay. Stop fighting it.
In Lord of the Rings:
"Ranger" in the Shire means some suspicious-looking dude who keeps showing up at the pub.
"Ranger" in Gondor means a trained soldier of a secretive order that's been protecting your kingdom for generations, you ungrateful hobbits.
Same word. Completely different meaning. Different context.
In your codebase:
Customerin the Sales context means someone with a CRM record, a lead score, and a contract value.Customerin the Billing context means someone with a payment method, an invoice history, and a credit limit.Customerin the Support context means someone with a ticket queue and an SLA agreement.
These are not the same object. Stop sharing them. Stop making a Customer class with 80 properties so it can serve all three. What you've built is not a reusable model. It's Shelob's web — sticky, large, and everything that enters it dies.
Each Bounded Context gets its own model. Yes, this means some duplication. Good. Duplication in separate contexts is not a bug. It is independence. It is the reason you can deploy your Billing service without asking the Sales team to stand by.
Ubiquitous Language: Speak Elvish or Go Home
Here is a conversation I have had, in various forms, approximately one hundred times:
Me: "So when an order is confirmed, do we send the email?" Product: "Well, it's not really confirmed confirmed. It's more like… submitted?" Me: "Oh, so it's in a Submitted state?" Product: "No, submitted is before approval. This is after approval but before it goes to fulfillment." Me: "So it's… Approved?" Product: "We don't use that word internally. We call it Cleared." Me: opens a new private browsing tab to check job listings
This is the absence of Ubiquitous Language — the DDD concept that says the developers and the domain experts must use the same words for the same things, and those words must live in the code.
Not isApproved = true. Not status = 3. Not OrderFlag.CLEARED_FOR_FULFILLMENT_V2.
Your enum value should be called OrderStatus.Cleared if that's what the business calls it. Your method should be order.Clear() not order.UpdateStatusToApprovedForFulfillment().
In Middle-earth, Gandalf did not say "apply the temporary ring-suppression enchantment to the door." He said "You shall not pass." Everyone knew what that meant. It was in the ubiquitous language of the fellowship.
Your code should read like your domain, not like someone tried to translate domain language into computer words and ended up with something that sounds like a legal document written by a robot.
Entities vs. Value Objects: Aragorn vs. "A Sword"
This distinction broke my brain for longer than I'd like to admit.
Entities have identity. They persist over time. They can change and still be the same thing.
Aragorn is an Entity. He starts as a ranger who won't tell you his name, becomes a king, gets a better sword, grows a beard, loses the beard, and through all of that — he is still Aragorn. He has an identity (son of Arathorn, heir of Isildur) that is separate from his current attributes.
In code: Customer, Order, Product — these are entities. Your Customer with ID 4521 who updates their email address is still Customer 4521. Their identity doesn't change.
Value Objects have no identity. They are defined entirely by their attributes. Two value objects with the same values are, for all purposes, the same thing.
"A sword from Rohan, 80cm blade, steel" is a Value Object. There might be a thousand of them in the armory. You don't care which specific one a soldier picks up. They're interchangeable. If you replace one with an identical one, nothing meaningful has changed.
In code: Money, Address, DateRange, Coordinates — these are value objects. new Money(100, "PHP") and new Money(100, "PHP") are the same thing. They should even be immutable. You don't change a value object, you replace it.
I used to model everything as entities, giving IDs to things that didn't need them. AddressId. PhoneNumberId. EmailId. My database looked like a tax form. Value objects cleaned all of that up.
Aggregates: The Fellowship as a Deployment Unit
This is where DDD gets spicy.
An Aggregate is a cluster of entities and value objects that are treated as a single unit for the purpose of data changes. It has one root — the Aggregate Root — and all access to anything inside goes through that root.
The Fellowship of the Ring is an aggregate. Frodo is the Aggregate Root.
You don't go to Legolas directly to ask where the fellowship is going. You go to Frodo. You don't modify Samwise's state without going through the fellowship's current objective. All external interaction — the Council, the elves, Galadriel — goes through Frodo (or Gandalf, but Gandalf is basically a domain service and we'll get there).
In code: an Order aggregate contains OrderLines, a ShippingAddress, and a PaymentIntent. You do not fetch an OrderLine directly from the database and update it. You load the Order, modify it through Order.UpdateLineQuantity(lineId, quantity), and save the whole Order. The Order enforces all the invariants — "you can't have an order with zero lines," "you can't change a shipped order" — because it owns all the data it needs to make those decisions.
This also means your Order repository only loads and saves Order objects. Not OrderLine objects separately. This was deeply uncomfortable for me when I came from an anemic domain model background where everything was a DTO being shuffled between repositories. But once it clicked, the invariants started enforcing themselves and I stopped writing if (order.Status != OrderStatus.Shipped && orderLine != null && orderLine.Quantity > 0) in my service layer.
Domain Events: The Eagles Are Coming
When Frodo puts on the ring and Sauron sees him — that happened. It's a fact. Other things react to it. The Ringwraiths move. Sauron looks. The whole world responds to that moment.
That is a Domain Event.
Domain events are things that happened in your domain that other parts of the system might care about. OrderPlaced. PaymentConfirmed. CustomerDeactivated. They are named in the past tense because they have already occurred. They are facts.
You publish them. Other bounded contexts subscribe to them. The Sales context doesn't need to know about Billing's internal implementation — it just needs to hear CustomerUpgradedToPremium and react accordingly.
In my current stack this maps beautifully to Azure Service Bus topics. Each domain event becomes a message. Each bounded context that cares about it has a subscription. They're decoupled. They scale independently. When one context is down, the others keep running and process the backlog when it comes back.
This is clean architecture singing in harmony. And if you squint, it's basically the eagles — they show up exactly when they're needed, do their thing, and leave. No tight coupling. No direct dependency. Just an event and a handler.
(Yes, yes, "why didn't they just ride the eagles to Mordor" — they're a Domain Event, not a feature. They respond to things. They don't drive the plot. Now you understand why they didn't go to Mordor.)
Anti-Corruption Layer: The Translation Desk at Rivendell
Eventually, your beautiful bounded context needs to talk to the outside world. Maybe it's a legacy system. Maybe it's a third-party API. Maybe it's a service built in 2011 that uses XML and has field names like cust_tp_cd that nobody has documented.
You do not let that garbage leak into your domain.
You build an Anti-Corruption Layer (ACL) — a translation layer that converts the external model into your domain model. Think of it as the translators at Rivendell. The Dwarves speak Khuzdul, the Elves speak Sindarin, Men speak Westron, and somehow the Council of Elrond makes decisions anyway. Someone is doing translation work. That work is explicit and contained.
In code: your ACL receives the external DTO, validates it, and maps it to your domain objects. Your domain never sees cust_tp_cd = "B2B". It sees CustomerType.Business. The mapping lives in one place. When the external system changes their field names (and they will), you update the ACL and your domain doesn't care.
I built one of these for a BIR integration at work. The BIR data format is a special kind of historical artifact that I will not describe here for the sake of this post's rating. But behind the ACL, my domain model was clean. TaxDeclaration had real field names. GrossIncome was a Money value object. The translation layer was ugly and commented extensively. The domain was beautiful and made sense to a human being.
The Part Where I Admit The Mistakes I Made
Look, I spent a good two years thinking I was doing DDD because I had folders named Domain, Application, and Infrastructure.
I was not doing DDD. I was doing Folder-Driven Development, which is a cargo cult where you arrange your code in a way that looks like clean architecture from a distance but still has a UserService with 40 methods and an entity called UserInfo that is just a DTO with an ID property.
Real DDD is uncomfortable at first. The aggregates feel too big. The bounded contexts feel like duplication. Writing a domain event for something feels ceremonial and over-engineered for a startup feature.
But the payoff comes the first time a new developer looks at your Order aggregate and says, "oh, I get it — you can't ship an unconfirmed order, it's enforced right here in the model." No service to check. No database column to validate. The model tells the story.
That's the whole point. The code should tell the story of your domain the way Tolkien told the story of Middle-earth — with enough detail that it feels real, with names that mean something, with rules that make sense within the world, and with a clear separation between the Shire and Mordor so that hobbits don't accidentally wander into Mount Doom.
(Our UserManager class was Mount Doom.)
Practical Takeaways (For Those Who Skipped to the End)
If you skimmed past the Elvish and the Eagles, here's the TL;DR:
Bounded Contexts — split your system into areas where a model is internally consistent. Don't share models across contexts. Let them each have their own Customer.
Ubiquitous Language — use the words your business uses. In your code. In your PR descriptions. In your standup. Same words, everywhere, always.
Entities vs. Value Objects — if it has identity over time, it's an Entity. If it's defined purely by its values and is interchangeable, it's a Value Object. Give things IDs only if they need them.
Aggregates — group related entities under one root. All changes go through the root. The root enforces invariants. Load and save the whole aggregate together.
Domain Events — model things that happened as events. Let other parts of the system react to them. Azure Service Bus is your eagle.
Anti-Corruption Layer — when talking to external systems, translate at the boundary. Protect your domain model from other people's legacy decisions.
Written by someone who once had a service class with a method called ProcessUserStuffAndMaybeEmail(). We've all been there. The important thing is that we grow.
— Jubs, Loose Coupling





