Commands Aren't Just Events in Reverse¶
BorrowBook and BookBorrowed. ReserveSeat and SeatReserved. AcquireBook and BookAcquired. The first half is what someone wants, the second half is what happened, and the only visible difference is the tense. Most diagrams in most Event Sourcing tutorials draw the two as a pair, and after enough examples, your brain quietly starts assuming they always will be.
That picture gets people started, and it isn't wrong, exactly. But the more real your system becomes, the less it matches the shape of what's happening underneath. Commands and events look symmetric on the surface, and they're not. They're not symmetric in whether they can fail. They're not symmetric in who they speak to. They're not even symmetric in number. Treating them as if they were is one of the quietest, most expensive mistakes we see, because the code keeps working long enough for the asymmetry to set in everywhere before anyone notices.
The Picture That Looks Right¶
The reason this misconception is so durable is that it's incredibly useful at the beginning. When you teach Event Sourcing to someone, you reach for the simplest possible example. A book gets borrowed. A seat gets reserved. A payment gets made. In all of those, the command and the event line up perfectly. BorrowBook produces BookBorrowed. ReserveSeat produces SeatReserved. The shapes match, the names rhyme, and the diagram fits on a slide.
Tutorials lean on this for the same reason that physics lectures start with frictionless planes. The simplification gets the idea across. The trouble is that frictionless planes don't exist, and neither do systems where every command produces exactly one event of exactly the right name. The first time you build something for real, friction shows up everywhere, and the symmetric picture quietly stops paying its rent.
The tense trick reinforces the illusion. A command in imperative form, an event in past tense. BorrowBook becomes BookBorrowed, and it feels like the only thing that changed is when. That feeling is the whole bug. The shift from intent to fact is not a tense change. It's a transition across three different dimensions at once, and we'll look at each of them.
One Can Fail, the Other Cannot¶
The first asymmetry is the one most developers eventually feel, even if they never name it. Commands can be rejected. Events cannot. A command is a request that the system is allowed to refuse. An event is a record of something that already happened, and nothing can unsay it.
This is why one of the most common anti-patterns we see is CommandFailed sitting in the event store. Somebody hit a validation error, somebody sent a bad payload, somebody triggered a rate limit. Now there's an "event" in the log that doesn't describe anything that happened in the business. It describes that the system failed to accept a request. That's not a domain fact. That's infrastructure noise that found its way into the wrong place.
The rule needs a sharper edge, though, because not every failure is noise. Some failures are facts about the business, and they deserve to be remembered. When a card payment is declined, that's not the system being broken. The cardholder has to be told, the merchant has to react, the risk model has to learn. PaymentDeclined is a perfectly legitimate event because it's something the domain itself cares about. The same goes for AccessDenied when someone tries to enter a building they shouldn't, or LoanRefused when underwriting says no.
The distinction is between business failure and technical failure. Business failures are part of the story your system is telling, and they belong in the event log alongside the successes. Technical failures are signals about the system itself, and they belong in logs, metrics, or error responses, not in the store. If the failure tells a business story, it's an event. If it's just the system arguing with itself, it isn't.
One Has an Addressee, the Other Has an Audience¶
The second asymmetry is about direction. A command is sent to someone. There is a specific recipient, usually a specific Aggregate, that's expected to handle it and produce a specific outcome. The sender knows who they're talking to. The receiver knows what they're being asked. The conversation is one-to-one.
Events are not like that at all. An event is published, not sent. It's broadcast into the world for anyone who finds it interesting. Sometimes nobody is listening. Sometimes one projection is listening. Sometimes five different Bounded Contexts are listening, each interpreting it for their own purposes. The point is that the publisher doesn't know and shouldn't care who the audience is.
This is more than a routing detail. It's a structural property that shapes how systems compose. Commands belong to a Bounded Context because they encode an intent that's specific to that context. Events leave a Bounded Context because they describe facts the rest of the system might need. When teams forget this, they start letting commands travel across context boundaries, and what feels like flexibility very quickly becomes coupling that can't be undone.
Commands are how a context talks to itself. Events are how it talks to the world. That single sentence has unmade more architectural mistakes for us than almost anything else. The moment you treat a command as something a foreign context can send, the two contexts share an assumption about what's valid and what isn't, which is exactly the coupling that Bounded Contexts are supposed to prevent.
The Cardinality Lie¶
If the first two asymmetries are about what commands and events are, the third one is about how many of them there are. The textbook picture suggests a clean one-to-one correspondence: one command in, one event out. Reality is messier and more interesting in every direction.
A single command can produce zero events when it gets rejected on business grounds and the rejection itself isn't worth recording. It can produce exactly one event in the boring lucky case. And it can produce many. A ConfirmBooking command might trigger SeatHeld, PaymentAuthorized, BookingConfirmed, and NotificationQueued. These are four distinct facts, each meaningful on its own, and none can be collapsed into the others without losing information.
Going the other direction, events also break the simple count. A single event can be the result of no command at all, when an external source triggers it: a sensor reading, a scheduled clock tick, a message from another system. It can be the result of one command, in the easy case. It can also be the joint outcome of many commands working through a saga or a process manager, where it only becomes true once a series of earlier decisions has lined up.
Once you see this, the "reverse" metaphor doesn't just stop being symmetric. It stops making sense as a metaphor at all. There is no one-to-one map between commands and events to begin with, so reversing it was never going to work. There's a flow, and the flow branches and joins in ways that the symmetric picture has no way to express.
What Breaks When You Treat Them as Symmetric¶
The symptoms of getting this wrong are easy to spot once you know what to look for. The first one we already mentioned: the event store starts collecting things that aren't really events. Names like ValidationFailed, RequestRejected, BadInput. They look like events because they're written in past tense, but they describe the system's troubles, not the domain's history. A year later, the event store reads less like a logbook and more like a complaint folder.
The second symptom shows up downstream. Read models start growing validation logic they have no business carrying. Because the producing side kept its events too coupled to commands, the consuming side has to second-guess every event it receives to figure out whether it was the "real" one or some artifact of how the command was processed. The clean separation Event Sourcing is supposed to give you starts leaking in both directions.
The third symptom is the most expensive, and it lives between Bounded Contexts. When teams treat commands as if they were just the active voice of an event, they start letting one context send commands directly into another. It feels natural. The receiver "is supposed to do this thing", after all. But it means the sending context now knows about the receiving context's invariants, its naming, and its versioning. A coupling that should have been a thin layer of published facts becomes a thick layer of shared rules. When one context wants to change those rules, everyone has to agree first.
Schema versioning is where the bill arrives. Commands and their "matching" events get versioned together, because they're treated as two faces of the same thing. Every change becomes a coordinated migration on both sides at once. By the time you notice, the symmetry assumption is welded into so many places that pulling it apart looks like a project of its own.
Designing for the Asymmetry¶
The fix isn't subtle. Once you accept that commands and events live in different worlds, the design choices become almost obvious. Commands are allowed to say no. Build that into the type system and the API: a command handler returns either a list of events or a domain-level rejection, and the rejection carries a real reason that someone outside the engineering team can understand. Don't pretend the rejection is an event, and don't pretend it never happened.
Events are allowed to be ignored. Publish them for whoever cares. Don't reach out to specific consumers, don't shape an event to fit a known reader, and don't pull "the right one" out of the store based on what some downstream system is going to do with it. Naming helps here: well-chosen event names describe domain occurrences, not technical operations, and that alone removes a lot of the temptation to make events serve specific consumers.
A useful test we apply ourselves is this: if the truth of an "event" depends on whether some attempt succeeded, it isn't an event, it's a command result. Push it back across the boundary. Hand it to the caller. Don't put it in the store. The store is for what the business considers to have happened, and the caller's success or failure to convince the business is a different kind of thing entirely.
EventSourcingDB enforces a lot of this by design. It only stores events. It doesn't know what a command is, and it has no opinions on whether one succeeded or failed. That seems like a limitation at first, until you realize it's exactly the line the asymmetry draws in the first place. The thing that gets stored is the thing the business has agreed is now true. Everything else is mechanism, and mechanism doesn't belong in the log.
Two Shapes, One Flow¶
The reason this asymmetry matters isn't theoretical. It's that systems take the shape of the words they're built out of. When you treat commands and events as mirror images, your architecture starts mirroring them too: contexts that know each other's commands, stores that record requests instead of facts, read models that revalidate what should already be settled. The symmetry was an illusion, but the code took it seriously, and the code is now the thing you have to live with.
Treat them as different shapes, and the architecture relaxes. Commands stay inside the context that owns the decision. Events flow outward, picked up by anyone who can use them. Failure becomes a first-class part of the design without polluting the log of what actually happened. The flow is one shape; the commands and events along the way are different shapes; and the system tells a cleaner story because of it.
If you'd like to walk through the broader picture this fits into, the CQRS without the complexity post is the natural next stop. And if you're thinking through how this lands in your own system and want a second pair of eyes on a specific design, we're always happy to talk. Drop us a line at hello@thenativeweb.io.