Domain-Driven Design
Architecture Diagram
Why Service Boundaries Keep Going Wrong
Most teams split their systems by technology layer. You get a frontend team, a backend team, a database team, maybe a platform team. It feels natural because that is how the org chart looks. The problem is that every single feature now requires coordination across all of those teams. Want to add a discount code to checkout? Frontend needs a UI change, backend needs a new API endpoint, database team needs a schema migration, and now you are scheduling a three-team planning session for what should be a two-day task.
This is Conway's Law working against you. Your architecture mirrors your communication structure, and if your communication structure is organized by tech layer, your services will be too. You end up with a "backend service" that handles orders, inventory, payments, and shipping all in one blob. It deploys as a unit. It fails as a unit. And every team has to coordinate deploys because they all touch the same codebase.
DDD flips this. Instead of splitting by technology, you split by business capability. The team that owns ordering owns the API, the database, the business logic, and the deployment pipeline for orders. They can ship independently. They can make schema changes without filing a ticket with another team. The boundaries follow the business, not the tech stack.
Bounded Contexts
A bounded context is a boundary within which a particular domain model applies. That sounds abstract, so here is the concrete version: in an e-commerce system, the word "order" means completely different things depending on who you ask. To the ordering team, an order is a cart that got confirmed, with line items, quantities, and a total. To the shipping team, an order is a package with dimensions, weight, a destination address, and a tracking number. To the billing team, an order is a charge amount, a payment method, and a refund policy.
If you try to create one Order model that satisfies all three teams, you end up with a 47-field table that nobody fully understands. Changes to the shipping fields break the billing code because they share the same model. That is the canonical "ball of mud" pattern.
The way to find bounded contexts is to listen for where language changes. When the shipping team says "order" and means something different than the billing team, you have found a context boundary. Each context gets its own model, its own database, its own service. The Order in the ordering context is a different class, with different fields, than the Order in the shipping context. Yes, there is some duplication. That duplication is the price of independence, and it is worth paying.
Getting context boundaries wrong is expensive. If two contexts share a database, you have coupled their deployment schedules, their schema evolution, and their failure modes. If a context is too large, you have a mini-monolith that will eventually need to be split again. If a context is too small, you have nano-services that generate more network overhead than business value. Aim for something a single team (5-8 engineers) can own end to end.
Ubiquitous Language
Here is a bug that shows up at company after company. A customer creates an "account." The auth team stores it as a User. The billing team stores it as a Customer. The support team stores it as a Contact. Somewhere in the integration layer, someone wrote a mapping between User.id, Customer.userId, and Contact.externalId. One day, a migration script updates Customer records but not Contact records. Support agents now see stale data for two weeks before anyone notices.
The root cause was not a bad migration script. It was that three teams used three different words for the same concept and nobody maintained the mapping rigorously. Ubiquitous language solves this within a bounded context: everyone on the team uses the same words in conversation, in Jira tickets, in code, and in database columns. If the business calls it a "subscription," the code calls it a Subscription, not a RecurringPaymentAgreement.
Note the "within a bounded context" part. You do not need one universal glossary for the whole company. That is a fool's errand. Each context has its own language. The word "product" in the catalog context means something with a title, images, and a description. In the inventory context, a product is an SKU with a quantity and a warehouse location. Both are correct within their own context. The translation happens at the boundaries, through explicit anti-corruption layers or published events, not through a shared model.
Aggregates and Consistency Boundaries
An aggregate is a cluster of objects that you treat as a single unit for data changes. The aggregate root is the entry point. You never reach inside an aggregate to modify a child entity directly; you go through the root.
The most common mistake is making aggregates too big. One real-world example: an Order aggregate that included the Customer, all their previous Orders, the Product catalog entries for each line item, and the Inventory records. Loading a single order required joining six tables. Saving an order locked rows across those same six tables. Under any real load, you get lock contention, timeouts, and eventually a 2 AM pager alert.
Keep aggregates small. An Order aggregate contains the order itself and its line items. That is it. It holds a customerId (just the ID, not the full Customer object) and productIds (just the IDs). If you need customer details to display on the order page, you make a separate read query. The aggregate boundary defines what must be transactionally consistent. The order total and its line items must add up within a single transaction. But whether the customer's address is up to date? That can be eventually consistent. The inventory count matching the reserved quantity? Also eventually consistent, handled through domain events.
A good rule of thumb: if you cannot explain why two entities must be in the same transaction, they should not be in the same aggregate. Push everything you can toward eventual consistency. Your database will thank you, and your system will scale better.
When DDD Is Overkill
DDD has a real cost. You need to run discovery workshops. You need to maintain separate models across contexts. You need anti-corruption layers at boundaries. You need event-based communication between contexts. For complex domains with dozens of teams, this investment pays off many times over. For simpler situations, it is overhead you do not need.
If your app is straightforward CRUD, DDD is overhead. When business logic amounts to "validate these fields, save to database, show a confirmation screen," a well-structured MVC app is fine. Small teams feel the cost too. Under 20 engineers, there are probably not enough people to staff separate teams per bounded context, and the coordination overhead of maintaining context boundaries exceeds the benefit. Early-stage startups should also hold off, especially when the domain is still changing weekly. You will invest weeks modeling bounded contexts only to pivot and throw the model away. Build the monolith, learn the domain, then apply DDD when the domain stabilizes and the team grows.
The worst outcome is a team that adopts DDD vocabulary without the substance. They call everything an "aggregate" and a "bounded context" but still share one database, deploy everything together, and have one giant team working on all of it. That is just a monolith with fancier naming.
Making It Practical
Start with event storming. Get the engineers, product managers, and actual domain experts (the people who run the business process day to day) in a room with a wall of sticky notes. Map out the domain events: "Order Placed," "Payment Received," "Item Shipped." Then identify the commands that trigger those events and the actors who issue those commands. Within a couple of hours, natural clusters emerge. Those clusters are your candidate bounded contexts.
Once you have candidates, draw a context map. Which contexts talk to each other? What is the nature of that relationship? Is it a partnership (both teams evolve together) or a customer-supplier (one team provides, the other consumes)? Do you need a conformist relationship (just use their model as-is) or an anti-corruption layer (translate their model into yours)? These relationships determine how you will integrate at the code level.
Then pick one context and build it properly. One. Not five. Get the aggregate boundaries right, establish the ubiquitous language, set up the event contracts with neighboring contexts. Run it in production for a few months. Learn what you got wrong, because you will get something wrong. Adjust. Then take those lessons to the next context. Trying to model your entire organization into bounded contexts in one big design phase is a recipe for analysis paralysis. The model will be wrong in ways you cannot predict from a whiteboard. Ship something, learn, iterate.
Key Points
- •Bounded contexts are the single most valuable concept in DDD. Get these right and most of your service boundary problems disappear. Get them wrong and you end up with a distributed monolith that is worse than what you started with.
- •Ubiquitous language is not a documentation exercise. It is the shared vocabulary your team uses in code, conversations, tickets, and design docs. When the code uses different words than the business, bugs hide in the translation layer.
- •Keep aggregates small. An Order aggregate that pulls in Customer, Product, Inventory, and Shipping is not an aggregate, it is your entire database with extra steps.
- •DDD is a tool for complex domains, not a universal architecture style. If your app is basically CRUD with some validation rules, you are adding ceremony for no benefit.
- •Event storming with actual domain experts in the room will teach you more about your system in two hours than six months of reading source code.
Common Mistakes
- ✗Creating bounded contexts that map to technical layers (API context, database context, messaging context) instead of business capabilities
- ✗Building one canonical data model shared across all services, which forces every team to agree on field names and data shapes for entities they use differently
- ✗Making aggregates too large by stuffing related entities together, which causes contention, slow writes, and transaction failures under load
- ✗Skipping the domain modeling phase and jumping straight to microservices, then discovering six months later that the service boundaries are in the wrong places