Software that keeps the history of its changes, hand-coded in JavaScript with no framework: you see what the tools hide.
Why this book
On a team that works with events, you hear jargon from day one that nobody ever taught you. A framework does the job in silence, and all is well until the day a message arrives twice or two saves overwrite each other. That day, you find out you never knew what the tool was doing for you.
Implementing DDD, CQRS and Event Sourcing, by Alex Lawrence, attacks the problem from the other end. This self-published Leanpub book (version 1.3.3, April 2021) rebuilds every building block by hand, in JavaScript and Node.js, with no third-party library. A building block is a basic mechanism. Even the database and the message transport rest on plain files. The book covers three methods that often go together:
- DDD (Domain-Driven Design): the code reuses the words and rules of the business, meaning the activity (banking, logistics) that the software serves.
- CQRS (Command Query Responsibility Segregation): the side that writes data and the side that reads it are separate.
- Event Sourcing: you keep the list of everything that happened, rather than only the current state.
Its thesis only arrives in the conclusion, and it tempers everything before it. "Every concept and pattern described in this book should only be applied when it makes sense to do so." (p. 412) It then aims at the frameworks that implement these methods, and admits to having worked on one of them: "Generally speaking, I would not recommend their use and often find them problematic." (p. 412) He adds that "this is only my personal opinion" (p. 413). Writing each building block once is therefore a way to understand, and then to keep only what is useful.
I work in fintech and crypto, in Go, with Event Sourcing. I came to this book with three questions. For a given project, should you separate writing from reading, keep the history, or do none of this? How do you stop a save and the message that follows it from drifting apart? And is it better to write your own building blocks or to take a tool?
The twelve ideas fall into two families, plus two overviews:
- The business in the code (ideas 1 to 5): how do you write business objects that cannot be corrupted, and save them without overwriting someone else's work or losing a message?
- Organize, separate, then keep the history (ideas 6 to 10): how do you organize the user's actions, serve reads fast, and rebuild any state from the list of facts?
- Two overviews (ideas 11 and 12): what this changes for the screen, and how to go to production.
The ideas that stick
1A business object refuses to be born in a wrong state
Before any architecture, the book looks at the smallest objects in the code. Its first example is a floor-plan program, where a desk is written const furnitureWidth = 100, widthUnit = 'cm' (p. 82). Nothing ties 100 to 'cm': you can change one of the two variables without the other.
The first remedy groups what belongs together in a small object, Measurement, which carries a quantity (magnitude) and a unit. This object is immutable: it never changes after it is created. To take the desk from 100 to 120 centimeters, you replace it with a new measurement (p. 84). Addition follows the same rule: it refuses to mix two units and returns a new object instead of touching the old one (p. 87).
plus(measurement) {
if (this.unit !== measurement.unit)
throw new Error('unit mismatch');
const combinedMagnitude =
this.magnitude + measurement.magnitude;
return new Measurement(
{magnitude: combinedMagnitude, unit: this.unit});
}
The book calls this kind of object a Value Object: only its content matters, and nothing tells it apart from another object with the same content. Two measurements of 60 cm are therefore equal, even if they are two distinct objects in memory (p. 85). Since nothing modifies it, it keeps the state that was checked at creation. The book puts it this way: "an object that is initially valid always remains valid. There is no later verification or validation required." (p. 86)
Not everything can stay immutable, though. An address-book contact changes address and stays the same contact. The book calls an Entity an object you track for who it is, not for what it contains: it keeps a stable identity while its values change (p. 88). It recommends an artificial identifier rather than an email, which is unique but not constant (p. 88). An Entity keeps few fields of its own: it manages its identity and its life, and hands the detail to Value Objects (p. 91). To change the address, you replace the whole address rather than editing it field by field.
It remains to say what "wrong" means. The book calls an invariant a business rule that must hold at every moment of an object's life (p. 98). Its example is a workday planner with two rules: an activity's description does not exceed 50 characters, and the remaining hours of the day never drop below zero (p. 99).
const Activity = function({description, requiredHours}) {
if (description.length > 50)
throw new Error('too long description');
Object.freeze(Object.assign(this,
{description, requiredHours}));
};
class WorkdayPlan {
#activities = [];
planActivity(activity) {
if (activity.requiredHours > this.getRemainingHours())
throw new Error('not enough time remaining');
this.#activities.push(activity);
}
// ...
}
The objection: my framework already validates forms, so why one more concept? The book answers with the moment of the check. A validation done afterwards bears on an object that already exists, whereas "an invariant prevents entering such a state in the first place" (p. 99). In the code above, Activity checks at birth, then Object.freeze forbids modifying it. planActivity checks at every change. The #activities list is private: from the outside, no other door lets you add an activity.
In Go, nothing forces you through a constructor, and any package can write Activity{}. The same move works with unexported fields and a function that returns an error. A trap along the way: len() counts bytes, so an accented description hits the limit too early. In PHP, the counterpart is mb_strlen().
func NewActivity(desc string, hours int) (Activity, error) {
// count characters, not bytes
if utf8.RuneCountInString(desc) > 50 {
return Activity{}, errors.New("too long description")
}
return Activity{desc: desc, hours: hours}, nil
}
To remember: a business object protects itself, valid at birth and checked at every change. It must now tell the rest of the code what happens to it, and that is the role of events.
2An event tells a past fact, and it can arrive twice
A well-protected object stays alone in its corner. But the business often asks for chain reactions. On a classified-ads platform, the book adds a paid option: the user picks a keyword, say "chair", and every new classified ad that contains it is added to their list. They then receive an email (p. 123). The classified ad must know neither the lists nor the sending of messages.
The book cuts this link by reversing the direction: the classified ad says what happened to it, and other parts of the code listen. Once its title is checked and its fields are filled, the ClassifiedAd class publishes a ClassifiedAdCreated message. The book insists on the moment of publication: "the event only occurs for successfully created ads, but not for failed attempts" (p. 126).
This message is called a Domain Event, which the rest of this note simply calls an event: a business fact, in the past tense, that the business people consider important. You name it with the subject then the action in the past: login() gives UserLoggedIn (p. 113). A fact is no longer up for debate. The book speaks of "occurrences", facts that have happened, which "can only be acknowledged, but cannot be rejected" (p. 112). The message therefore stays simple: immutable attributes, no functions, with a type, the subject's identifier, the specific data, a unique identifier and often a creation time (p. 114).
To deliver these events, the book codes an event bus: an intermediary where the sender drops an event and where each subscriber receives the types it asked for (p. 118). It sets aside the EventEmitter built into Node.js for one precise reason: it cannot wait for an asynchronous subscriber to finish (p. 119), which the guaranteed delivery below needs (p. 121).
The book admits the theory is easy: "The theory of Domain Events and their distribution is fairly trivial." (p. 120) The difficulty comes from delivery. To lose nothing, you most often need a queue that survives a crash. It can sit at the sender, the bus and the subscriber all at once (p. 120). Each processed item must also be acknowledged (p. 120). I infer the trap: if an acknowledgment gets lost, the message goes out again although it had arrived. The book concludes that "it is technically almost impossible to ensure that every item is delivered exactly once" (p. 121). Most systems promise at-least-once delivery: the event surely arrives, sometimes twice (p. 121).
The book's example makes the duplicate concrete. An order publishes OrderCreated, and a subscriber sends the confirmation email. If the event arrives twice, the customer gets two emails. A filter keeps the identifiers of events already processed and throws away the duplicate (p. 122):
const createSubscriberWithDeDuplication =
originalSubscriber => {
const processedEventIds = [];
return event => {
if (processedEventIds.includes(event.id)) return;
originalSubscriber(event);
processedEventIds.push(event.id);
};
};
This filter only reduces the risk: the identifier is noted after the processing, and a crash between the two lets the duplicate through. The subscriber must therefore stay able to handle duplicates (p. 121), if possible by being idempotent: "processing an event repeatedly must have the same effect as when done once" (p. 122). Setting a stock to 5 is idempotent, adding 5 to the stock is not. The book concedes that this is not always possible (p. 122). I add the confirmation email: a message that has gone out cannot be recalled.
Last trap, the order. Events can arrive in a different order than they were published, especially between two programs or across the network (p. 123). Guaranteeing a global order costs work to the sender, the bus and the subscribers, and the book settles it: "In most cases, the benefits do not justify the associated complexity." (p. 123) When the subscriber needs a sequence, it is up to the subscriber to restore it just before processing (p. 123). The book does not show this move. I infer a lead: the creation time that each event often carries (p. 114) is enough to sort.
Outside the book. The processedEventIds array lives in memory and disappears at restart, at the very moment the redeliveries arrive. The version that holds stores the processed identifier in the database, under a uniqueness constraint that refuses the second pass. It is written in the same SQL transaction as the effect, a block of writes that succeeds entirely or not at all. My article Idempotent CQRS and Event Sourcing: outbox pattern (Part 2) shows it in Go with PostgreSQL.
To remember: an event is a past fact that you do not refuse, and every subscriber must accept receiving it twice or late. It remains to know which objects must change together, and which can wait for an event.
3What you change as one block must stay small
Events link objects, but not all of them can wait. The book shows it through a failure. Two people each add an item to the same shopping list at the same moment. Each loads their copy, adds their item and saves. The second save wipes out the first: "only one of the items is successfully added" (p. 143). This defect is called overwriting: the last write wins, and nobody sees it.
The book's first remedy is a lock: the second person waits until the first has finished, so the additions go through one at a time. The book calls it pessimistic locking, as opposed to the method of idea 4 (p. 175). Nothing gets lost anymore, but execution becomes "almost equivalent to sequential processing" (p. 144). The lock actually protects a set of objects that must change as one block, and the author gives that set a name.
An Aggregate is a group of objects that you modify as one block and save in a single transaction: everything goes through or nothing does, and the invariants stay true (p. 132). You enter it through a single Entity, which serves as the front door, and the Aggregate refuses any change that would break its rules (p. 133). Between two Aggregates, the only link allowed is the other one's identifier: putting one inside the other would not make them transactional, since each is saved in its own transaction (p. 145).
What goes in an Aggregate? When an invariant touches several objects, they share the same Aggregate (p. 145). The book then asks you to question each rule, using a budget of 1000 that receives a rent of 800, then groceries of 250. In the first version, the budget contains its expenses and refuses the groceries, since only 200 is left (p. 148). But an exceeded budget is not a failure of the software, it is information for the user. The second version takes the expenses out of the budget and computes the remainder separately, because "an exceeded budget is not an invalid state but an expected condition" (p. 149). The groceries then go through without error, and the overrun becomes data to display (p. 149).
Once the real rules are found, the book wants Aggregates "as small as possible" (p. 150). The more objects an Aggregate contains, the more likely two people are to modify it at the same time (p. 150). Its example is a calendar: two years at two appointments a day make 1460 entries, heavy to load as one block (p. 151). For the book, though, the risk of conflict matters less than the design flaw (p. 151). Modifying one appointment never touches another, so no conflict is possible and no rule links them: nothing justifies locking them up together (p. 151).
Everything that leaves the Aggregate agrees later, by event. The example is a game server. The server keeps its capacity and the list of its players, and each player keeps their "online" status and their current server (p. 152). These are two Aggregates. When the server accepts a player, it publishes PlayerAddedToServer, and a subscriber then marks the player online (p. 155):
addPlayer(playerId) {
if (this.getLeftSpots() <= 0)
throw new Error('limit reached');
this.#playerIds.push(playerId);
this.#eventBus.publish(new PlayerAddedToServerEvent(
{serverId: this.id, playerId}));
}
This gap has a name, eventual consistency: data read outside the Aggregate may be stale for a short moment. The book takes the example of a bank card: the payment goes through at once, but the online account only shows it later (p. 151). The delay is often acceptable, especially when it is counted in milliseconds, on one condition: "an update mechanism must never be unreliable" (p. 152), meaning without losing an event (idea 5).
Why keep the online status on both sides, at the player and in the server's list? Why not store it on one side only and compute the other on demand? If it lives at the players, finding the free spots of a server means querying all the players. If it lives at the server, knowing whether a player is online means searching all the servers. "Both approaches do not scale well." (p. 153)
To remember: an Aggregate is the smallest zone that must stay correct at every moment, and everything that can wait is synchronized by event. The lock at the start made everyone wait, and the next idea shows the remedy that blocks nobody.
4A version number stops two saves from overwriting each other
The lock of idea 3 makes each transaction wait until the previous ones have finished (p. 144). The book proposes another remedy, housed in the class that saves the objects. This class is called a Repository: it loads an Aggregate by its identifier and saves it, hiding files and tables from the rest of the code (p. 167).
The starting scenario is a meetup with 30 places (p. 181). Two people each book 15 places at the same instant (p. 182): together, they fill the meetup. Both load the same state, add their booking and save. Without a guard, the second save overwrites the first, like the shopping list, and 15 booked places vanish.
Optimistic concurrency settles the case with a number. Each successful save raises the Aggregate's version, and each writer remembers the version it read. Before writing, the Repository rereads the current version, and "Only if they match, the request is processed and the version is incremented." (p. 175) Otherwise the request is rejected: this is a conflict. The method only touches writes, and reading is always allowed (p. 175). I infer the meaning of the word optimistic: you bet that conflict is rare. In the code, an Aggregate that was never saved has no file: .catch(() => ({})) and || 0 give it version 0.
async #verifyVersion(entityToSave) {
const latestEntity = await this.load(entityToSave.id)
.catch(() => ({}));
if ((entityToSave.baseVersion || 0)
!== (latestEntity.baseVersion || 0))
throw new ConcurrencyConflictError(
{entityToSave, latestEntity});
}
What do you do with a refusal? The book gives three answers, from the simplest to the most business-driven:
- Report: the error goes up, and the user tries again.
- Retry: the code reloads and replays, here with ten attempts at most. Three requests of 10 places out of 30 then all go through (p. 183).
- Resolve: the business decides. Out of 20 places, Jim takes 15, which leaves 5. Ben asks for 1 and John for 5 at the same instant. Ben saves first, leaving 4 places. John loses the conflict: instead of refusing him, the code rereads the free places and books him the smaller number between 5 and 4 (pp. 184-185).
The book nevertheless warns that retrying has a cost: "However, it promotes implicit behavior and treats conflicts as expected situations." (p. 182) Conflicts must stay the exception, and frequent conflicts may signal a badly cut Aggregate (p. 182). For the meetup, it refuses to split: all the bookings share one invariant, that the sum never exceeds the capacity, so one single Aggregate, at the price of replayed conflicts: that is the trade-off (p. 183).
Its code also has a limit, which it admits: "this implementation only works correctly when using a single Repository instance per Entity type" (p. 178), meaning one single Repository object in the program. The check rereads the file then writes, in two steps. An in-memory queue, specific to that object, keeps two saves from crossing. A second program started on the same files has its own queue and does not see it.
A transposition outside the book. With a SQL database, the comparison and the write happen in a single query, and the limit disappears whatever the number of servers. If the query touches no row, someone saved in the meantime: that is the conflict.
UPDATE meetup
SET data = $1, version = version + 1
WHERE id = $2 AND version = $3;
To remember: a version read then compared at write time replaces the lock, and the conflict becomes a business decision instead of a silent loss. A successful save must still tell the others, and that is where the next failure hides.
5Save then notify: the event travels with the data
A protected save must still tell the others what it changed, and these two moves never happen in one go. The book starts from a shop: each order publishes an event, and a subscriber sends the confirmation email (p. 185). If you publish before saving and the save fails, the customer receives the confirmation of an order that does not exist. For the book, "something only counts as happened when its associated change is saved" (p. 185).
Publishing after the save is not enough either. The two writes do not form a transaction, and "independent of the order, the latter operation can fail without affecting the first one" (p. 190). If the bus goes down right after, the order is recorded but nobody is notified, and the email never goes out (p. 190).
The book builds the remedy in steps, on another platform, an auction site where the outbid bidder is notified (p. 185). First, the Entity changes role: unlike the game server of idea 3, it no longer publishes anything. It puts its new events in a list, which frees it from any dependency on the bus (p. 188). Then the Repository writes the auction and its events into the same file, in one write that succeeds entirely or not at all (p. 191):
async save(entity) {
const data = {...this.#convertToData(entity),
newDomainEvents: undefined};
const savedEvents =
await this.loadDomainEvents(entity.id);
const allEvents =
savedEvents.concat(entity.newDomainEvents);
const fullData = {...data, domainEvents: allEvents};
await writeFileAtomically(
this.getFilePath(entity.id), JSON.stringify(fullData));
}
Finally, you have to publish from this file. In the first version, the Repository takes care of it itself: after the write, it publishes the new events, then notes the identifier of the last one sent in a small file specific to each auction (p. 192). If the bus goes down, its function publishOverdueDomainEvents picks up everything that follows this identifier (p. 192).
This scheme is called the outbox: with each change, the events join the data in the same storage, in one transaction (p. 190), and their publication is handled separately. With a database, you often store the events to publish in a separate collection. You mark them there once published (p. 193).
The price is known in advance, and it applies to any component that writes a piece of data then, separately, the number of the last item processed. Without a common transaction, a crash between the two writes leaves the number behind, and the event goes out again on the next pass. The book says it of the Repository that publishes: "While the implementation guarantees event publishing, it can lead to duplicates in rare cases." (p. 192) This is the at-least-once delivery of idea 2, hence the idempotent subscriber.
Why not let the Repository publish? For a design reason, this time, and not a transaction one. Publishing from the Repository makes it depend on a publication mechanism and weighs down its role (p. 188). The Repository therefore just provides the saved events (p. 194). A separate component, DomainEventPublisher, takes over the publication with the same tracking of the last identifier. Its function publishDueDomainEvents also serves to catch up after an outage (pp. 194-195).
To remember: what must be published is saved with the data, then leaves from there, even if it leaves twice. It remains to organize the user actions that set these objects in motion.
6A user action takes three moves: load, ask the business, save
The previous ideas gave objects, events and Repositories. Missing is the piece that sets everything in motion when an HTTP request arrives. The book gives this role to a thin layer of application services. Each one serves a single use case, meaning an action the user can ask for (p. 208). Its example is a notepad (p. 211):
async updateNoteContent({noteId, content}) {
const note = await this.#noteRepository.load(noteId);
note.content = content;
await this.#noteRepository.save(note);
}
This code assigns note.content without any check, whereas idea 1 protected its objects. It is not a counterexample: the book took here a note with no business rule, with free title and content (p. 210). Its behavior therefore boils down to an assignment. An Entity with rules would call a method like planActivity (idea 1).
The book sums up this move: "The standard procedure consists of loading an Entity, executing behavior and saving the resulting change." (p. 212) The rule that goes with it forbids any business logic in this layer: it "must not contain behavior or maintain state that belongs to the Domain Model" (p. 208). The book splits the roles like this: the domain (the objects of ideas 1 to 3) carries the rules. The application layer protects it by acting as a facade, the front door that delegates (p. 208). I draw the benefit from it: the rules live in one place. A practical detail completes the rule: the service receives the identifier of the object to create instead of generating it (p. 209). The book sees in this a more predictable behavior, which allows an idempotent procedure (p. 209). Replayed, the same request aims at the same object instead of creating a second one.
The scheme gets complicated when an action touches two Aggregates. On the classified-ads platform, each account can publish three classified ads at most, and publishing takes three steps: create the ad, add its identifier to the seller's account, send a confirmation (p. 215). The book calls a process a use case made of several steps. Each has its own transaction, since an Aggregate is saved in a single one (idea 3, p. 214). It tries three versions:
- One single service does everything. It is simple, but a failure between two steps leaves a classified ad that the account does not know. The book says so: "a service interruption can cause this in-between state to exist permanently" (p. 218).
- One service per step, chained by the caller. Each step is safe, but the chaining rests on the caller, who can also be interrupted in the middle (p. 219).
- Events link the steps. The service creates the classified ad and stops there. The
ClassifiedAdCreatedevent triggers the addition to the account, which in turn publishes the event that triggers the confirmation (pp. 219-222).
The third version gains in robustness, and the book says what it costs. If delivery is guaranteed (idea 5), the account always ends up updated, and the state never stays wrong. The system nevertheless remains visible for a moment in the intermediate state (p. 222). A risk remains: the three-ad rule relies on the account, updated later, and a delay can let a fourth classified ad through (p. 222). The book suggests that a subscriber of ClassifiedAdCreated run a corrective action if the limit is exceeded, without saying which (p. 222). Here, the service only launches the first step, then each step reacts to the previous one with no central component. The book does not file this example in a box, but its definition of choreography fits: each part reacts to a trigger and does a single step (p. 215). The orchestrator, a central component that tracks progress, mostly makes sense in its view for long processes or ones with several paths (p. 215).
Last tool of the layer: what concerns all the services at once, like logs, validation or security. Written into each service, this code would "scatter across a software" (p. 223). The book therefore wraps it around the services, as a middleware: a wrapper that runs before and after each call (p. 223). After logging and validation, its last example reuses the retry of idea 4. A single wrapper replays any call that fails on a version conflict, here with five attempts at most and a 15-millisecond wait (p. 230):
const createRetryMiddleware = ({retries}) =>
async ({originalFunction, args, next}) => {
while (retries-- > 0)
try {
return await next({originalFunction, args});
} catch (error) {
const isConflict =
error.message === 'ConcurrencyConflict';
if (!isConflict || retries <= 0) throw error;
await timeout(15);
}
};
In Go, the same wrapper is written like a net/http middleware, a function that receives the next service and returns an enriched service. In PHP, Symfony Messenger stores these wrappers in the configuration of its message bus. In both cases, the retry must only target the conflict error, never a business rule that was refused.
To remember: an application service loads, asks the business, saves, and decides nothing itself. When an action touches several Aggregates, events chain the steps, at the price of a visible intermediate state. All this path serves writing. Reading remains, and it has other needs.
7Writing and reading do not have the same needs
So far, the same object serves everything: you load it to modify it, and you also load it to display it. The book shows where this gets stuck with customer reviews of a laptop, rated from 0 to 5 (p. 255). To display a product's average rating, the naive version loads all the reviews, one file per review, and computes the average at each display. The book notes: "While this implementation works, it has a poor performance, as it requires to always load all reviews for a product." (p. 258)
The separated version keeps beside it a small sheet per product, with its ratings and their average already computed (p. 259). Each new review updates it. To display the average, "For determining the average rating, only a single file must be loaded." (p. 260)
async addOrUpdateRating({productId, reviewId, rating}) {
const product = await this.#storage.load(productId);
product.ratings[reviewId] = rating;
product.averageRating = ratingCalculator
.getAverageRating(Object.values(product.ratings));
await this.#storage.save(productId, product);
}
This split is the CQRS of the introduction. According to the book, the two sides can differ in their data, their availability, their consistency, their performance and the complexity of their code (p. 251). The example shows performance, and what follows shows shaped data then a tolerated delay. I add a reading of my own: with no business rule, the code on the reading side stays simple. In general, the book applies CQRS to a chosen zone of the software, not to the whole system (p. 251).
The Write Model is the Aggregate of idea 3, from which you keep only the operations that modify: it protects the invariants, always on the current state (p. 254). The Read Model is data already shaped for a screen, with no business rule (p. 255). The ratings sheet above is one.
The objection: two models is overcomplicating. The book answers that many projects already do it without knowing: "Even the sole use of a caching mechanism can be interpreted as some form of CQRS." (p. 253) It adds that a large platform that displays its number of users surely does not compute it by querying its production database (p. 253).
It remains to keep the Read Model up to date. The book compares three ways, on songs that users like or no longer like. The Read Model must give the most liked (p. 261):
- The write side itself calls the read side after each change. It is fast, but writing then depends on reading, and nothing guarantees that the call succeeds (pp. 263-265).
- The read side recomputes on demand. Each request for the ranking walks through all the songs, and a cache keeps the answer for one second. It is simple too, but reading stays slow, so it does not hold with many songs (p. 267).
- The write side publishes an event, the read side subscribes to it. The book judges it the strongest: "Overall, this approach is the most powerful variant." (p. 269) The price is extra complexity (p. 268).
This separation has a visible consequence: the Read Model is eventually consistent (idea 3). "While the update process may take time, it should commonly complete within seconds." (p. 261) The screen must own this delay, because right after a click it can reread stale data. The book cites two remedies: display for a moment what the user has just sent, or notify the screen when the data changes (p. 261). Idea 11 comes back to it.
The split finally brings a vocabulary. What the user asks to do is called a command (LikePost), and what they ask to read a query (pp. 270-273). A command and an event look alike, and the book sets them against each other. In the book's words, "a Command is an instruction to do something that is phrased in the present tense" (p. 273). It is addressed to a single recipient who can accept or refuse it. An event describes a past fact, broadcast to everyone, which can only be acknowledged (p. 273). The code that receives a command is called a handler, an application service of idea 6 (p. 274). It should return nothing, except a simple status code (p. 278), because reading is the business of another handler (p. 274).
To remember: separating writing from reading lets each side have the shape that suits it, provided you accept a delay that the screen must handle. The write side still keeps the current state of each Aggregate. The next idea proposes keeping only its history.
8You keep the history, and the state is only a computation
Event Sourcing keeps only the history. A bank statement already works this way: the bank keeps each operation, and the balance is only their sum. The book defines Event Sourcing as "an architectural pattern where state is represented as a sequence of change events" (p. 299). Events are immutable and are added at the end of a log, a list where you only write at the end, never modifying or deleting anything. The current state is recomputed on demand, and the book holds it as transient: you can throw it away and rebuild it (p. 299).
Its example is a support ticket system. You open a ticket, you comment on it, you close it, and commenting on a closed ticket reopens it (p. 304). For its examples, the book chooses the function style (p. 310): a function receives the state and an event, then returns the new state. You pass it each event in order (p. 309):
const applySupportTicketEvents =
(state = {}, events = []) =>
events.reduce(applyEvent, state);
const applyEvent = (state, {type, data}) => {
if (type === SupportTicketOpenedEvent.type)
return {...state, id: data.ticketId, isOpen: true};
if (type === SupportTicketClosedEvent.type)
return {...state, isOpen: false};
if (type === SupportTicketReOpenedEvent.type)
return {...state, isOpen: true};
return {...state};
};
A behavior, for its part, modifies nothing. It reads the state and returns the events to add. Commenting on a closed ticket produces two, a reopening then the comment, and that is why each behavior returns a list (p. 309):
const commentSupportTicket =
(state, {authorId, message = ''}) => {
const ticketId = state.id;
const events = [];
if (!state.isOpen) events.push(
new SupportTicketReOpenedEvent({ticketId}));
events.push(new SupportTicketCommentedEvent(
{ticketId, authorId, message}));
return events;
};
The book draws three requirements from this for the Write Model. Each Aggregate must know how to rebuild its state from a list of events. Its behaviors never modify the state directly, they produce events that describe the change. Finally, these new events must leave the Aggregate to be saved (p. 303).
Why impose this on yourself? For the author, the clearest advantage plays out on the reading side: the history of events lets you derive more than the state: "historical event logs allow to derive specialized data for read-related scenarios, even retroactively" (p. 299). A question that nobody asked yesterday finds its answer in events already written. A separate read side is therefore needed, and the book is blunt: "Event Sourcing can only be applied adequately together with some form of CQRS." (p. 299)
A trap lurks, though. The events stored in the history are fine-grained, and some only make sense in their context, the part of the software that produces them: "Exposing them directly risks to couple external functionalities to internal details." (p. 301) The book then specifies that it only calls Domain Events the public events, meant for the outside (p. 302). The chapter on events (idea 2) nevertheless used the word more broadly. A dedicated mechanism translates them and drops the private ones along the way (p. 301). On a video platform, adding and removing a subscription are public, because the channel owner must be told. The notification setting stays private (p. 302).
The book explains two styles for writing this model, object and functions, plus a variant. It refuses to pick one: "Still, there is no universal argument for either of the approaches." (p. 304) The price, for its part, is stated plainly: a more complex design and code (p. 303).
Outside the book. A stored state with a log beside it makes two copies, which can diverge if some code writes the state without the event. To audit only, keep the state as the reference and add an audit log. If the history must be authoritative or feed future views, Event Sourcing is worth its price.
A common practice, outside the book. Replaying the whole history at each load ends up costing a lot once an Aggregate piles up thousands of events. The usual remedy is the snapshot: from time to time you store the computed state with the number of the last event included, then you only replay what follows. Measure before adding it, because a snapshot is one more piece of data to keep correct when the events change shape.
To remember: in Event Sourcing, the truth is the list of facts, and the state is recomputed by replaying it. This list must now live somewhere, and the book shows that it takes very little to store it.
9An event database fits in a few dozen lines
Products specialized in storing events are impressive. The book demystifies them by writing its own. It calls stream the sequence of events of a single Aggregate, and Event Store the database that keeps these streams. Its minimum fits in one sentence: "At a minimum, an Event Store must provide one operation for reading a stream and another one for appending new events." (p. 311) With it, the Repositories of idea 4 become useless (p. 310), since each stream contains the history from which an Aggregate's state is derived (idea 8).
Its version runs on files: one folder per stream, one numbered file per event, and a _version file that says where the stream ends (pp. 311-312). For a warehouse good, it looks like this:
good/good-id/
1.json
2.json
3.json
_version (contains 3)
The append reads easily. The code reads the version, numbers each new event in sequence, writes each file, and only then updates the version (pp. 311-312):
async save(streamId, events) {
const dir = `${this.storageDirectory}/${streamId}`;
await mkdir(dir, {recursive: true});
const currentVersion = await this.#getStreamVersion(dir);
await Promise.all(events.map((event, index) => {
const number = currentVersion + index + 1;
return writeFileAtomically(`${dir}/${number}.json`,
JSON.stringify({...event, number}));
}));
const newVersion = currentVersion + events.length;
await writeFile(`${dir}/_version`, `${newVersion}`);
}
The detail that matters is the order of the writes. Reading ignores any file beyond the version. So if the server goes down in the middle of a batch, the files already written stay invisible, and the stream stays clean. The book says it: "the save operation can fail in between the persistence of multiple events without producing corrupted state" (p. 312).
The optimistic concurrency of idea 4 comes back more simply: the writer passes the version it read, expectedVersion, and the store refuses to write if the stream has moved (p. 314), with the same limit of a single instance:
const currentVersion = await this.#getStreamVersion(dir);
if (expectedVersion != null
&& expectedVersion !== currentVersion)
throw new StreamVersionMismatchError(
{expectedVersion, currentVersion});
It remains to feed the read side. The store offers a subscription: a subscriber receives the events of a stream from a given version, then those that arrive afterwards, without polling the store in a loop (pp. 315-316). To follow all the Aggregates at once, the store copies each event into a global stream, named $global (p. 318). It refuses to promise an order there between different streams (p. 318). The store notes the last copied event in a version file, and meets again the price of the outbox (idea 5): an event can appear twice (p. 319).
The book finally draws a clear border. These subscriptions serve inside the context that owns the store, never to broadcast events across the whole system (p. 316). To talk to the outside, a dedicated subscriber reads the global stream. It only publishes the public types: this is the mechanism of idea 8 (p. 329).
Database version, outside the book. In PostgreSQL, a uniqueness constraint on the stream and version pair does the job of expectedVersion. Two writers who read version 3 each insert a row of version 4. The database refuses the second, whatever the number of servers. My article CQRS in Go, part 4: PostgreSQL as an event store details the table and the subscriptions.
CREATE TABLE events (
stream_id text NOT NULL,
version bigint NOT NULL,
type text NOT NULL,
data jsonb NOT NULL,
UNIQUE (stream_id, version)
);
To remember: an Event Store means appending at the end, checking the expected version and notifying the subscribers. These subscriptions serve to build the screens, and that is the role of projections.
10A read view is computed by replaying the events
Once the history is stored, you have to draw screens from it. The book calls projection the component that takes care of it: "Read Model Projections are components that consume event streams and compute derived read data." (p. 320) It distinguishes two kinds, on-demand projections and continuous projections (p. 320).
The on-demand projection computes at the moment of the question. The example is a player's play time. Its stream contains "started playing" and "stopped" events, each with its time (p. 321). To know how long they played, the projection loads the stream and adds up the gaps between each start and each stop (p. 322):
let result = 0, lastStart = 0;
const {events} =
await this.#eventStore.load(`player/${playerId}`);
events.forEach(({data, type}) => {
if (type === PlayerStartedPlayingGameEvent.type)
lastStart = data.time;
if (type === PlayerStoppedPlayingEvent.type)
result += data.time - lastStart;
});
return result;
The result is kept nowhere and is recomputed at each question. The cost therefore grows with the number of events (p. 323). The book accepts it here: "this is negligible as the playtime is likely to be requested infrequently" (p. 323).
Data that is read all the time is computed in advance. The continuous projection subscribes to the global stream and updates its Read Model at each event (p. 323). The example is a warehouse stock. A function receives the old state and an event, and returns the new state (p. 324):
const updateStock = (state, {type, data}) => {
if (type === GoodRegisteredEvent.type)
return {...state, goodId: data.goodId,
title: data.title, stock: 0};
if (type === GoodStockedEvent.type)
return {...state, stock: state.stock + data.units};
if (type === GoodDestockedEvent.type)
return {...state, stock: state.stock - data.units};
return {...state};
};
The book notes that it looks like the applyEvent function of the write side (p. 324). It is the same move, serving the screen rather than the rules. After 100 units in and 95 units out, the projection shows five units (p. 325). This data is eventually consistent (idea 3), so sometimes behind: the book reserves it for information that commits to nothing, like an availability shown in a shop (p. 323).
Where does the result live? In memory, the Read Model is lost at restart and must be rebuilt by replaying the events (p. 325). For a warehouse of millions of products, "recreating Read Models on every restart is not feasible" (p. 326). The persistent version therefore stores the stock and, beside it, the number of the last processed event, then starts again from there at restart (p. 326). These two writes form the outbox case (idea 5): without a common transaction, an event can come back at restart (p. 325).
Without a common transaction, the book generally asks for idempotent transformations (p. 325), but updateStock adds up. Replayed on the same event, it counts the units twice (idea 2: set to 5, or add 5). In a SQL database, write the stock and the number of the last processed event in the same transaction. Then ignore any event whose number has already passed.
A deduction, not a sentence of the book. A new projection, with no recorded number, starts again from the very first event (p. 326). To add an unforeseen screen, you write one more projection and let it catch up with the past.
To remember: a read view is only a computation over the events, done on demand when it is rarely read, in advance when it is read often. Two broader questions remain: what this changes for the screen, and how to leave the files.
11A screen designed around tasks speaks the language of commands (overview)
The screen is the last link. Many admin interfaces are editing forms: you open a record, change fields, confirm. The book calls this a CRUD interface (create, read, update, delete). It sets against it the task-based interface, where each interaction that matters triggers a precise use case (p. 364). On its example blog, publishing and unpublishing a post go through a button that sends the PublishBlogPost or UnpublishBlogPost command (p. 368). With a simple form, the book warns, "Changing the publishing status of a post would be represented as generic record modification, without adequately capturing the domain-specific meaning." (p. 368)
For the delay of idea 7, the book adds two tools. The optimistic interface shows success before the server's answer, and offers to retry in case of failure (pp. 369-372). Its example is writing a message in a social network. Reactive Read Models deliver not only a piece of data but also its future updates (p. 372). Here, they push them to the browser through Server-Sent Events (SSE). This is a long, one-way HTTP connection that reconnects by itself (p. 374). On a shared to-do list, the book compares three ways to choose what you push to the browser at each change: the events themselves, the whole model, or only the part that changed (pp. 381-387). It concludes that "none of them can be recommended in general" (p. 388).
To remember: one button per intention rather than a generic form, and the screen sends the business's commands directly. It remains to plug all this into real tools.
12Home-made building blocks are replaced with few adjustments (overview)
Since the introduction, everything has rested on files: can you put JSON files in production? Most often no, answers the book in appendix B (p. 430). In production, "most of the infrastructural functionalities should be powered by existing technologies" (p. 430), plugged in behind interfaces that look like those of the file-based versions (p. 430), like the Repository of idea 4.
Its example application is a task board for remote teams. It moves to EventStoreDB for events, Redis for Read Models and RabbitMQ for the bus. NGINX, in front, sends the write requests one way and the rest the other (pp. 433-443). The whole thing runs in 14 Docker containers, those isolated boxes that each carry a program and what it needs (p. 440). The application has three independent contexts, in the sense of idea 8. Each has four containers (write, read, EventStoreDB, Redis), plus RabbitMQ and NGINX in common. The existing code "requires only minor adjustments" (p. 440), mostly in the command handlers and the event publisher (p. 440). I read in this the sign that the borders of the previous ideas were in the right place. The book stays honest: EventStoreDB does not have exactly the same interface as the file-based version, its versions starting at zero for example (p. 435). It adds that PostgreSQL or MongoDB suit too, because an Event Store mostly needs transactions and a notification of changes (p. 433).
To remember: the interfaces of ideas 4 and 9 are the replacement point, you change the tool behind and the business stays.
My honest take
A workshop book, not a showcase book, and it answers the three questions from the start. Should you separate writing from reading, or keep the history? Only where it pays, never by default. How do you stop a save and its message from drifting apart? With the outbox of idea 5, and subscribers that tolerate duplicates. Should you write your own building blocks? Yes to understand, then a real product takes over behind the same interfaces (idea 12).
Its strength: each building block is built before your eyes, in few lines, and the author says where it breaks. He admits the Repository limited to a single instance (idea 4) and the outbox that can publish twice (idea 5). The reader thus knows where to reinforce the code before production. When you already practice these architectures, you quickly see what carries over to Go and what stays specific to JavaScript. The mechanisms carry over as they are. The in-memory queues (idea 4) and Object.freeze (idea 1) are Node.js scenery.
Two reservations. The prose is dry, and each section changes example: meetup, auctions, songs, warehouse, tickets. You relearn a small business every five pages, and reading in one sitting tires you out. Then, the limit of a single instance per Repository comes back with the home-made Event Store (pp. 178, 314). With several servers on the same database, the guard against overwriting must therefore move into the database. You write it yourself, or you hand it to a real Event Store as in appendix B. The "My reading" boxes of ideas 2, 4 and 9 give the first steps.
My advice: skip chapters 1 to 5 of the book (their number is not the one of the ideas) if you have read Evans or Khononov, presented below. Read chapters 6 to 12 with a keyboard within reach, then appendix B (production, idea 12). If you aim at several programs, chapter 13 cuts the application into separate programs that talk to each other. Otherwise, skim it, and keep 14 (the interface, idea 11) if you touch the front end.
Odilon
Still relevant in 2026?
The substance has not moved: small Aggregates, a version compared at write time, outbox, at-least-once delivery and projections remain the daily bread of these architectures. The Server-Sent Events of idea 11 remain a browser standard (the EventSource object). What has changed mostly touches the production appendix:
- EventStoreDB is now called KurrentDB. The vendor Event Store became Kurrent at the end of 2024, and version 25.0 is the first under the new name. Look for the documentation under that name, and expect a few gaps with the book's code (Kurrent FAQ).
- The Docker images are frozen in 2021 (Redis 6.0.10, RabbitMQ 3.8.11, NGINX 1.19.6, EventStoreDB 20.10.0), and the
docker-composecommand is now writtendocker compose, with no hyphen. - The book's home-made identifier generator relies on
Math.random()(p. 90), and the book itself replaces it with theuuidpackage in the appendix (p. 431). Node.js now providescrypto.randomUUID()with no dependency.
For whom?
Read it if
- you are joining a team that talks about Aggregates, projections and outbox, and you want to see what these words really do
- you use an Event Sourcing framework and want to know what it does for you
- you read JavaScript without trouble, even if you code in PHP or Go
- you prefer typing code to understand rather than reading diagrams.
Skip it if
- you are after modeling the business with the experts, more than the code: Evans or Khononov devote themselves to that
- you want a turnkey production recipe, with a SQL database and several servers
- long listings with no types put you off.
Going further
- Domain-Driven Design, by Eric Evans: the origin of Value Objects, Entities and Aggregates, on the design side.
- Learning Domain-Driven Design, by Vlad Khononov: the same patterns explained through reasoning, with the outbox, CQRS and Event Sourcing.
- Understanding Eventsourcing, by Martin Dilger: starting from event modeling rather than from the code.
- Exploring CQRS and Event Sourcing: the story of a real CQRS project, with its mistakes.
- Event-Driven Architecture in Golang, by Michael Stack: the same building blocks in Go, with the outbox and orchestration.
- Clean Architecture, by Robert C. Martin: the dependency rule that explains why the business does not move in idea 12.
- CQRS in Go: Aggregate, Transition() and Clone(): the event-sourced Aggregate of idea 8, written in Go.
- CQRS in Go, part 3: sagas and event choreography: the processes of idea 6 when they get longer.
- Best Event Sourcing and CQRS Books, and the Order to Read Them: where to place each of these books.
Comments (0)