From DDD to Event Sourcing: The Chain of Problems

architecturedddevent-sourcingcqrskafkamicroservicesjava

None of this was invented for fashion

Every piece of modern architecture —DDD, message queues, microservices, Kafka, CQRS, Event Sourcing— was the answer to a problem the previous piece created. Once you understand that chain of problems, you stop memorizing acronyms and start knowing when to use each one and, above all, when not to.

In 16 years of building systems in Java I've watched teams adopt Kafka because "it's the modern thing" and teams suffer through Event Sourcing on a CRUD app that never needed it. The tool wasn't the problem. Not knowing what pain it came to relieve was.

This article tells that story in eight steps. Every step has the same shape: the pain that opens it, the idea that resolves it, the tool that implements it, and the exact point where it breaks. That breaking point is always the start of the next step.

So nothing stays abstract, I'll use a single example from beginning to end: an Order in an online store. It has a customer, lines with product and quantity, a payment, an inventory reservation and a shipment. It's the domain I've worked in the most, and the one any reader understands without explanation.

What to take with you: you don't pick architecture from a catalog; you pick it for the problem you have today.

Step 0 · The happy monolith

The monolith was not a mistake: it was the right architecture for years, and it still is for many systems.

The starting point. One application, one relational database (Oracle, MySQL, PostgreSQL). Our Order lives in an orders table with its status, another table order_lines, and inventory, payment and shipping are columns or neighboring tables in the same schema. One transaction saves it all. One JOIN queries it all. It's simple, and simplicity is a virtue not to be dismissed.

The first pain: reads. As traffic grows, the database gets the same query thousands of times a minute ("give me the catalog", "give me the cart"). The industry's answer was to put memory in front of the database: Memcached (2003) and, years later, Redis (2009), which also brought data structures, persistence and pub/sub. Our Order doesn't change; the repetitive reads just stop hitting the disk.

Where it breaks. The cache relieves reads, but it doesn't touch three deeper problems:

  1. One schema for the whole business. The orders table is touched by inventory, billing, shipping and marketing. Every team adds columns and nobody knows anymore what status = 7 means.
  2. One deployment. A bug in the shipping module takes billing down, because it's the same process.
  3. The code doesn't speak the business's language. The business says "confirm an order"; the code says UPDATE orders SET status = 3. That distance is where the expensive misunderstandings are born.

The third problem is the deepest, and it isn't technical. Solving it is the next step.

What to take with you: the monolith breaks on the model first, not on the traffic.

Step 1 · Modeling the business: DDD

In 2003 Eric Evans published Domain-Driven Design: Tackling Complexity in the Heart of Software (the "blue book") and changed the question. It was no longer "how do I organize the layers" but "how do I make the software say the same thing the business says".

One clarification before going on, because I see it written wrong often: DDD is Domain-Driven Design. There is no "Domain-Driven Development"; the confusion comes by analogy with TDD and BDD. DDD is not a development methodology nor a coding technique: it's a way of thinking about design when the business logic is complex.

The idea, in three pieces.

  1. Ubiquitous language. Developers and business experts use the same words, and those words are in the code. If the business says "confirm", the class has a confirm() method, not setStatus(3).
  2. Tactical patterns. The Order stops being a row and becomes an Aggregate: a root (Order) that protects its rules ("it can't be confirmed without lines", "it can't be modified after payment"). Its lines are part of the aggregate; money is a Value Object (Money, immutable, with a currency); and a Repository saves it and loads it whole, never halfway.
  3. Bounded Context. This is the piece that changed the industry. Evans observed that the word "Product" means different things to Catalog (name, photos, description), to Inventory (SKU, quantity, location) and to Billing (price, tax). Forcing a single Product model onto all three is the root of the chaos in Step 0. The answer: one model per context, with explicit boundaries, and translation at the edges.

What DDD doesn't say. The blue book doesn't talk about microservices, or queues, or events. Domain Events (OrderConfirmed, PaymentReceived) are not in the 2003 book: Evans added them years later and Vaughn Vernon consolidated them in Implementing Domain-Driven Design (2013, the "red book"). Worth knowing, so you don't credit DDD with things that came afterwards.

Where it breaks. DDD answers "where to cut": by Bounded Context. But once Orders, Inventory and Shipping are separate contexts with their own models, the question the book doesn't answer appears: how do they talk to each other? If Orders calls Inventory through a direct method call, we're back to the monolith's coupling, only with more classes.

What to take with you: DDD tells you where the boundaries are; it doesn't tell you how to cross them.

Step 2 · Talking without calling: queues and Pub/Sub

The answer to "how do two contexts talk without coupling" already existed in the industry: asynchronous messaging. What DDD did was give it a business reason to be used.

The idea. When the Order is confirmed, Orders doesn't call Inventory. It drops a message on a queue: "reserve these SKUs for order 4821". Inventory consumes it when it can. If Inventory is down, the message waits in the queue (as long as the queue is durable and the message persistent; that's not magic, it's configuration). Orders has already finished its job and answered the customer.

This decouples along two dimensions at once: in time (the two services don't have to be alive at the same moment) and in knowledge (Orders doesn't know who consumes the message or how many of them there are).

The tools.

  • Amazon SQS (beta in 2004, generally available in 2006). It was AWS's first service. A managed queue, no servers to administer. Point to point, at-least-once delivery, no ordering guarantee on the standard queue (the FIFO variant came later). To broadcast a message to several consumers you combine it with SNS.
  • RabbitMQ (2007). It implements AMQP and adds what SQS didn't have: exchanges with routing. A fanout exchange copies the message to several queues, one per consumer, so Inventory and Notifications can both receive the same "order confirmed" without knowing about each other. That's Pub/Sub.

In Java, with Spring, both connect in a few lines (@RabbitListener, @SqsListener). The hard part was never the code; it was deciding which messages exist and what they mean.

Where it breaks. Traditional queues work on a consume-and-delete model: when the consumer acknowledges (ack) the message, it disappears from its queue. That's correct for "send the welcome email". But two needs don't fit:

  1. Rewinding. A new analytics team wants to process every confirmed order from the last six months. The messages no longer exist.
  2. Volume. Millions of events per second with dozens of consumers reading the same stream at different paces.

Kafka will solve those two needs, in Step 5. But first there's an internal problem to deal with: our Order, even with boundaries and messages, is still married to its database and its framework.

What to take with you: a queue decouples in time and in knowledge, but it forgets what it delivers.

Step 3 · Protecting the core: Hexagonal and Clean Architecture

If the Order is the heart of the business, the heart shouldn't know whether it's stored in PostgreSQL, whether it arrives over REST, or whether its event goes out through RabbitMQ. That's the whole idea of this step.

The pain. In the typical monolith, the Order class extends a JPA entity, carries framework annotations, and the service that confirms it receives an HttpServletRequest. Changing the database, testing the rule "can't be confirmed without lines" without spinning up a container, or emitting the event through another channel, all force you to touch the domain. The business is held hostage by the infrastructure.

The idea. Alistair Cockburn published Hexagonal Architecture (also called Ports and Adapters) in 2005. The domain sits at the center and defines ports: interfaces that say what it needs (OrderRepository, EventPublisher) without saying how. Outside, adapters implement those ports with whatever technology is current: a JPA adapter, a RabbitMQ adapter, a REST controller. The dependency always points inward.

Robert C. Martin formalized the same intent as Clean Architecture in 2012 (and as a book in 2017), with concentric layers and the rule that inner code never knows about outer code. Hexagonal, Clean and Onion are variations on one discipline; it's not worth fighting over which one is "the right one".

Why DDD adopted it. Hexagonal was born outside DDD, but it fits like a glove: the Aggregate and its rules live at the center; Repositories are ports; adapters are the edge of the Bounded Context. Vernon recommends it explicitly in the red book.

In my Spring Boot projects this translates into three packages: domain (no Spring dependencies), application (use cases) and infrastructure (JPA, Kafka, REST). The proof that it's done right: domain compiles without the framework.

Where it breaks. We now have a good internal design: clear boundaries (DDD), decoupled communication (queues) and a protected core (Hexagonal). But all of that is still a single deployable artifact. One .jar, one pipeline, one release cadence for every team. When the Shipping team wants to ship three times a day and the Billing team once a month, internal architecture is no longer enough. The next cut is physical.

What to take with you: the domain defines what it needs; the infrastructure decides how. Never the other way around.

Step 4 · Deploying per context: microservices

A microservice is a Bounded Context that deploys on its own, with its own database and its own team. That's the useful definition; everything else is detail.

The right date. The term was used at an architects' workshop in Venice in 2011, but what turned it into an industry standard was James Lewis and Martin Fowler's article in March 2014. I point this out because plenty of material places it somewhere between 2005 and 2011, and that scrambles the order of the story: microservices came after DDD, after queues, after Hexagonal and after Kafka, not before.

The idea. Orders, Inventory, Payments and Shipping stop being packages inside one .jar and become four processes. Each one:

  • owns its data (Inventory has its database; nobody else reads it directly),
  • deploys at its own pace (Shipping releases three times a day without asking Billing for permission),
  • and talks to the others through messages (Step 2) or an API, never through a shared database.

Spring Boot (2014, the same year) and containers made this practical: a service is an executable with its embedded server, not a .war deployed onto a shared application server.

A correction that matters. The relationship is not "one microservice = one Bounded Context". A context can hold several services (Orders could have one for the cart and another for confirmation, sharing the same model and the same language). What is a red flag is the opposite: a service that crosses two contexts and mixes their models. The rule I use: a service is never bigger than a Bounded Context.

Where it breaks. Now the Order lives in four databases. And the customer's "My orders" screen needs the order status, whether the payment was approved, how many units are left and where the package is going. It used to be a JOIN. Now it's four calls to four services, with four latencies and four possible failures, to paint a single screen.

On top of that, when Orders emits "order confirmed", it's no longer two consumers: it's Inventory, Payments, Shipping, Notifications, Analytics and Fraud, each at its own pace, and some of them will want to re-read what already happened. The queues from Step 2 aren't built for that.

What to take with you: microservices is DDD with independent deployment; the price is that cross-context queries and event broadcasting become first-order problems.

Step 5 · The log that doesn't forget: Kafka

Kafka is not a faster queue. It's something else: an ordered, immutable, on-disk event log where reading doesn't delete.

The pain, with a name attached. At LinkedIn, around 2010, dozens of systems (search, recommendations, metrics, security) needed to consume the same activity streams, each at its own pace, and be able to reprocess them. Traditional queues wouldn't do: the message died with the first ack. LinkedIn built Kafka and open-sourced it in early 2011.

The idea. A topic is an append-only log: events are written at the end and never modified. Each consumer keeps its own offset, its reading position. Analytics can be at event 10,000 while Shipping is at 10,400, and a new consumer can start from the beginning. The OrderConfirmed event for order 4821 is still there tomorrow, and next week, until the retention policy removes it.

For our Order, this solves the two needs the queues left open: six consumers reading the same stream without copies, and the analytics team reprocessing six months of history.

An honest nuance about RabbitMQ. The distinction "RabbitMQ deletes, Kafka keeps" is true of their original designs, but RabbitMQ added Streams in version 3.9 (2021), a log with Kafka-style offsets. And Redis has had Streams since version 5 (2018). The line between tools is blurrier today than it was in 2011; what doesn't change is the question: do I need the consumer to be able to re-read?

Where it breaks (and this is the most expensive point in the whole article). Look at this flow, the one that shows up in almost every microservices diagram:

  1. Orders saves the order in its database.
  2. Orders publishes OrderConfirmed to Kafka.

Those are two writes to two different systems. No transaction covers both. If the database commits and Kafka fails (network, broker down, timeout), the order exists but Inventory never reserves stock. If Kafka commits and the database fails, Inventory reserves stock for an order that doesn't exist. This is called the dual-write problem, and I've seen it produce real inconsistencies that took weeks to track down.

The standard solution: Transactional Outbox. In the same transaction where you save the order, you insert the event into an outbox table in the same database. A separate process (a relay, or Debezium reading the database's change log via CDC) reads that table and publishes to Kafka. Now there's a single atomic write; publishing can retry without fear.

The price: the event may be published more than once (the relay retries). That's why every consumer must be idempotent: Inventory stores the id of each processed event and, if it sees it again, ignores it. Outbox and idempotency always go together; one without the other is half a solution.

What to take with you: Kafka is a log you can re-read, not a queue; and never write to your database and to Kafka as if they were a single operation.

Step 6 · Reading and writing differently: CQRS

The model that works for protecting the Order's rules is terrible for painting the "My orders" screen. CQRS accepts that fact and uses two models instead of forcing one.

Where it comes from. Bertrand Meyer formulated the CQS principle (Command-Query Separation) in the 1980s: a method either changes state or returns data, never both. Greg Young took it from the method to the architecture around 2009-2010 and called it CQRS (Command Query Responsibility Segregation). It's not an invention out of nowhere; it's CQS at system scale.

The pain. The Order Aggregate from Step 1 is designed for writing: it loads whole, validates invariants, protects consistency. When the screen asks for "the customer's last 20 orders with product name, payment status and shipping city", that aggregate is useless. You'd have to load 20 full aggregates, cross-reference three other services (Step 4) and assemble a DTO. Slow, expensive, and the read code ends up polluting the write model with fields that are "just for the view".

The idea. Separate:

  • Command side. ConfirmOrder, CancelOrder. This is where the Aggregate, the rules and the transactional database live. Optimized for correctness.
  • Query side. GetMyOrders. A projection: a table (or document, or index) with exactly the columns the screen needs, already joined. Optimized for read speed. It can be a different database (Elasticsearch, a denormalized table in PostgreSQL, Redis).

How does the projection stay current? With the events from Step 5. When OrderConfirmed, PaymentApproved or ShipmentDispatched arrives, a consumer updates the projection's row. The screen reads a single table. That's the real value of CQRS in microservices: cross-context queries are resolved ahead of time, by listening to events, not at request time by calling four services.

Eventual consistency, explained with the Order. The customer pays and, for a few hundred milliseconds, the "My orders" screen still says "payment pending" because the event hasn't reached the projection yet. That's eventual consistency, and it's a business decision, not a bug. In an e-commerce store it's acceptable; in an ATM it isn't. The right question for the business is "how much delay do you tolerate on this screen?", not "do you want consistency?".

What CQRS doesn't require. CQRS doesn't force you to use Event Sourcing, or Kafka, or two databases. The minimal version is two service classes in the same module, one for commands and one for queries, against the same database. Start there; scale up only if the pain asks for it.

Where it breaks. The command side stores the Order's current state: status = CONFIRMED. When Finance asks "why did this order end up with a 30% discount?" or Audit asks "rebuild the order as it was on March 3rd", the current state has no answer. The history was lost in every UPDATE.

What to take with you: CQRS is two models for two jobs; the eventual consistency it brings is a business decision to be negotiated screen by screen.

Step 7 · Keeping the history: Event Sourcing

Event Sourcing is the exception, not the rule. I say it up front because it's the most misapplied pattern on this list.

Where it comes from. Martin Fowler described it on his blog in December 2005, before Kafka existed and before CQRS. The DDD community, with Greg Young at the front, turned it into practice and paired it with CQRS. It was born to solve a modeling pain, not an infrastructure one.

The idea. Instead of storing the Order's current state (status = CONFIRMED, total = 70), you store the sequence of facts that got it there:

  1. OrderCreated (customer 17)
  2. LineAdded (SKU 4421, quantity 2, price 50)
  3. DiscountApplied (30%, coupon AUTUMN)
  4. OrderConfirmed
  5. PaymentApproved (70)

The current state is rebuilt by applying the events in order. There is never an UPDATE; only INSERTs of new events. The events table is the source of truth, and Audit's question from the previous step ("how did it end up at 70 with a 30% discount?") is answered by reading facts 2 and 3. Rebuilding the order "as it was on March 3rd" means applying the events up to that date.

When yes. Domains where the history is the business: accounting, banking, insurance, regulatory traceability, any system where "who changed what, and when?" is a frequent question rather than an exception.

When no. A product catalog, a user profile, 90% of CRUD apps. If nobody is going to ask about the history, you're paying complexity for nothing. In my experience, most microservice systems use events to communicate (Step 5) and store current state with JPA. That's Event-Driven Architecture without Event Sourcing, and it's perfectly healthy. Confusing the two terms because both contain the word "event" is the most common mistake I hear in interviews.

Where the events are stored (and why not in Kafka). Event Sourcing needs to read the stream of a single aggregate (every event of order 4821, in order) and to write with optimistic concurrency ("append this event only if the latest version is still 5"). Kafka offers neither natively: a topic doesn't index by aggregate and has no per-key version. The event store goes elsewhere: EventStoreDB, Axon Server, or a relational table events (aggregate_id, version, type, payload) with a unique index on (aggregate_id, version). Kafka, if present, receives the events afterwards, via Outbox, to notify the other contexts. Two different things: one is the aggregate's memory; the other is the loudspeaker.

The costs nobody mentions at the start.

  • Snapshots. An order with 10 events rebuilds in microseconds; a bank account with 200,000 movements does not. You have to store periodic snapshots of the state and apply only the events after them.
  • Event versioning. LineAdded version 1 didn't have the currency field. Old events are immutable; the new code has to know how to read both formats forever.
  • Queries. An event store is terrible at "every order from October". That's why Event Sourcing almost always comes with CQRS: projections answer the queries, the store keeps the truth.
  • Deletion. Regulations like GDPR require deleting personal data, and an immutable log doesn't delete. The solutions (per-customer encryption with key destruction, crypto-shredding) are complex.

What to take with you: Event Sourcing stores facts instead of state; use it only where the history is the business, and keep it in an event store, not in Kafka.

Closing · How to decide

You choose the tool with questions, not with trends. These are the ones I ask before proposing any piece of this chain.

Notice that the last questions depend on the first ones. Nobody should arrive at Event Sourcing without having gone through DDD and CQRS; and nobody should publish events from a service with its own database without Outbox.

Is the business logic complex, and does it change often?

  • Yes: DDD, with ubiquitous language, aggregates and Bounded Contexts.
  • No: a well-built CRUD; DDD would be over-engineering.

Do two contexts need to react to what happens in another?

  • Yes: domain events over messaging.
  • No: synchronous calls (REST/gRPC) are fine.

Do you need to deliver a message to one consumer, reliably?

  • Yes: RabbitMQ or SQS (plus SNS to broadcast).

Do several consumers read the same stream, and does any of them need to re-read or reprocess?

  • Yes: Kafka (or RabbitMQ Streams / Redis Streams).
  • No: a queue is enough.

Do you save to the database and publish an event in the same operation?

  • Yes: Transactional Outbox plus idempotent consumers, no exceptions.

Do different teams need to deploy at different paces?

  • Yes: microservices, at most one per context.
  • No: a modular monolith with Hexagonal.

Do screens cross data from several contexts, or is the write model useless for reading?

  • Yes: CQRS with projections fed by events.
  • No: a single model.

Does the business frequently ask "how did this get here" or "what did it look like on that date"?

  • Yes: Event Sourcing in an event store (not Kafka), with CQRS.
  • No: current state; events only for communication.

The big picture

The Order end to end: one transaction, a replayable log and idempotent consumers

The Order end to end · 1 transaction, 1 log, 3 consumers. The order and its event are saved in a single transaction; the relay carries them to Kafka, where each consumer reads at its own pace and discards duplicates; the customer's screen reads a projection, never four services.

Verified timeline

The dates matter because they fix the order of the chain. These are checked against the primary sources listed at the end.

  • 2003 · Domain-Driven Design (blue book) · Eric Evans
  • 2003 · Memcached · Brad Fitzpatrick (LiveJournal)
  • 2004-2006 · Amazon SQS (beta 2004, GA 2006) · AWS
  • 2005 · Hexagonal Architecture (Ports and Adapters) · Alistair Cockburn
  • 2005 · Event Sourcing (article) · Martin Fowler
  • 2007 · RabbitMQ · Rabbit Technologies
  • 2009 · Redis · Salvatore Sanfilippo
  • 2009-2010 · CQRS (building on Meyer's CQS) · Greg Young
  • 2011 · Apache Kafka (open source) · LinkedIn
  • 2012 · Clean Architecture (article; book in 2017) · Robert C. Martin
  • 2013 · Implementing DDD (red book) · Event Storming · Vaughn Vernon · Alberto Brandolini
  • 2014 · Microservices (article) · Spring Boot 1.0 · James Lewis and Martin Fowler · Pivotal

What to take from the whole article

  1. The monolith breaks on the model first, not on the traffic.
  2. DDD says where the boundaries are; queues say how to cross them; Hexagonal protects what's inside.
  3. Microservices is DDD with independent deployment, and its price is cross-context queries.
  4. Kafka is a replayable log; the dual write is solved with Outbox and idempotency, always.
  5. CQRS is two models for two jobs, and it brings eventual consistency the business has to accept screen by screen.
  6. Event Sourcing is the exception: only where the history is the business, and never with Kafka as the event store.

Sources

View Spanish version