Find out how AI is better than .NET developers at creating projects, writing unit tests, adding comments and debugging exceptions.
Make career conversations with your manager clearer by exploring your interests, desired responsibilities, role expectations, and opportunities as goals change.
Tony’s Pizza looks simple from the outside. A customer orders a pizza, the pizza is prepared, and a driver delivers it. Unfortunately, though, Tony’s has spent 20 years acquiring regional pizza chains, loyalty programs, delivery services, and online ordering platforms. Answering the question of “Where is my pizza?” is not so simple anymore, requiring data from multiple systems and databases with no clear ownership.
This is exactly the kind of problem the GraphQL semantic layer is uniquely positioned to solve. It allows customers, orders, stores, rewards, and other business concepts to remain stable even as the underlying system, databases, and vendors continuously change. The schema becomes less of a data map and more of a shared business language.
In this guide, learn all about GraphQL: what it is, how it works, the problems it solves, and how to design the perfect schema – one that holds up as systems keep changing.
Behind that simple experience is a fragmented technology landscape built through years of acquisitions, integrations, and legacy system decisions. There’s no unified approach – different systems manage online ordering, loyalty rewards, delivery operations, and in-store point-of-sale (POS) transactions.
Consider one of Tony’s loyal customers, Sarah Johnson. Sarah orders pizza every Friday night and, from her perspective, she’s just another customer.
From the perspective of Tony’s technology ecosystem, however, she may be represented as:
So, while Sarah believes she is just one person, Tony’s databases have developed five different theories!
A traditional integration strategy often exposes these underlying systems directly, with applications becoming tightly coupled to implementation details. Linking the systems together to join orders, loyalty points, delivery status, address, etc, should not require intimate knowledge of every single database.
Similarly, it shouldn’t require working through arcane matching logic to know that five identities are really the same person. Over time, business knowledge becomes inseparable from technology knowledge – and this is exactly where complexity begins to leak through architecture.
A semantic layer sits between consumers and underlying systems. It retrieves data but its real purpose is to establish a common business language.
Essentially, instead of exposing databases, tables, services, and application programming interfaces (APIs), the semantic layer exposes concepts that the business actually understands.
So, in the case of Tony’s Pizza:
With this, for example, consumers no longer ask “Which system stores loyalty information?” – instead, it’s “Show me Sarah’s reward account.”
That’s because the semantic layer simply translates business questions into whatever technical operations are required behind the scenes.
GraphQL provides the ideal technology for implementing this pattern, as the schema becomes a representation of the business domain rather than a reflection of database structures.
For example, a consumer can query:
customer(id: "SARAH") {
name
rewardAccount {
pointBalance
}
orders {
orderDate
delivery {
status
}
}
}NOTE: The identifier in this example is deliberately business-facing; the semantic layer is responsible for resolving it to whatever source-system identifiers are required.
This query is written entirely in business language because, of course, the consumer themselves don’t need to know:
Put simply, the GraphQL schema becomes the source of truth for business definitions. It’s essentially modeling how the business actually thinks; it’s not just an information store.
The power of the semantic layer becomes easier to see when we compare how the business views Sarah with how the underlying architecture views her.
From the business perspective:
Customer
|
+-- Orders
|
+-- Reward Account
|
+-- Coupons
|
+-- DeliveriesFrom the technology perspective:
CRM Database
|
Online Ordering Platform
|
Loyalty System
|
Marketing Application
|
Delivery PlatformWithout a semantic layer, every consumer must understand both diagrams. With a semantic layer, consumers only need to understand the first. As mentioned previously, the semantic layer is modeling how the business thinks – rather than just being an information store.
This matters because databases are implementation details, and implementation details change over time. Who knows, Tony’s may replace its loyalty platform next year, or modernize its delivery application. It may even acquire another pizza chain with yet another customer database…
If consumers are directly coupled to those systems, every change becomes a costly integration project. However, if consumers are simply coupled to the semantic layer instead, the underlying systems can evolve while the business contract remains stable.
Many discussions about GraphQL focus on client performance, query flexibility, or reducing API calls. All useful benefits, yes, but not the most transformative aspect of the technology. The more powerful idea is that GraphQL enables organizations to create a unified business model, one that stands apart from the systems that implement it.
In other words, the goal is to allow the business to speak in terms of customers, orders, pizzas, stores, and deliveries, without requiring anyone to know where those concepts are actually stored. When this works, the semantic layer becomes a shared language for the organization.
Or, as Tony’s eventually concluded: the pizza business has exactly one customer named Sarah, the databases have several opinions, and the semantic layer exists so the rest of the company can stop arguing about it!
Once we accept that GraphQL can serve as a semantic layer, the next design question becomes more important: what should the schema actually model?
One of the most common mistakes teams make when adopting GraphQL is assuming that its schema should mirror the underlying database schema.
And who can blame them? After all, the database already contains tables, columns, keys, and relationships. Exposing all of these structures through GraphQL appears to be a straightforward path to implementation.
Unfortunately, though, this approach eliminates many of the benefits a semantic layer is intended to provide.
When a schema simply reflects storage structures, consumers inherit all of the design decisions, inconsistencies, and historical baggage of the underlying systems. The result is often a GraphQL API that looks modern on the surface but exposes the same complexity as before.
The challenge, then, is to expose the business.
A useful exercise when designing a semantic layer is to temporarily ignore the databases entirely. For example, instead of asking “what tables do we have?”, ask “what questions does the business need to answer?”
For Tony’s Intercontinental Pizza Group, common questions might include:
Notice that none of these questions mention tables, databases, APIs, foreign keys, or system names…
Business users naturally think in terms of outcomes and relationships rather than storage structures – and the semantic layer should do the same.
Suppose Tony’s began by exposing existing systems exactly as they exist. The resulting schema might look something like this:
type CustomerMaster {
customerId: ID!
}
type LoyaltyMember {
memberId: ID!
}
type WebUser {
userId: ID!
}
type DeliveryContact {
contactId: ID!
}Technically, this is accurate. Unfortunately, though, it’s practically useless from a business perspective.
This is because a developer attempting to answer a simple question such as “show me Sarah’s available rewards” must first understand which object represents Sarah. Then, how those identifiers relate, and which system owns the information.
This complexity of the underlying architecture leaks directly into the API.
Now consider the same scenario designed around business concepts:
type Customer {
id: ID!
name: String!
rewardAccount: RewardAccount
orders: [Order!]!
activeCoupons: [Coupon!]!
}The schema now reflects how the business talks about customers. After all, the consumer doesn’t need to know where loyalty data resides, which system stores orders, and whether customer records exist in multiple places. These concerns are hidden behind the semantic layer, where they belong.
So, yes, the underlying architecture may remain complicated – but the consumer experience is dramatically simpler. GraphQL gives an organization a place to define what its business actually means.
Of course, business users rarely think in joins! Rather, they think in relationships: a customer places orders, and the store fulfills the orders. Then, the drivers deliver the orders. There’s also the supplier, providing the ingredients.
These relationships are intuitive because they mirror how people describe the business in everyday conversation. GraphQL is particularly effective because it allows schemas to represent these relationships directly:
customer {
name
orders {
orderDate
delivery {
driver {
displayName
}
}
}
}Reading this query feels remarkably similar to reading an English sentence: Show me a customer, their orders, the delivery associated with each order, and the driver responsible for the delivery.
Again, the schema is the business language.
The process of designing a semantic layer often reveals a surprising challenge: agreeing on what things should be called. For example, Tony’s various systems may use terms like customer, member, consumer, account holder, guest…the list goes on. And each system may well have valid reasons for its terminology.
None of them are necessarily wrong. They’re just confidently describing different slices of the same pizza, if you will. Some may even be necessary. However, Tony’s should not have to keep paying for the sins of earlier designs.
The semantic layer forces a different question: what should the business call this concept? Rather than perpetuating every variation, the schema simply establishes a canonical vocabulary. In Tony’s case, the answer might simply be type Customer.
While this may seem like a minor thing, it’s actually one of the most valuable contributions a semantic layer can make. A shared vocabulary reduces confusion, simplifies onboarding, and provides a stable foundation for future development.
The naming problem is not simply choosing between terms like customer, member, guest, contact, etc. The actual problem – the one that needs solving – is how these concepts relate to one another.
At Tony’s, Sarah may appear in several systems under several different names. In one system showing her orders, she’s a customer. In another, for loyalty points, she is a ‘loyalty member’. And when she receives marketing promotions, she’s a ‘marketing contact.’
We could go on, but you get the point: the labels all point to the same person, yet don’t all mean the same thing. For the system, this is confusing. For humans like Sarah – not so much.
All of this matters when designing a semantic layer. If every term is collapsed into a single customer type, for example, the schema may hide important business meaning. Then there’s the source-system terms where, if each term is exposed separately, the schema recreates the confusion it was meant to resolve!
The better approach is to separate the enduring business concept from the roles and relationships attached to that concept.
Ultimately, Sarah is a customer. Yes, she has a rewards account, receives marketing messages, and is definitely the recipient of deliveries – but she’s still just a customer. Her rewards account, marketing preferences, and who receives the deliveries, are different things.
In GraphQL, those distinctions can be modeled directly:
type Customer {
id: ID!
name: String!
rewardAccount: RewardAccount
orders: [Order!]!
deliveryRecipients: [DeliveryRecipient!]!
marketingPreferences: MarketingPreferences
}Not only does this model use cleaner names, it also expresses the relationships the business actually cares about. After all, a customer is not the same thing as a reward account: a reward account belongs to (or is associated with) a customer. And the delivery recipient is not always the same as the person who placed the order. We could go on.
This is where the schema comes in, answering questions like:
These decisions shape how consumers understand and use the API. The end goal? Define a model that makes business relationships explicit without hiding unnecessary implementation detail. A well-designed semantic layer always asks, “what is this thing, and how does it relate to the rest of the business?”
It’s that last element – the relationship to the business – that’s so important, and this is where GraphQL becomes so powerful. Its graph structure allows the schema to describe both business objects and the relationships between them, creating something that feels natural to consumers since it’s mirroring how the business already thinks.
A useful rule of thumb when reviewing a schema is what we might call the “Business Language Test.” Basically, imagine showing a portion of the schema to a store manager. If the explanation requires a database diagram and an acronym dictionary – followed by an apology! – the schema may be telling the wrong story.
Could they understand what it represents? Consider:
Most business stakeholders can immediately understand these concepts. Now compare those to:
LOY_MEMBERWEB_USERCUST_ACCTORD_HDRDEL_CONTACTThese names reflect existing systems but require institutional knowledge to interpret. That’s where a semantic layer succeeds – it uses meaningful and understandable language outside of the technology department.
Perhaps the greatest advantage of designing around business concepts is durability. Technology landscapes are always changing: businesses evolve, companies merge, systems get replaced, and new vendors arrive. What looks like a permanent platform today can become tomorrow’s migration project. That’s why, like many organizations, Tony’s has learned that “temporary” solutions have a curious talent for becoming permanent.
The business concept of a customer, however, tends to persist. Tony’s may replace its loyalty platform in five years, adopt a completely new ordering system in ten, and absorb more databases through future acquisitions. Through all of that change, the business will still need to understand customers, orders, stores, deliveries, rewards, and the relationships between them.
If the schema has been built around current implementation details, each change becomes disruptive because consumers are forced to learn the shape of the latest system. However, if the schema has been built around enduring business concepts, changes can remain largely invisible to consumers.
All of this is what makes up the true value of a semantic layer. It’s a deliberate separation between how the business understands itself and how technology happens to be implemented at any given moment.
For architects and implementation teams, this changes the starting point of schema design without making the conversation purely technical. The first deliverable should always be a shared vocabulary and relationship model, one that all stakeholders can validate together before implementation begins.
As the data team at Tony’s eventually discovered: systems, databases, and vendors all come and go. Customers, orders, and pizzas, on the other hand – they all remain. Design the graph around these concepts, and the business can evolve without dragging every consumer through yet another migration project.
In short: the schema isn’t just a data-fetching layer — it’s the durable contract between the business and the technology that serves it. Design it so systems can change behind the scenes without anyone in front of it ever noticing.
A semantic layer is a layer that sits between API consumers and backend systems, exposing business concepts (like Customer or Order) instead of raw database tables or service structures — so consumers interact with stable, business-friendly language rather than shifting technical implementation.
Traditional integrations often expose underlying systems directly, coupling consumers to implementation details. GraphQL’s schema-first approach lets you define a business-facing contract (like Customer.rewardAccount) that stays stable even when the systems behind it change.
Mirroring the database inherits all its inconsistencies, legacy naming, and structural baggage — producing an API that looks modern but still forces consumers to understand internal system architecture to use it.
It’s a quick schema review method: if you can’t explain a type or field to a non-technical stakeholder (like a store manager) without a database diagram or acronym glossary, the schema likely needs to be redesigned around business concepts.
No — the underlying systems can remain just as complex. A semantic layer hides that complexity from consumers rather than removing it, so teams can replace or merge backend systems without breaking every application built on top.
The post Why your schema shouldn’t mirror your database (and what to do instead) appeared first on Simple Talk.
“Hi Oskar, have you ever given DCB any attention?”
I got that question on Discord recently. I replied: “Yes, I did, what’s the question behind the question?” And after a few messages, it came:
“Is it on the Emmett roadmap?”
That was my guess, and my answer was: not for now.
It wasn’t the first time I’ve been asked, and I think it’s time to share my standpoint. Especially since I have a GitHub issue on Emmett to answer the same question.
I wasn’t much active in DCB discussions intentionally. I’m not against Dynamic Consistency Boundaries (DCB). I see it as an interesting pattern, but not as a safe default choice, as some people try to explain it. In other words, I don’t see it as a high priority for me, and it’s not the endeavour I want to take right now. But I also don’t mind people doing it and bringing that to their solutions.
It’s an idea brought by Sara Pellegrini and Milan Savic 3 years ago:
At that time, engineers at Axon, the company behind the Java Event Store and CQRS Framework.
They started with the example of students signing up for courses. Let’s use it here also to explain it.
The rules are:
Suppose student Oskar can attend at most five courses and is already enrolled in four. Courses Math and Programming both have free places.
If we’re building an event sourcing system, we could design a stream per course and a stream per student. Enrolling needs information from both: how many students are already in the course and how many courses the student already has. So which stream should the decision live in?
If a course has a limited number of places, we can check it on the course stream. If it handles all sign-ups, we can validate whether we have already reached the limit. If we have, we wouldn’t allow further sign-ups. All mature event stores support optimistic concurrency and atomic appends to the stream. Fine.
Now, how do we check whether our student has reached the course limit for the semester? The student stream could validate that. If we start sign-up from their stream, we could ensure that they didn’t sign up for more than their limit. Fine.
But how do we ensure both? What if our event store doesn’t have transactions or at least atomic multi-stream appends? Then we can’t enforce both conditions in a single operation, because between one append and the other the process may die, and we end up inconsistent.
We could, of course, decide which rule is more important and append to the course stream, then subscribe to it and asynchronously update the student stream to include that information. Then we could also run a compensating operation to cancel their sign-up if they had more course sign-ups than allowed. That would work, but we would need some coordination and compensation that may not be needed in simple cases, and some people are afraid of it.
DCB proposes to stop asking which stream the decision belongs to. Instead of appending events to streams, we tag them:
{
"type": "StudentSubscribedToCourse",
"data": { "studentId": "s1", "courseId": "c1" },
"tags": ["student:s1", "course:c1"]
}When handling a command, we select the events needed for the decision by type and tag. To enrol Oskar in Math course, we need the events defining the course and its capacity, the subscriptions to this course, and the subscriptions of this student:
[
{ "types": ["CourseDefined", "CourseCapacityChanged", "StudentSubscribedToCourse"], "tags": ["course:c1"] },
{ "types": ["StudentSubscribedToCourse"], "tags": ["student:s1"] }
]We build the state from those events and make the decision. Then we append the new event with the condition: “fail if any event matching this query was appended after the position I read”. The event store checks this condition atomically with the append.
In our case, both requests read the same student:Oskar events. The first append succeeds. The second fails because an event matching its query, StudentSubscribedToCourse tagged with student:Oskar, was appended after the position it read. We retry, and this time the limit check rejects it.
We don’t pick the consistency boundary upfront when designing the system; each command defines it with its query. That’s where the dynamic in the name comes from.
The strongest case for DCB goes like this:
Those are good points, and I see why people like them. To explain where I land differently, I need to go back to where the idea started.
If you watched the video I linked earlier, the original idea didn’t have tags. The consistency boundary could be any query over past events, and I can see why that was so tempting. Deciding how to group events into streams is one of the first modelling decisions we make in Event Sourcing, and we make it upfront, when we know the least about the business.
The power of Event Sourcing is that our domain model (events) is also the technical model. We can get synergy between them and more easily understand how our system works.
But that can also mean that if we mess up modelling, we also mess up the technical part. The decision about stream type/category then drives not only the business model, but also consistency boundaries, concurrency, and performance. Changing that later, undeniably, has a cost.
The tagless idea promised to remove it. Events would just be facts in a log, and each decision would ask exactly the questions it needs: “has anyone taken the last place in this course since I checked?”, “is this student already enrolled in too many courses?”. If we understood the business better later, we’d change the question, not the data. That could also be closer to how business people describe their decisions.
That was truly dynamic, and honestly, that’s the version I liked more, even though I already saw it as niche.
Then it met reality. To guarantee consistency, the event store has to run that query atomically with every append, while other appends happen in parallel, and make sure none of them changes the query result. For arbitrary queries over all events, the database has to either lock much more than needed or track everything that each transaction read. That’s a fight with physics, and it won’t hold at any larger scale or with many parallel writes. I explained that in The Order of Things: Why You Can’t Have Both Speed and Ordering in Distributed Systems.
Tags are the pragmatic compromise to that. Instead of running any query over the event data, the event store can precompute an index for each tag and event type. That’s something doable for most databases. I understand that compromise; some simplification was needed to make the idea work with typical storage systems. But that simplification changes its nature.
Tags have to be known when we append the event, which means we need to know upfront how we’ll query for it later. Logically, it’s similar to linking events to other streams, which some event stores support (e.g., KurrentDB and RailsEventStore). It’s just less flexible: we can link an event to another stream later, but a tag has to be there from the start. We can retag events, but that’s a migration. The selection at command time stays dynamic, but the boundaries are set when we append the event.
Now look again at student:Oskar and course:Math. Those are tags, but they also look like identifiers of specific entities, which is exactly what stream IDs are. Technically, those tags create streams; the difference is that a single event (logically) lands in several of them. That’s more flexible than single-stream append, but for me, it’s a big compromise compared to the original idea, as it brings us much closer to what we already had.
When I discussed it, I heard a fair counterargument: DCB is a superset of what we had before. We can do traditional Event Sourcing with DCB by always having one tag per event, which is essentially a stream name. Two tags let us touch more than one entity in a single append. And selecting by multiple event types is something only DCB can do.
Yet, if we only operate on tags, what’s the difference from loading two streams and wrapping their optimistic concurrency checks in a single transaction? That’s what multi-stream appends could give us.
Back to our two requests: each of them loads its course stream and the student:Oskar stream, makes the decision and appends to both in one transaction, checking the expected versions of both. The first one bumps the version of student:Oskar. The second one fails its check, retries, and gets rejected by the limit. The result is the same as with DCB. DCB may be faster here, but not necessarily; I’ll get to that later.
What’s left is narrowing conflicts by event type. Say that while Oskar enrols in a Math course, they also change their email address, and we record StudentEmailChanged in the student:Oskar stream. With streams, the enrollment fails its concurrency check and has to be retried, even though the email has nothing to do with it. With DCB, it succeeds, as StudentEmailChanged isn’t part of its query.
That’s useful, but how often does it happen? We use optimistic concurrency because we assume that conflicts will be rare, and a retry means reloading the state and running the decision again. Personally, I’ve never had a situation where conflicts were so frequent that they became a real problem. When I saw them happening often, it was a design smell, and the answer was to look at the design, not to narrow the conflicts. If conflicts are frequent by the nature of the business case, pessimistic locking may be a better fit.
And if narrowing conflicts does matter in a specific case, I’d rather get it differently. That’s also how I’d see tags: as low-cardinality labels, closer to hashtags, like course:enrollment. Such a label tells what kind of information the event carries. Identifying specific entities is what streams are already good at. I’d keep them as logical boundaries, but without locking the whole stream on each append. The append would then say: these are the streams I’m touching, and these are the labels I care about within them. For our enrollment, that’s the course:Math and student:Oskar streams, and within them, the events labelled course-enrollment. StudentEmailChanged isn’t one of them, and it wouldn’t conflict. A small, fixed number of such labels would also simplify the implementation, since the unbounded number of high-cardinality tags is what complicates it.
Low-cardinality tags like student:Oskar and course:Math are good for the write model, where we want precise information about what to load and change. Yet they’re also terrible for business analysis, because event size grows and you can’t easily group or filter by criteria.
So to me, tags, as they’re shown most of the time, made DCB mostly a solution to a technical problem, while it was supposed to solve a modelling one. And multi-stream appends solve that technical problem without changing the storage model.
Concurrency is also a different problem than stream length or aggregate size. A stream may live long and be big without having any concurrent writes. Of course, legacy noun-based aggregates, like keeping everything about the user in a single stream, are too big, and DCB is more forgiving for them. But it doesn’t fix their design; it just makes it hurt less.
If multi-stream appends give us the same guarantees, what’s left? The argument I hear most often is that streams end up with too many events. It comes in three flavours:
Let’s go through them and check whether they’re really an issue.
Let’s take the cinema reservation example, and say we have a distancing rule from good-old-Covid times: when someone reserves a seat, the seats next to it must stay empty. If we have a stream per seat, we need to load the neighbouring seats too, as we need to check them (the seat on the left and the seat on the right of where we’re sitting). To also get the optimistic concurrency check on them, we have to append something to their streams, something like SeatNextToWereTaken. Nobody in the cinema would describe what happened that way, in total 3 events.
I agree that SeatNextToWereTaken sounds redundant. But this could also be solved technically. If our event store supports multi-stream appends, and we’re using the relational database, we could bump the version just on the stream’s row in the streams table, without appending any event.
The same fact recorded in several streams is a different story. With a multi-stream append, the course stream records that a student enrolled, and the student stream records that they got a new course. Is that a problem? I don’t mind having multiple events instead of a single one. Events are usually small, and their size is rarely an issue. Each stream also gets a linear history of its own observations, which makes it easier to understand what happened from its perspective.
Same for the cinema reservation: if we see in the stream that SeatNextToWereTaken, we have clear local information about why we need to reject the reservation, without needing to look around at the queries we have and why it was rejected. Imho, this is a big benefit on its own.
The third flavour needs more context, since it comes from explicitly modelling processes. And that brings me back to the enrollment example.
The course example is a good way to show how DCB works, as the rules are simple and easy to follow. But I don’t think it’s the best example of why we’d need DCB. I’m not an expert in this domain, but whenever we enrol in something, there are usually a few steps before it’s final. The example flattens that process into a single, CRUD-like operation, which raises the question of which stream the decision should live in. That’s fine for an introduction, but I’d like DCB examples also to show how such a process works in reality.
When I raised that, I heard:
“Regardless of the process, at the step where we add the student to the course, we’d add them without checking the student’s limit, and then we’d have to roll it back. DCB simplifies that step.”
Yes, but we don’t need a saga or a rollback for that. We can either do it in a single transaction, as I described above, or have a stream dedicated to the process. In my experience, the latter is usually the better option, as it’s closer to how the business works.
What do I mean by a stream dedicated to the process? Let me explain it with the example I used in my webinar on distributed processes and in How TypeScript can help in modelling business workflows: a hotel group checkout.
When a group of guests leaves the hotel, the clerk doesn’t want to check them out one by one. They select multiple guest stays and run the checkout as a batch. Each guest stay has its own stream and its own rules. For instance, we can’t check out a guest whose balance isn’t settled. Some checkouts may succeed, and some may fail, so the group checkout must gather the results and decide whether it completed or failed.
Such a group checkout could get its own stream in which we would record the information on the completed single checkouts. It could look like that:
Pos | Event
----|-----------------------------------------------------------------
1 | GroupCheckoutInitiated
2 | GuestCheckedOut {guestId: 'g1'}
3 | GuestCheckoutFailed {guestId: 'g2', reason: 'BalanceNotSettled'}
4 | GuestCheckedOut {guestId: 'g3'}
5 | GroupCheckoutFailedWe could also follow the Workflow Pattern and record the commands it handled. That’s also how workflows in Emmett work. The stream of a single group checkout can look as follows:
Pos | Kind | Direction | Message
----|---------|-----------|------------------------------------------
1 | Command | Input | InitiateGroupCheckout {groupId: '123'}
2 | Event | Output | GroupCheckoutInitiated
3 | Command | Output | CheckOut {guestId: 'g1'}
4 | Command | Output | CheckOut {guestId: 'g2'}
5 | Command | Output | CheckOut {guestId: 'g3'}
6 | Event | Input | GuestCheckedOut {guestId: 'g1'}
7 | Event | Input | GuestCheckoutFailed {guestId: 'g2'}
8 | Event | Input | GuestCheckedOut {guestId: 'g3'}
9 | Event | Output | GroupCheckoutFailedThe clerk’s command starts the process (1). The group checkout records that it was initiated (2) and sends a checkout command for each guest stay (3-5). Each guest stay handles its checkout according to its own rules, and the results come back as inputs (6-8). Once they’re all back, the group checkout decides the outcome (9). In this case, it failed, as one of the guests didn’t settle their balance.
Positions 6 to 8 are our third flavour. They record in the group checkout stream that it learned about each guest’s checkout. Domain experts wouldn’t phrase it that way, and that’s why some people call such events “technical”. I see it differently. It’s close to bookkeeping, where we add entries to the ledger to record that something happened. If we use a transaction or DCB, we can skip it, as things happen “together”. Logically, it still exists, but experts often don’t say it explicitly, and it can feel a bit artificial. It also depends on the implementation style; without Event Sourcing, we don’t need additional events, as we can just update the state.
Some may say that it’s just an event-sourced process manager. Maybe, but I don’t see a problem with that. For me, it’s a form of data locality: everything about this group checkout is in one place, together with what triggered each decision.
Process streams are quite often also closer to how the domain really works. Payment is a good example. It’s never just a Paid event; it’s a bigger workflow, where PaymentInitiated is followed by PaymentAccepted or PaymentCancelled. If we don’t have a place to model the process, we’re tempted to flatten it into a single event.
So are those additional events an issue? For the event store, not really. Event stores handle a large number of streams well; what hurts them are long streams that are actively accessed. Process streams are the opposite. They’re short, live only as long as the process, and once the process finishes, they’re rarely read. We can then archive them, as I described in Keep your streams short!.
For us, they’re useful, and the value becomes even more visible when a process crosses modules, because then we also have to deal with race conditions. Let’s take the order process I described in Saga and Process Manager. After the customer confirms the shopping cart, we initiate the order and request the payment from the payments module. When the payment is completed, we ask the shipments module to send the package. If the product turns out to be out of stock, we refund the payment. If the customer doesn’t pay in time, we cancel the order.
Each module has its own storage and works at its own pace. Messages between them don’t come in a guaranteed order; we have no ordering guarantees between different queues (read more in Dealing with Race Conditions in Event-Driven Architecture). Let’s say that the customer paid at the last moment. The timeout fired, and we cancelled the order, but a moment later, we got the information that the payment was completed. The order process stream could look as follows:
Pos | Kind | Direction | Message
----|---------|-----------|------------------------------------------
1 | Event | Input | ShoppingCartConfirmed {cartId: 'c1'}
2 | Event | Output | OrderInitiated {orderId: 'o1'}
3 | Command | Output | RequestPayment {orderId: 'o1'}
4 | Command | Input | TimeoutOrderPayment {orderId: 'o1'}
5 | Event | Output | OrderCancelled {reason: 'PaymentTimedOut'}
6 | Event | Input | PaymentCompleted {orderId: 'o1'}
7 | Command | Output | RefundPayment {orderId: 'o1'}Positions 4 to 7 show the race condition and how we dealt with it. We cancelled the order because of the timeout, and only then did we learn that the payment went through, so we refunded it. If the payment had arrived before the timeout, the stream would continue with SendPackage to the shipments module, and then with either PackageWasSent or ProductWasOutOfStock followed by a refund.
The stream records the order in which we observed things and what we decided based on that. When a customer asks why they got a refund, we don’t need to correlate events from the order, payment, and shipment modules by timestamp, which can lie because of clock skew between services. We read one short stream. Essentially, we’re making the chaotic outside world linear, based on the order of our observations.
DCB wouldn’t help us here; it’d even make it harder. Payments and shipments are separate modules with their own storage, and an external provider may even process the payment. To create a dynamic consistency boundary across them, all their events would need to live in the same event store, which means sharing storage between modules and coupling them through it.
In exchange for the additional events, we get:
Could we get the same by looking at tags or correlation ids? Partially, yes. We can reconstruct the process that way, but then we need additional tooling, and without it, it’s hard to tell where the source of truth is.
There’s no perfect method; each of them has flaws. It also depends on what we optimise for. In my approach, I optimise for precision in understanding the domain and the process. The compromise I accept is that some events may seem redundant, or the ceremony may feel too big. I prefer to do a bit too much rather than flatten the process and the domain. That’s, by the way, my biggest complaint about some practices in Event Modeling.
I mentioned that what hurts event stores are long streams that are actively accessed. That’s the other side of “too many events”: too many events in a single stream.
DCB has an answer for that too: reading only the last matching event, which can replace snapshots. I think we shouldn’t need snapshots at all. Snapshots are a good optimisation tool, but a technical one, focused on performance. They should be a conscious trade-off, not a hack we reach for by default. Yet they’re often presented as the default way to handle long streams. The record holder I saw while working at Event Store had tens of millions of events in a single stream. By now, it probably has hundreds of millions. That’s why I prefer to show how to model streams and keep them short, rather than work around long streams with a technical solution.
I explained how to do that in Keep your streams short!, using the example of cash registers in a department store. The classical approach is a stream per cash register, with all transactions registered on it. If we register one transaction every three minutes, that’s around 4,200 events per month, and the stream never stops growing. Yet, if we take a closer look or talk with domain experts, we notice the lifecycle: the cashier’s shift. It starts with ShiftOpened and ends with ShiftClosed, and we only care about the current shift. The ShiftClosed event contains the summary that the next shift starts from. It may look like a snapshot, but it’s the result of a business operation: the cashier closes the shift and declares the tender.
The same goes for the bank account, a common example in Event Sourcing. There’s no database transaction between multiple bank accounts when we transfer money, and accounting doesn’t treat a bank account as a transactional boundary. It works in periods, like the accounting month. Yes, there’s no spoon (read more in Implementing Closing the Books pattern).
All of that requires modelling, and we won’t always get it right. That’s why I often hear: “What if I model it wrong?”
When I asked what the biggest struggle with the classical stream design was, I heard that if we make a modelling error in an aggregate boundary, we may be forced to migrate. That’s a real challenge, and it’s tempting to think DCB removes it, since the boundary is just a query we can change. Yet tags have the same flaw. They also have to be defined upfront, and we can get them wrong as well. I’d even say we can get them wrong more easily, since with tags we’re not drawing boundaries at all, and I think drawing them is important.
Why? Because a well-designed stream reflects our process and how things happen in reality. When we read the cashier’s shift stream, we see what happened during that shift, in the order it happened. The group checkout stream shows the order in which we sent the checkouts and learned their results. The story is already there, and we don’t need to reconstruct it. With tags, an event belongs to many logical streams at once, and to get the story of a single process, we have to reconstruct it with a query.
And if we get the stream design wrong? Then we need a migration, just like retagging in DCB. With streams, that’s the copy-and-transform pattern: we subscribe to the old streams, transform their events, and append them to the new streams. Event stores should make that easy, and they don’t, Emmett included. That’s something I’d like to add out of the box, and I plan to do it before DCB.
I also heard the opposite argument: DCB could lower the barrier to entry. Tag the events, select the event types and tags needed for each command, and that’s it. No need to think upfront about how to split the system into streams. Similarly, Event Sourcing can be easier for beginners to grasp than for people who spent their whole careers with state-based systems.
My experience is different. From training and teaching, especially people outside the Event Sourcing community, I see that people won’t easily get which events they should select and why. Those are the people I optimise my work for, because I want to make Event Sourcing accessible to them. It’s so different from how they work that they’d rather give up on it. Even the Decider pattern is hard for many of them. In Decider, the business logic is just two functions: decide, which takes a command and the current state and returns new events, and evolve, which builds the state from events (I explained it in How to effectively compose your business logic). That’s already a different way of thinking than loading an entity, changing it and saving it. DCB adds even more heavy lifting: for each command, we also need to define which events to load and why, and that query becomes the consistency boundary.
Let’s see what that means for our enrollment. With streams, the decision loads course:Math and student:Oskar, and whatever is appended to them is inside the box. That’s why StudentEmailChanged caused a conflict. Now, say that the business adds a rule: a suspended student can’t enrol in new courses. We start recording StudentSuspended in the student stream. We still need to handle it in the decision logic, but the enrollment already loads it, and if the suspension happens while the enrollment is processed, the concurrency check catches it. With DCB, we also have to add StudentSuspended to the enrollment query. If we forget, the decision won’t see it, the append condition won’t protect us, and nothing will tell us.
That’s the trade-off. The box isn’t as precise as a query, and it creates conflicts we don’t care about, like the explained email change. But it’s good enough for most typical cases, and we decide on it once per stream type. With DCB, we write a query for each command and decide which event types and tags it must include. We have to think about it every time. That’s good for the important cases, but I don’t see many teams with enough rigour to do it right every time.
I also got asked whether
“which events should I select?”
is the same question as
“why should this be a separate aggregate?”
For me, not really, as I don’t think in aggregates. I think in streams and in where I want my source of truth and consistency, and I decide on that once, not for each command.
I’ve heard that in the Event Modeling community, business people find the query-per-command approach natural and developers need the paradigm shift. That might be true for some people. Still, this is one way to model software systems; maybe it’ll become mainstream someday, but for now it’s a niche. A niche already aligned with event-driven thinking. That’s not the same as saying that it’s easier for newcomers who haven’t worked much with event-driven systems. If DCB became the default, I’m afraid it’d make Event Sourcing harder to adopt outside that niche.
DCB can also align with Vertical Slices. Still, for me, it’s similar to functional programming. When you understand it well and know what you’re doing, it can make a slice simpler and more focused. If you go too far, newcomers won’t understand the code. That’s why I see DCB as a tool for experienced people.
Experience helps you get queries right, but it doesn’t help with events we don’t own. That’s a concern I heard from someone looking at DCB from outside Event Sourcing, and I fully agree. DCB assumes that we own all the events we use in a decision. In most organisations, many teams work on different business domains.
A “transaction” over events that don’t belong to us ends in one of two ways: either we synchronise each change with the other team, which means a longer lead time, or we change things without communication, which means integration bugs. And, as we saw with the order process, once we cross system boundaries, like a payment provider, a courier or microservices that don’t share a database, we still need workflows, process managers or sagas, with or without DCB.
To be fair to DCB, many of the pains that make people look for it don’t come from the stream-based approach itself, but from the limits of the event stores we use. If the event store doesn’t support multi-stream appends, we end up with artificial events like SeatNextToTaken. If it doesn’t provide copy and transform, fixing a wrong stream design means a painful, custom migration. If it doesn’t support archiving or closing the books, streams grow forever, and we end up reaching for snapshots. With such limitations, DCB looks like a way out, and I understand that. Yet those are tooling problems, and we can solve them in the tooling without changing the storage model. That’s why I prefer to enable stream lifetime features like archiving, closing the books, etc. in Emmett first.
There’s also one scenario where I think DCB can be an okayish starting point: migration. Let’s say a team has an audit log of changes but no streams and wants to move toward Event Sourcing.
If you’d like to use Event Sourcing but have an audit log or outbox with messages that carry entity ids, and nobody has grouped them into streams, regrouping all history upfront is a big step. DCB allows them to go step by step:
order:o1 or customer:c1.It’s not perfect. Audit log entries often aren’t proper business events, but records of changed data. They need interpretation first (I wrote about the differences in Is the audit log a proper architecture driver for Event Sourcing? and about moving from relational data in Hitchhiker’s Guide To Moving From Relational Data To Events). And if the team builds the DCB part on its own, it effectively builds its own event store, with all the challenges I’ll describe in the next section. I don’t think they should; building an event store is a big project in itself.
So where does DCB fit? In my opinion, in very specific situations, where creating an additional stream or additional events would be redundant compared to how the business works. That means within one bounded context and one database, and with a team experienced enough to get the queries right. In my experience, those cases are much rarer than they seem. Of course, that also depends on the kinds of problems I was exposed to. And it can be a stepping stone in migrations, as long as it’s not home-brewed.
So far, I’ve talked mostly about modelling concerns, but I’m also building a tool. And that’s where the second group of my reasons comes in.
A general-purpose event store must deliver three things at once: correct consistency under parallel writes, subscriptions with clear ordering guarantees, and decent performance. With streams, each of them has well-known solutions. DCB makes all three harder at the same time.
Let’s start with consistency. In Emmett, as in Marten and SqlStreamStore before it, I keep a streams table. The optimistic concurrency check runs against a single row there: the expected stream position, incremented by one. A unique constraint on stream id and stream position is the last line of defence. Nothing queries the messages table inside the append transaction, so there’s no risk of deadlocks and no need for table locks or serialisable isolation. I also take the global position in the final step, after the concurrency check, which reduces gaps.
A DCB append can’t work the same way. Its condition says: “nothing matching this query was appended after position X”. To check it, the event store has to query events and their tags, instead of a single row. And it has to be atomic with the insert, relative to all parallel appends that could match it. Back to our two requests for student:oskar: both read the same student events, and when both try to append, one of them has to see the other’s event, even though it wasn’t committed when its check started.
The PostgreSQL implementations I’ve seen typically deal with that in one of the following ways:
Mature solutions exist, but most of what I saw were naive. I don’t mean that their authors don’t know what they’re doing. They make the append correct by serialising it, and stop there. This can be fine for a specific project solution, but not for general solution tools. The append (the write model) is the simpler part. Typical problems show up when we start reading what we’ve written.
Subscriptions build read models and integrate different flows and parts of the system. If we’re going into Event Sourcing, we typically do more than run our business logic against events; we embrace the event-driven way.
Subscriptions need to perform well enough to keep up with appends and should give us strong delivery guarantees. Plus, it helps if they deliver events in the order they were committed. All of that is trickier than it seems. For instance, in PostgreSQL, sequence values are taken before the commit, and transactions can commit out of order. A reader can see position 4 before position 3. If it moves its checkpoint to 4, it will never see position 3 when it finally commits. And if the transaction with position 3 is rolled back, there’s a gap that will never be filled. I described that in detail in How Postgres sequences issues can impact your messaging guarantees, and how it affects processing in Checkpointing the message processing.
Subscriptions also need to scale, run in multiple instances without processing the same events twice, and allow rebuilding read models without stopping appends.
Those are the 2nd-day cases you’ll need sooner rather than later. Those are the features where most of my work on Emmett goes (see Consumers, projectors, reactors and all that messaging jazz in Emmett and On rebuilding read models, Dead-Letter Queues and Why Letting Go is Sometimes the Answer). It was the same when I worked on Emmett.
Many DCB implementations I’ve seen don’t provide subscriptions at all, or read events by sequence number, leading to the issues mentioned above. These issues will show up in production as missed events in read models and integrations. Subscriptions also require a different way to handle the schema, since we need to read by types and tags, not just by global position. A general-purpose tool can’t skip any of that.
Then there’s performance. Tag-based designs add a tags table. Each event means extra rows and index entries, and more writes than a regular append. That’s write and read amplification and more storage, which contradicts the premise that DCB is simpler than streams. For the same workload, I expect a regular append to be faster and to scale better. Some argue that filtering by event types lets through appends that would otherwise fail, so DCB could be faster under the same concurrent workload. Maybe; it remains to be seen. That’s why I think that DCB benchmarks should also include a regular stream append as a baseline. Even better, they’d compare implementation categories (DCB, stream-based) across scenarios based on realistic usage, with different concurrency characteristics and setups (e.g., how many stream types or tags we have).
Tags also bring cardinality issues, as tags like student:Oskar are unbounded. High-cardinality indexes put pressure on databases, and retagging makes it worse. If we drop the old tags, we lose auditability. If we keep both old and new ones, cardinality explodes. Fast reads by arbitrary tag combinations at scale are the same problem observability tools are struggling with.
Some approaches index all event data instead of tags, which is closer to the original idea. The cost is that the database must be able to query the payload. We can’t compress, encrypt or serialise it our own way. And it brings back the problem of running a query on each append.
And then there’s what happens on day two, after we deploy. In production, the harder part is living with the data: versioning events, changing queries, retagging, archiving. With streams, we know those challenges and have patterns to deal with them (I gathered my articles on that in the Event versioning guide, starting with Simple patterns for events schema versioning). How would we do it with DCB?
Let’s start with versioning. With streams, an event is read by decisions of a single stream type. If we change its schema, we know where it’s used and can handle it by upcasting when reading. Keeping streams short helps too, as old schemas disappear together with closed and archived streams. With DCB, many queries across many slices can select the same event type. Renaming an event type, splitting it into two, or changing its payload means finding all the queries that name it. Tags make it even trickier. They’re stored and indexed when we append the event. Upcasting can change what we read, but not what the query selects. If the new version of the event should be tagged differently, we’re back to retagging.
Changing queries brings a similar challenge. We saw with StudentSuspended how a new event type can make an existing query incomplete. On day two, that’s not a single case. A query is the consistency boundary of a decision, so changing it changes what the decision is protected against, and each new event type may affect many of them. How will incomplete queries be detected? How will the old queries be invalidated, both logically and technically? I haven’t seen a convincing answer yet.
Then there’s retagging. It’s a migration done on the same storage that keeps taking appends. Until it finishes, queries using the new tag don’t see the old events, so append conditions based on them are weaker than we think, and nothing tells us about it. We need to either stop writes or handle both tag versions for a while.
Finally, archiving. With streams, when the cashier’s shift closes, we can archive its stream, since it won’t be needed for new decisions. With DCB, an event belongs to several boundaries at once. We can archive it only when none of the queries needs it anymore, and the store doesn’t know those queries, as they live in the code.
None of this makes DCB impossible. I’d implement it if it was easy, but it’s really tricky to get right. A single project can take shortcuts that fit its needs. A general-purpose tool can’t, and I don’t want to add a half-baked solution.
If I were exploring this space, I’d try the direction I described earlier: streams as logical boundaries and tags as low-cardinality labels within them, like course-enrollment. Or I’d start with neither streams nor tags, see how far plain queries can take us, and add stream- or tag-like indexes purely as a performance optimisation. Incremental computation, like DBSP, looks promising for making such indexing cheaper, and I’d like to try it.
For now, I prefer to invest my limited time in features that address the root causes behind many DCB requests, or that every production system needs:
So sorry for not bringing good news if you were counting on it. I might add DCB at some point, but for now, I need to finish other things first. The only thing I see that could make it a higher priority is sponsorship.
If there’s something you can do with DCB that you can’t do with classical Event Sourcing in Emmett, please tell me. I’m happy to help find an alternative.
One of the people I discussed it with summed it up nicely: either paradigm can hurt when we make a modelling error; tooling can compensate, but DCB isn’t a way to bypass modelling, and there’s no free lunch. I agree with that. Just like I asked about the question behind the question, I prefer to solve the root cause rather than put a bandage on it.
And, as always, I keep my right to be wrong here. Take it with a pinch of salt: this is my perspective and why I’m focusing on other stuff, not why I tell you not to do DCB at all.
DCB is an interesting concept; I like that it appeared, as it already triggered a lot of good discussions and some innovations. Plus, I love how Sara and Bastian Waidelich are transparently working with the community. I love that they didn’t just throw an idea out, but discussed it, and maintain specifications, example solutions and best practices at: https://dcb.events/
It’s precisely how I see working with the community on the concept.
See also Sara’s latest recording on her findings while adopting DCB. She tackled some of the stuff I discussed:
Cheers!
Oskar
p.s. Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help. You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to Red Cross, Ukraine humanitarian organisation or donate Ambulances for Ukraine.

Rachel Lee Nabors, former React core team member, dives into the architecture and philosophy behind local-first applications, exploring how small language models (SLMs) running directly on-device can deliver privacy-focused, low-latency, and cost-effective alternatives to cloud-hosted LLM wrappers.
By Rachel Lee Nabors