Back to Blog
Technology
January 22, 2026
12 min read
2,283 words

Why We Stopped Domain-Driven Design. Bounded Contexts Became Silos.

We adopted DDD religiously. 14 bounded contexts. Each with its own models, databases, teams. Simple features required 5 team negotiations. We merged back to a modular monolith.

Why We Stopped Domain-Driven Design. Bounded Contexts Became Silos.

Domain-Driven Design promised cleaner boundaries, better modeling, and teams that could move independently. We read the blue book. We attended the workshops. We hired DDD consultants who charged $2,000/day. We went all-in on bounded contexts, aggregates, and ubiquitous language.

Two years later, we had organizational scar tissue instead of software. The architecture that was supposed to enable team autonomy had created a coordination nightmare. Simple features required multi-team negotiations. What should have been a one-week project became a three-month odyssey across context boundaries.

This is the story of how we adopted Domain-Driven Design, why it failed us, and what we do instead. Spoiler: the problem wasn't DDD itself—it was applying enterprise-scale patterns to a startup that didn't need them.

The Promise That Seduced Us

We were a 40-person startup building an e-commerce platform. Our monolith was getting messy. Teams were stepping on each other's code. Deployments were scary. Someone suggested DDD as the solution, and the pitch was compelling.

Bounded contexts would give each team ownership of their domain. No more merge conflicts with other teams. No more coordinating deployments. Each context could evolve independently, with clear interfaces between them. The domain model would become a shared language between engineers and business stakeholders.

We read "Domain-Driven Design" by Eric Evans. We read "Implementing Domain-Driven Design" by Vaughn Vernon. We brought in consultants who helped us identify our bounded contexts through event storming sessions. We felt sophisticated. We were doing what the big companies did.

The consultants left. The implementation began. The problems started.

The 14-Context Landscape

Our e-commerce platform became a distributed system of 14 bounded contexts:

  • Identity Context: Authentication, authorization, sessions
  • Customer Context: Customer profiles, preferences, addresses
  • Product Context: Product catalog, descriptions, images
  • Inventory Context: Stock levels, warehouse locations
  • Pricing Context: Prices, discounts, promotions
  • Cart Context: Shopping cart, saved items
  • Order Context: Order creation, order history
  • Payment Context: Payment processing, refunds
  • Fulfillment Context: Shipping, tracking, delivery
  • Review Context: Product reviews, ratings
  • Recommendation Context: Product recommendations, personalization
  • Notification Context: Emails, SMS, push notifications
  • Analytics Context: Event tracking, reporting
  • Support Context: Customer service, tickets

Each context had its own PostgreSQL database. Each had its own models that represented domain concepts in that context's language. Each had its own team (or partial team—we didn't have 14 teams, so some engineers owned multiple contexts). Each deployed independently through its own CI/CD pipeline.

On paper, it was beautiful. Clean separation of concerns. Clear ownership. Textbook DDD implementation. The architecture diagrams looked like something you'd see at a conference talk.

In practice, it was a coordination hellscape.

The Feature That Broke Us

Six months after the migration, product management requested a seemingly simple feature: "Show estimated delivery date on the product page."

Customers wanted to know when they'd receive their order before adding to cart. Standard e-commerce functionality. Amazon does it. Every major retailer does it. Should be straightforward.

Here's what the feature required:

Product Context: Needed to expose which warehouse(s) had the product in stock. But Product Context didn't know about inventory—that was Inventory Context's domain. So Product Context needed to call Inventory Context to get stock locations.

Customer Context: Needed to provide the customer's shipping address. But the customer might not be logged in. And even if logged in, they might want delivery to a different address. So we needed to handle multiple scenarios.

Fulfillment Context: Needed to calculate shipping time based on origin warehouse and destination address. But Fulfillment Context didn't have carrier rate APIs—that was considered "infrastructure" and lived in a shared library. Except the shared library needed updating to support this use case.

Pricing Context: Needed to factor in whether expedited shipping was available and how it would affect delivery. But Pricing Context and Fulfillment Context had different models of what "shipping options" meant.

Cart Context: Needed to integrate the estimate into the cart flow and update it as the cart changed. But Cart Context's model of "cart items" didn't include delivery estimates.

Five contexts. Five teams (or five team-slices). Five separate planning sessions. Five design documents. Five code reviews. Five deployments.

The engineering work was perhaps 3 days of actual coding. The coordination overhead was 5.5 weeks. Calendar time from request to production: 6 weeks.

For a feature that should have been a week of work.

The Anti-Patterns That Emerged

The delivery date feature wasn't an anomaly. It was representative. Several anti-patterns emerged from our DDD implementation that made every cross-cutting feature painful.

Context Translation Layers

Every context had its own ubiquitous language. That's DDD best practice—each context should use language that makes sense in that context. But it meant endless translation.

"User" in Identity Context was "Customer" in Customer Context. "SKU" in Product Context was "Item" in Cart Context. "Shipment" in Fulfillment Context was "Delivery" in Order Context. Every integration required mapping between different representations of the same real-world entity.

We built anti-corruption layers, as DDD prescribes. These were translation services that converted between context languages. They added latency. They added bugs. They added cognitive overhead. When the upstream context changed, every downstream anti-corruption layer needed updating.

Eventually, we had translation layers translating from other translation layers. The indirection was absurd.

Distributed Transaction Hell

Real business operations crossed context boundaries. A customer placing an order touched Order Context, Payment Context, Inventory Context, Fulfillment Context, and Notification Context. That's five databases that needed to remain consistent.

We implemented the Saga pattern. Each cross-context operation became a choreographed sequence of events with compensating transactions for rollback. Order created → Payment attempted → If payment fails, compensate by canceling order → If payment succeeds, reserve inventory → If inventory unavailable, compensate by refunding payment and canceling order → And so on.

The complexity was immense. Debugging a failed order meant reconstructing a timeline across five services, correlating events by order ID, understanding which step failed and whether compensating transactions had run correctly. Simple questions like "why didn't this customer get their confirmation email?" became archaeological expeditions.

Eventual consistency is fine in theory. In practice, it meant angry customers asking "why does my order show as processing in one place and confirmed in another?" Explaining eventual consistency to a VP of Customer Success is not fun.

Ownership Disputes

Bounded contexts create boundaries. Boundaries create disputes. Every new feature sparked debates about which context owned it.

Customer wishlists: Is that Cart Context (it's a collection of products) or Customer Context (it's a customer preference)? Neither team wanted the complexity. Both claimed it was the other's responsibility.

Gift cards: Payment Context (it's a payment method) or Promotions Context (it's a stored value discount)? The answer affected three other contexts that needed to integrate with it.

Product bundles: Product Context (it's a product) or Pricing Context (it affects pricing) or Cart Context (it affects cart behavior)? We had a two-week debate about this one.

Context ownership became defensive. Teams protected their boundaries because boundary violations meant scope creep and surprise work. "That's not our context's responsibility" became a common refrain. The model became more important than shipping product.

Duplicate Data Everywhere

Each context needed information from other contexts. Customer Context had customer preferences. Order Context needed customer preferences to apply them to orders. Instead of coupling to Customer Context, Order Context maintained its own copy of relevant preferences.

This is event-driven architecture as DDD recommends. Contexts publish events. Other contexts subscribe and maintain local copies of needed data. Eventually consistent, loosely coupled.

Also: duplicate data everywhere. Customer preferences existed in Customer Context, Order Context, Recommendation Context, and Notification Context. When preferences changed, events propagated... usually. Event ordering issues meant contexts sometimes had stale data. Debugging "why did this customer get an email they'd opted out of?" meant checking four different databases.

We spent more time keeping data synchronized than we'd ever spent on the coupling we were trying to avoid.

The Retreat to Sanity

Eighteen months in, we made the call: we were reversing course. Not back to the original messy monolith—we'd learned things—but to something simpler.

We called it a "modular monolith." The key differences:

Single codebase: All code in one repository. No service boundaries. No network calls between what used to be contexts.

Single database: One PostgreSQL database with clear schema namespacing for different modules. Foreign keys. Transactions that actually work.

Clear module boundaries in code: Modules with defined interfaces. Other modules use the interface, not internal implementation. But the interface is a function call, not an HTTP API.

Shared models where sensible: "Customer" is "Customer" everywhere. "Order" is "Order." No translation layers. No anti-corruption layers. One source of truth.

Team ownership of modules: Teams still own code areas. But ownership is advisory, not enforced by service boundaries. If Feature X needs changes in two modules, one team can make both changes.

The migration took three months. We rewrote almost nothing—we mostly deleted infrastructure code. Deleted the message queues. Deleted the event handlers. Deleted the anti-corruption layers. Deleted the saga orchestrators. Replaced them with function calls and database transactions.

The estimated delivery date feature? After the migration, a single engineer implemented it in one week. One PR. One deployment. One database query to get inventory, another to calculate shipping. Done.

What We Learned

Boundaries have costs, not just benefits. Every boundary adds coordination overhead. DDD literature emphasizes the benefits of clean boundaries but doesn't price the cost. For a startup iterating rapidly, the cost often exceeds the benefit.

Context boundaries should be discovered, not imposed. We drew boundaries before understanding our domain deeply. The consultants helped us hypothesize contexts, but hypotheses need validation. Real boundaries emerged differently than we predicted. Some contexts that seemed distinct were actually tightly coupled. Others that seemed unified had natural seams we didn't see initially.

Team structure matters more than software structure. Conway's Law runs both directions. Our context boundaries created team silos. The silos reinforced the boundaries. Changing the software meant changing the organization, which is much harder. If we'd kept teams fluid and let boundaries emerge from actual pain points, we'd have found better boundaries.

Start monolith, extract when needed. The wisdom of starting with a monolith and extracting services when you hit actual scaling problems is well-established. We ignored it because DISTRIBUTED SYSTEMS and MICROSERVICES and DDD sounded sophisticated. We paid the price.

Network boundaries are expensive. Every HTTP call between services adds latency, failure modes, and operational complexity. We had feature requests that required 12 sequential service calls to complete. The accumulated latency was noticeable. The accumulated failure probability was a support nightmare.

When DDD and Bounded Contexts Actually Work

DDD isn't wrong. It's a tool. We used the tool for the wrong job. Here's when it works:

Genuinely distinct sub-domains: If your business has areas that truly operate independently—different users, different data, different evolutionary pressures—separate contexts make sense. A company that's both a bank and an insurance provider has genuinely distinct domains.

Large organizations with team scaling problems: When you have 500 engineers and the coordination cost is already high, codifying boundaries can reduce chaos. The overhead of distributed systems is worth it when the alternative is constant merge conflicts and deployment coordination across huge teams.

Mature products with well-understood domains: If you've been running your product for 10 years and know exactly what the boundaries are, extracting them into contexts can work. We tried to draw boundaries after 18 months. We didn't know enough yet.

When the coordination cost is less than the coupling cost: Sometimes coupling is genuinely painful. Two teams constantly blocked by each other, unable to deploy independently, fighting over shared code. In that case, the overhead of context separation might be worth it. Measure the actual pain first.

For a 40-person startup building an e-commerce platform that's still figuring out product-market fit? Bounded contexts were premature optimization at an organizational level.

What We Do Now

Our modular monolith has served us well for three years now. We've grown to 120 engineers. The monolith has grown too—but thoughtfully.

We apply DDD concepts at the model level, not the service level. Aggregates help us think about consistency boundaries within the codebase. Entities and value objects shape our data models. Ubiquitous language matters in how we name things—but it's one language, shared across the codebase.

We've extracted exactly two services: a payment processing service (for PCI compliance reasons) and a search service (for scaling reasons). Both extractions were driven by real, measurable problems—not by architectural aesthetics.

Before extracting anything else, we require proof: proof that the coupling is causing measurable pain, proof that the separation won't create worse coordination problems, proof that we've tried solving the problem within the monolith first.

The bar is high. It should be. Distributed systems are expensive.

The Real Lesson

DDD is a tool for managing complexity at scale. The key phrase is "at scale." If you're not at scale—if you don't have hundreds of engineers and millions of lines of code and genuine sub-domain diversity—the tool adds more complexity than it removes.

We were seduced by sophisticated-sounding architecture. We wanted to be the engineers who could say "we use bounded contexts" at conferences. We optimized for architectural elegance instead of shipping speed and organizational simplicity.

The best architecture is the simplest one that solves your actual problems. For us, that was a well-organized monolith. Maybe someday we'll need the complexity of distributed contexts. If that day comes, we'll know—because we'll have exhausted the simpler alternatives and measured the actual pain.

Until then, we'll keep our code in one repo, our data in one database, and our teams talking to each other instead of negotiating service contracts.

Domain-Driven Design is a solution to problems of scale. Make sure you have the problems before you adopt the solution. Premature architectural sophistication is just another form of premature optimization.

Tags:TechnologyTutorialGuide
X

Written by XQA Team

Our team of experts delivers insights on technology, business, and design. We are dedicated to helping you build better products and scale your business.