DDD Aggregate Sizing — Why God Objects Kill Payment Systems
One oversized Order aggregate caused payment timeouts from lock escalation.
20+ years shipping large-scale distributed systems. Lessons pulled from things that broke in production.
- DDD structures software around business domains, not technical layers
- Bounded Contexts define where a domain model applies; each context owns its vocabulary
- Aggregates enforce transactional consistency; keep them under ~100 child entities
- Entities have identity; Value Objects are defined by attributes and must be immutable
- Performance: oversized aggregates cause lock contention and slow transactions
- Production insight: sharing a database table across contexts couples deployments and kills autonomy
Imagine a hospital. The billing department calls a patient a 'payer with an account balance.' The doctors call the same person a 'patient with a diagnosis and treatment plan.' Both groups are talking about the same human being, but they each have their own vocabulary, rules, and paperwork — and that's totally fine. Domain-Driven Design says: stop trying to force everyone to share one giant shared definition. Instead, let each department own their own model of the world, speak their own language, and only sync up at the boundaries where they truly need to. That's it. That's DDD.
Most software systems don't fail because of bad algorithms or slow databases. They fail because the code stops making sense — not to the compiler, but to the team building it. Business rules get buried in utility classes. A 'Customer' object ends up carrying seventy fields because six different teams piled their needs into it. Changing one thing breaks three others in ways no one predicted. This is the silent killer of large codebases, and it's exactly the problem Domain-Driven Design was built to solve.
DDD, coined by Eric Evans in his 2003 book 'Domain-Driven Design: Tackling Complexity in the Heart of Software,' is a philosophy and a set of patterns for structuring software around the actual business domain. It argues that the biggest source of complexity isn't technical — it's conceptual. When your code's vocabulary doesn't match the business's vocabulary, every conversation between a developer and a domain expert becomes a translation exercise. Bugs hide in those translations. DDD eliminates the translation layer by making the code speak the same language as the business.
By the end of this article you'll be able to identify Bounded Contexts in a real system, design Aggregates that enforce invariants without becoming god objects, use Value Objects to eliminate primitive obsession, and understand exactly where DDD adds value versus where it becomes overkill. You'll also see the production gotchas that only show up when you're six months into a real DDD implementation — the things Evans' book doesn't warn you about. Raw theory never saved a production outage — applied DDD patterns do.
And that's the whole point: if your code doesn't hurt when you read it after a month, you're not modelling hard enough. DDD hurts at first, then it clicks.
What DDD Aggregate Boundaries Actually Enforce
Domain-Driven Design (DDD) is a software modeling approach that aligns code structure with business domain concepts. The core mechanic is the aggregate: a cluster of domain objects treated as a single unit for data changes. Aggregates enforce consistency boundaries — all invariants within an aggregate must be satisfied before a write completes. This means you load and persist the entire aggregate atomically, not individual entities. In practice, an aggregate is defined by its root entity (the only object clients can reference) and a set of internal entities/value objects that are only reachable through that root. The key property is that external references to internal objects are forbidden — you must go through the root. This prevents inconsistent state and makes transactional boundaries explicit. Use aggregates when you need to guarantee business rules across multiple objects in a single operation. They matter most in systems with concurrent writes, where naive entity-per-table designs lead to race conditions and data corruption. A well-sized aggregate keeps the consistency boundary tight — typically 3-5 objects — and accepts eventual consistency for everything outside it.
DDD in Practice: A Real-World Example
Take an e-commerce system. The 'Product' concept means different things to different teams. Inventory cares about stock locations and reorder points. Marketing cares about descriptions, images, and tags. The checkout team cares about price and availability. These are three different models of the same real-world thing. DDD says: don't share one Product class across all three. Create three Bounded Contexts: Inventory Product, Marketing Product, and Checkout Product. Each has its own lifecycle, its own invariants, and its own persistence. Communication between contexts happens through events or data transfer objects (DTOs), never through a shared database table.
The hardest part? Getting leadership to accept that 'Product' will be stored in three different tables. Engineers often resist because it feels like duplication. It's not — it's decoupling. Duplication of data is cheaper than coupling of teams.
In practice, you'll find teams that run a shared Product table because it's 'faster.' It's not – it's cheaper in the short term, but it costs you team autonomy. Once two teams share a table, they share deployment windows, migration schedules, and outage scope. That's not a database decision; it's an organisational decision.
Here's a concrete production story: a company had a single 'Product' table with 120 columns. Marketing wanted to add an 'SEO description' field, but the DBA said no because it would slow queries for inventory. That's a governance problem, not a technical one. The fix was splitting the table — and the teams — into separate contexts. The inventory team's write throughput doubled after removing marketing columns from their table.
And the metric that convinced leadership: after splitting, inventory writes went from 500 ops/s to 1200 ops/s. Marketing got their SEO field in one sprint. Shared nothing wins.
- Inventory Product: stock locations, reorder points, bin numbers.
- Marketing Product: description, images, tags, SEO metadata.
- Checkout Product: price, availability, discount eligibility.
Bounded Contexts — The Boundary That Prevents Chaos
A Bounded Context is the explicit boundary where a particular domain model applies. Inside the boundary, the language and terms have a specific meaning. Outside, they may mean something else — and that's intentional.
For example, in an e-commerce system, the 'Product' concept differs between inventory management (tracking stock levels) and marketing (tracking tags and images). Inventory doesn't care about the product description; marketing doesn't care about warehouse bin locations. Forcing them to share one model creates a 'Customer' object with 70 fields.
Implementing a Bounded Context means defining a distinct module, service, or package boundary. Within that boundary, use the Ubiquitous Language. Communication between contexts happens through events or APIs, never through shared databases.
Common pitfall: implementing anti-corruption layers too early or too late. Start with a lightweight translation layer (maybe a map) and only jump to full ACL when you see cross-context coupling hurting velocity.
Identifying Bounded Contexts in an existing system is messy. Look for the boundary where a term changes meaning. For example, when the billing team says 'customer' and the support team says 'customer' – they mean different things. That's your context boundary. Also, if a change in one module causes a cascade of test failures in another module, that module is likely violating your context boundary.
One more clue: ask your domain experts to draw a diagram of their business flows. The gaps and overlaps in their drawing are your context boundaries. Don't draw it yourself — let them show you where the seams are.
A subtle sign: if you have a 'Customer' class in a package called 'shared', you almost certainly have a context boundary violation. Shared model classes are a red flag. Rename it to something context-specific, even if it's initially identical.
- A Bounded Context maps to the team that owns it.
- Two teams using the same word may refer to different concepts.
- Translation between contexts happens at the boundary via an anti-corruption layer.
- A context's persistence can be any technology — SQL, NoSQL, even a file — as long as it's private.
Aggregates — Consistency Boundaries That Enforce Invariants
An Aggregate is a cluster of domain objects that must be treated as a single unit for data changes. Each Aggregate has a root entity (the Aggregate Root) that controls access to the rest of the cluster. All invariants (business rules) must be satisfied when the aggregate is committed.
For example, an Order should not allow more items than in stock. This invariant is enforced inside the Order aggregate root. The outside world only touches the root — never its children directly. This guarantees transactional consistency without locking huge swaths of the database.
You reference an aggregate by its global identity, not by navigating through other objects. This keeps relationships clean and avoids cascade problems.
Now the part nobody talks about: aggregate boundaries are often wrong the first time. You'll discover this when a seemingly simple business rule change forces a database migration across multiple services. That's the signal that your aggregate boundary wasn't correct.
The rule of thumb: if you can't fit the aggregate's state on a single post-it note during a whiteboard session, it's too big. Aggregates should be small enough that a business expert can reason about their invariants in one breath. If they need to say 'and then also...' your aggregate is too large.
Also consider: aggregates are not just about transactional boundaries; they also define ownership. If two teams need to change the same aggregate, you have an organisational mismatch. That's a Conway's Law problem, and no amount of code will fix it.
Performance nuance: loading a large aggregate from the database means loading all its children. If you have Order with 1000 LineItems, you're loading 1000 rows into memory every time you touch the order. That's a waste if you only need to check the total. Consider splitting into smaller aggregates or using a separate read model for queries.
A practical heuristic: start with a small boundary and expand only when you get a concrete business rule that requires transactional consistency across those objects. Premature aggregation is as dangerous as premature optimization.
Entities vs Value Objects — Identity Matters
Entities have a unique identity that persists through time. For example, a 'User' entity is the same user even if they change their email or password. You compare entities by ID, not by field values.
Value Objects, on the other hand, are defined entirely by their attributes. A 'Money' object with amount 100 and currency 'USD' is equal to another Money with the same values. Two order lines with identical product and quantity can be swapped — they have no separate identity.
The rule: if you care about 'who' it is, it's an Entity. If you care about 'what' it is, it's a Value Object. Value Objects should be immutable and side-effect-free.
Production pitfall: performance. If you create a Value Object in a hot loop (e.g., Money inside a large stream operation), object allocation can become a GC problem. Consider using records or value types.
One hidden trap: Value Objects that reference other Value Objects. If an Address contains a City, and City is a Value Object, then Address becomes composed of values. That's fine – but don't give City an ID. The moment you add an ID, City becomes an Entity and the composition semantics change. Stick to 'is-a-value' all the way down.
Another common mistake: using primitive types (string, int) instead of Value Objects to represent domain concepts. This is called primitive obsession. A Price is not a double; it's a Money with currency. A PhoneNumber is not a string; it's a structured value with formatting rules. DDD says: wrap every primitive that has business meaning in a Value Object. The code will tell you what it means.
I once saw a codebase where 'Amount' was a BigDecimal everywhere. Every method that dealt with money had to check the currency manually. After introducing a Money Value Object, the nullability checks and currency mismatch bugs disappeared. That's the power of making the type system work for you.
Another heuristic: if you can replace the object with a literal in a unit test and the test still makes sense, it's likely a Value Object.
- Entities have a thread of identity — they change over time.
- Value Objects are snapshots — they are equal if all attributes match.
- Value Objects should be immutable to avoid side effects.
- Never give a Value Object an ID — it violates the definition and causes identity confusion.
Ubiquitous Language — The One Rule That Makes DDD Work
Ubiquitous Language is the practice of using the same vocabulary in code, conversations, documentation, and domain experts' speech. When a domain expert says 'order is confirmed', the code should have an Order class with a confirm() method — not a 'status update' to a database column named 'flag_34'.
This sounds trivial, but in practice it's the hardest rule to follow. Teams slip into technical jargon or business shortcuts because they're faster in the moment. The result: bugs, misunderstandings, and a growing gap between what the business wants and what the system does.
The commitment is: whenever you discover a term that isn't in the Ubiquitous Language, either introduce it with the team's agreement or rename it. This is a continuous discipline, not a one-time exercise.
Hard truth: most teams fail at Ubiquitous Language within the first six months. The solution isn't a glossary doc — it's pairing developers with domain experts during refinement sessions. If you're not sitting next to the business when you write the code, your language will drift.
The best indicator of healthy Ubiquitous Language: can a product manager walk through the codebase and nod along? If they can't, your language is broken. Schedule regular 'language reviews' where the team reads through the domain model with a domain expert and flags terms that don't match.
One more thing: Ubiquitous Language applies to everything — APIs, event names, database column names. If you have a column called 'cust_status_cd', that's technical debt. Rename it to 'customer_status'. Every new developer will thank you.
A practical exercise: take your most recent user story. Write the acceptance criteria using only domain terms. Then go to your code and see if those terms exist as types, methods, or properties. If not, you have a gap.
And the final test: if you can't onboard a business analyst in two days to write acceptance tests, your language is a barrier.
Domain Events — How Contexts Communicate Without Coupling
Domain Events are the mechanism Bounded Contexts use to communicate asynchronously. When something important happens in one context (e.g., OrderConfirmed), it publishes an event. Other contexts subscribe and react. This keeps each context independent while still synchronizing state.
A well-designed Domain Event is immutable, includes the aggregate ID and a timestamp, and carries only the data the subscribers need — nothing more. The publishing context does not know who subscribes.
Production hazard: events that grow too large. If you put the entire order object into an OrderConfirmed event, you've coupled the subscriber to the publisher's internal structure. Keep events lean — use IDs and let subscribers fetch the rest via API if needed.
Versioning Domain Events is the part everyone forgets. Once you publish an event, you can't change its schema without breaking subscribers. Use Avro or Protobuf with schema registry, or at minimum, include a version field in the event and never remove fields – only add optional ones.
Another common mistake: publishing events before the transaction commits. If the transaction later rolls back, you've sent an event that never happened. Always publish events after the transaction is committed — use an outbox pattern if necessary.
Real production story: a team was using Domain Events to sync order data between contexts. They published the event before the transaction committed. A network timeout caused the transaction to roll back, but the event had already been consumed by the shipping context. The shipping context kicked off a fulfillment process for an order that didn't exist in the system. Recovering from that was a nightmare. Outbox pattern would have prevented it.
A subtle pitfall: using the same event bus for domain events and integration events. They have different guarantees — domain events are within a context, integration events cross contexts. Mixing them couples your infrastructure.
DDD and Microservices: Mapping Contexts to Services
One of the most common questions when adopting DDD is: how do Bounded Contexts map to microservices? The answer: they often align, but they're not the same thing. A Bounded Context is a conceptual boundary — it defines where a particular model applies. A microservice is a deployment unit — it defines what runs independently.
In many teams, each Bounded Context becomes one or more microservices. But you can also have multiple contexts inside a single service, especially in a modular monolith. The key is that contexts remain isolated in code and data even if they deploy together.
The danger is over-splitting: creating a microservice for every aggregate without considering operational cost. You end up with dozens of services, distributed transactions, and complex orchestration. Start with coarse context boundaries and split only when team autonomy or scaling demands it.
Common pattern: use a 'shared kernel' between contexts that are closely related, but this often turns into a shared mess. Prefer anti-corruption layers and published language via events.
When you do split into microservices, remember that network boundaries are real. You lose the ability to enforce invariants transactionally. You'll need sagas or process managers to maintain consistency. Don't take that lightly.
A pragmatic rule: if you have fewer than 10 developers, don't split into microservices based on DDD boundaries alone. Start as a modular monolith with clear package boundaries. Split when the team grows or when your CI pipeline becomes a bottleneck.
A good litmus test: if deploying a change to one context requires QA sign-off from another team, you've got a coupling problem that splitting won't fix on its own.
Anti-Corruption Layer — Protecting Your Domain from External Models
An Anti-Corruption Layer (ACL) is a protective boundary that translates between two Bounded Contexts. It prevents one context's model from leaking into and corrupting another's. This is especially important when integrating with legacy systems or third-party APIs where you can't control the model.
The ACL typically consists of a set of translators, adapters, and facade services. It maps external concepts to your internal Ubiquitous Language. For example, a legacy CRM's 'Account' object might map to your 'Customer' and 'Contract' aggregates.
Don't overbuild your ACL. Start with simple mapping functions. If you need to handle complex transformations, consider using a separate anti-corruption service. The key is that changes to the external system's model only affect the ACL, not your core domain.
Production gotcha: people often forget to version the ACL's translations. When the external model changes, the ACL must be updated. Without versioning, you'll get silent data corruption. Test ACL mappings with contract tests.
Another important point: the ACL belongs to the consuming context, not the provider. The team that consumes the external data owns the translation. That way they control when and how to update it.
Real-world example: a fintech company integrated with a legacy core banking system. The legacy system had a concept of 'account status' with values 'active', 'dormant', 'closed'. Our domain model had 'CustomerStatus' with 'Active', 'Inactive', 'Suspended'. The ACL translated one to the other. When the legacy system added 'suspended' as a status, the ACL broke silently until a customer complained they couldn't trade. The fix was adding a contract test on the ACL that warned when new statuses appeared.
A recurring pattern: teams put the ACL in the provider's codebase. Wrong. The consumer must own it, because the consumer decides what the domain model looks like.
Context Mapping — Visualizing Relationships Between Bounded Contexts
Context Mapping is the practice of documenting the relationships between Bounded Contexts. It's an essential tool for understanding integration points, shared kernels, and translation requirements.
- Partnership: two contexts cooperate on a shared goal
- Shared Kernel: a small shared subset of the model (risky)
- Customer-Supplier: one context provides data to another
- Conformist: one context adopts the other's model without translation
- Anti-Corruption Layer: protects the downstream context
- Open Host Service: one context exposes a stable API for others
- Published Language: both sides agree on a common interchange format
Draw a context map on a whiteboard during architecture reviews. It'll surface hidden dependencies. The map should be living documentation — update it whenever you change integration patterns.
Production insight: most teams skip context mapping until they have a broken integration. By then it's too late. Start the map early. Also, avoid shared kernels unless you have a dedicated team to manage them — they rot fast.
One more tip: color-code your context map by team ownership. It makes it immediately obvious where one team's changes can break another. That visual feedback is worth more than a hundred wiki pages.
A practical approach: use a tool like Miro or Structurizr to create a living context map. Link it to your code repositories. When a developer creates a new integration, they should update the map. Make it part of the definition of done.
A real example: a bank had 15 Bounded Contexts but no map. During an upgrade, the Payments team changed their API contract and seven downstream contexts broke silently. After creating a map, they discovered they had five undocumented conformist relationships. They invested in ACLs for each, and subsequent upgrades went smoothly.
Why Your Domain Model Needs Factories (And When They Save Your Ass)
Creating complex aggregates or value objects inline is a recipe for invariant leaks. You scatter construction logic across services, and suddenly nobody knows which fields are mandatory or what state transitions are legal. That's where domain factories come in.
A factory is a named, self-documenting way to assemble an aggregate that guarantees its invariants at birth. Unlike a constructor, a factory can accept primitive data, validate it against your ubiquitous language, and return an entity that's ready to work.
In production, I've seen teams skip factories because they felt like 'extra work.' Then a new hire passes invalid parameters directly to an aggregate constructor, and the system silently accepts a broken state. The factory would have stopped that cold. If building a valid object requires decisions — multiple steps, external references, or business rules — you don't put that in the constructor. You put it in a factory. Period.
Repositories Are Not DAOs — Stop Treating Them Like One
Developers love to confuse repositories with data access objects. A DAO is a mechanical mapper: it pushes rows into objects. A repository is a collection metaphor for your domain. It returns aggregate roots, not DTOs. You don't query repositories by SQL fragments. You query with domain-specced specifications.
The why? Repositories are the only part of your code that touches persistence. If your domain logic ever calls session.execute(), you've leaked infrastructure into the core. That breaks DDD's separation of concerns. When you later swap a SQL backend for EventStore or CosmosDB, you'll be rewriting half your domain.
A real repository returns a fully loaded aggregate or a list of them. No lazy loading. No partial hydration. If the caller needs a value object from inside the aggregate, they get the whole aggregate. That's the tradeoff: simplicity in the domain model for a slightly more expensive query. Keep the repository interface in the domain layer; put the implementation in infrastructure. Your future self will thank you.
Domain Services vs Application Services — The Boundary That Stops Cargo Culting
Most codebases dump everything into 'services' and call it a day. That's how you get god objects. In DDD, you need two kinds of services, and they serve completely different masters.
An application service is thin. It coordinates: get aggregate from repo, call a method, save it back. It doesn't contain business logic. A domain service contains business logic that doesn't naturally fit inside a single entity or value object. Example: checking if a transfer violates a daily limit across multiple accounts. That's not the Account entity's job — it needs to inspect other aggregates.
Domain services operate on domain objects and return domain objects. They're stateless. They're named after business activities. Application services are infrastructure-aware (they know about the repo, the unit of work, the email sender). Domain services know nothing about infrastructure. If you find your service importing SMTP libraries, it's an application service masquerading as domain logic. Move it.
Strategic Design — The Boundaries That Actually Control Complexity
Most devs jump into DDD by modeling aggregates and entities. That's tactical. It's also how you build a beautiful model that solves the wrong problem. Strategic design is the part nobody talks about because it's harder — it requires understanding the business itself.
Strategic design is why we have Bounded Contexts, Ubiquitous Language, and Context Maps. These aren't academic patterns. They are hard boundaries that stop one team's 'Order' from conflicting with another team's 'Order'. Without strategic design, your microservices become a distributed monolith where every service shares a customer model. That's not DDD. That's just a slow, painful way to deploy.
Map your contexts first. Find the core domain — the part your business actually competes on. Everything else is supporting or generic. That decision alone saves you years of refactoring.
Domain Knowledge Is Not Optional — It's The Whole Point
You can't model what you don't understand. I've watched teams cargo-cult DDD by drawing aggregates and events without ever talking to the business stakeholders. The result? A technically perfect model that does the wrong thing. That's worse than no model — it's technical debt with a fancy name.
Ubiquitous Language isn't just a jargon list. It's the output of hours spent with domain experts, arguing about what 'Order Shipped' actually means. Does it mean the carrier picked it up? Or the label printed? That ambiguity destroys consistency. Your code can't make that decision for you.
DDD forces you to learn the business. If you're not uncomfortable asking basic questions, you're doing it wrong. The model is a lie until the domain expert agrees it's correct. That's the bar. Nothing less.
Benefits of Domain-Driven Design (DDD)
DDD solves the mismatch between complex business logic and technical implementation. The ultimate benefit is longevity—systems built with DDD resist erosion because bounded contexts isolate change, aggregates enforce invariants, and ubiquitous language prevents translation errors between domain experts and developers. Teams ship faster over time because the model reflects reality, not database schemas. Maintenance costs drop: when a business rule changes, you change exactly one aggregate, not a dozen scattered queries. Communication improves: everyone speaks the same language, so meetings about "customer eligibility" mean the same thing to product, QA, and backend. Strategic design prevents the big-ball-of-mud pattern by forcing explicit context boundaries. Tactical patterns (Entities, Value Objects, Domain Events) reduce bugs by making impossible states unrepresentable. The real win: your software becomes a competitive advantage, not a bottleneck.
Challenges of Domain-Driven Design (DDD)
DDD is not free—it demands continuous investment. The first challenge is knowledge acquisition: you need genuine domain experts who can describe rules in precise language, not vague PowerPoints. Without them, your ubiquitous language drifts into jargon soup. Second, aggregate design is hard—developers routinely make aggregates too large (performance kills) or too small (inconsistent state). Third, learning curve: tactical patterns (Entities, Value Objects, Domain Events) require disciplined code structure that junior teams resist. Fourth, organizational friction: bounded contexts often map to team boundaries, but Conway's Law fights you when microservices don't match business subdomains. Fifth, performance overhead: separating aggregates and using eventual consistency via domain events adds latency and complexity. Sixth, you cannot retrofit DDD onto an existing transactional monolith—it requires a rewrite or strangler fig pattern. Many teams fail because they try DDD on CRUD systems where the cost exceeds the benefit.
Use-Cases of Domain-Driven Design (DDD)
DDD excels in complex domains where business rules evolve faster than your ORM schema. A prime use-case is financial trading platforms: order matching, risk limits, and settlement logic each form distinct bounded contexts. DDD prevents 'accounting logic' from leaking into 'trading logic.' Another strong case is healthcare scheduling — patient eligibility, provider availability, and billing are separate subdomains with different invariants. DDD also shines in insurance underwriting: policy rules differ by state and product type, and a unified model would collapse under conditional complexity. For e-commerce, DDD helps isolate inventory management from checkout and fraud detection, each with its own language and consistency boundaries. Avoid DDD for simple CRUD apps (like a blog engine) — you'll over-engineer. The sweet spot is when your team spends more time arguing about business rules than writing code; DDD forces those conversations into explicit models.
Conclusion: DDD Is a Language, Not a Framework
Domain-Driven Design is not about code patterns or folder structures. It is a commitment to shared understanding between developers and domain experts. When you adopt DDD, your biggest win is not 'clean architecture' — it's the reduction of misinterpretation errors that plague long-lived systems. Tactical patterns like Entities, Value Objects, and Repositories serve only to encode that shared language. Strategic patterns like Bounded Contexts and Context Maps prevent your software from turning into a Big Ball of Mud. Start small: pick one messy bounded context, model it in plain Python with a domain expert in the room, and evolve the design as their understanding deepens. DDD asks you to accept that complexity cannot be abstracted away — it must be understood and explicitly modeled. The payoff is software that stays malleable as the business changes, not software that fights every new requirement.
DDD in Production — The System Stops Fighting Your Business Logic
Most production systems degrade into a pile of workarounds because the code models reality poorly. When you build around the domain, every deployment aligns with how your business actually works. The benefit shows up as reduced incident rates — new features stop breaking unrelated modules because boundaries match domain concepts, not database tables. You also get faster debugging: when a payment fails, the code in the Payment aggregate contains all invariants, not scattered across controllers and SQL scripts. Domain events replace callback hell with explicit workflows you can trace. Teams stop stepping on each other because bounded contexts give clear ownership. Integration tests drop in number and rise in value — you test the domain logic, not the framework wiring. The biggest win is psychological: developers stop guessing what code is supposed to do. They read the domain model and know. Production becomes boring. That's the sign of a system built around the domain.
When DDD Pays Off and When It's Just Expensive Ceremony
DDD shines when your business logic has complex, evolving rules — think insurance underwriting, scheduling algorithms, or financial compliance. In those domains, the investment in aggregates, value objects, and domain events pays back tenfold because every rule is explicit and testable. It also crushes communication overhead: a term like 'PolicyTerm' means the same thing to product owners and developers. But DDD is overhead when your domain is a thin CRUD wrapper — basic blog CMS, simple form submissions, or any system where the database schema is the spec. If your primary operation is 'save whatever the user typed', those aggregates and repositories are cargo culting. Another anti-pattern is forcing DDD on top of a legacy ORM with lazy loading — you'll fight transaction boundaries all day. DDD also fails when leadership demands it but refuses to involve domain experts. Without their time, you're just building fancy objects around guesses. Use DDD where rules change often, not where data just passes through.
The God Aggregate That Took Down Payment Processing
- Aggregate boundaries must be sized by transactional consistency, not by data hierarchy.
- If an aggregate holds more than ~100 entities on average, redesign it.
- Test aggregate load under realistic data volumes before going to production.
- When you split, invest in a dedicated read model for queries that need the full picture.
- Always monitor transaction duration per aggregate — if it grows over time, your boundary is leaking.
git log --follow Order.java | headgrep -r 'Order\.' src/ | cut -d: -f1 | sort -uKey takeaways
Common mistakes to avoid
5 patternsSharing a single database table across multiple Bounded Contexts
Making Aggregates too large (including hundreds of child entities)
Skipping the Ubiquitous Language step and jumping straight to aggregate design
Forgetting to version Domain Events and not using idempotency keys
Using the same class model (Entity) across contexts instead of separate Value Objects or DTOs
Interview Questions on This Topic
What is a Bounded Context and why is it important in DDD?
Frequently Asked Questions
20+ years shipping large-scale distributed systems. Lessons pulled from things that broke in production.
That's Architecture. Mark it forged?
23 min read · try the examples if you haven't