Resources / Blog / ArchitectureKit: DDD, CQRS, and Event Sourcing for Go
AnnouncementSeptember 28, 202613 min read

ArchitectureKit: DDD, CQRS, and Event Sourcing for Go

Last week, we released ArchitectureKit: building blocks for applications based on Domain-Driven Design, CQRS, and Event Sourcing, in Go and on top of EventSourcingDB, open source under the MIT license. If you have been following us for a while, you may remember wolkenkit, our framework for event-sourced applications. ArchitectureKit is the first thing in that tradition we have released since.

It continues that tradition, but as a kit rather than a framework: every piece can be used on its own or left alone, and nothing happens behind your back. What makes it worth a closer look is how it handles the two places where event-sourced applications easily end up relying on luck: two concurrent writes to the same thing on the way in, and a read right after a write on the way out. The easiest way to see both is to follow a single loan, from the request that asks for a book to the moment the catalog shows it as borrowed.

A Command Knows What It Needs#

Our example is a library, and a reader wants to borrow a book. What happens to a book is recorded as events, each a struct that names its own event type, and every event is filed under a subject: the path that says which thing it is about, such as /books/42. A subject scheme builds that path from a book's ID:

type BookAcquired struct {
	Title string `json:"title"`
}

func (BookAcquired) EventType() string { return "io.eventsourcingdb.library.book-acquired" }

type BookBorrowed struct {
	ReaderID string `json:"readerId"`
}

func (BookBorrowed) EventType() string { return "io.eventsourcingdb.library.book-borrowed" }

type BookReturned struct {
	ReaderID string `json:"readerId"`
}

func (BookReturned) EventType() string { return "io.eventsourcingdb.library.book-returned" }

var BookSubject = architecturekit.NewSubjectScheme("/books/{book}")

The reader's wish becomes a command, and a command knows two things: which subject it acts on, and under which conditions its events may be written. The preconditions come from eventsourcingdb, the package of the Go client:

type BorrowBook struct {
	BookID          string
	ReaderID        string
	ExpectedEventID string
}

func (c BorrowBook) Subject() string { return BookSubject.Build(c.BookID) }

func (c BorrowBook) Preconditions() []eventsourcingdb.Precondition {
	return []eventsourcingdb.Precondition{
		eventsourcingdb.NewIsSubjectOnEventIDPrecondition(c.Subject(), c.ExpectedEventID),
	}
}

The kit adds no preconditions of its own. Whatever guards a write is declared by the command and built from its fields. That makes optimistic concurrency, uniqueness, and idempotency one and the same mechanism: borrowing checks that the book's latest event is still ExpectedEventID, the one the reader saw, acquiring a book checks that its subject has no events yet, and a request that arrives twice is written only once, because the second copy either finds the state already changed or fails the precondition.

Next comes the decision, in the shape of the Decider pattern: a pure function that turns the command and the current state into events. The state is built by rules that are matched by the Go type of their event parameter, so the event type is spelled out exactly once, and a rejection is a named domain error:

type Book struct {
	IsAcquired bool
	BorrowedBy string
}

func BookState() *architecturekit.State[Book] {
	return architecturekit.NewState(Book{}).
		Evolve(func(book Book, _ BookAcquired) Book {
			book.IsAcquired = true
			return book
		}).
		Evolve(func(book Book, event BookBorrowed) Book {
			book.BorrowedBy = event.ReaderID
			return book
		}).
		Evolve(func(book Book, _ BookReturned) Book {
			book.BorrowedBy = ""
			return book
		})
}

var (
	ErrBookNotAcquired     = architecturekit.NewDomainError("book has not been acquired")
	ErrBookAlreadyBorrowed = architecturekit.NewDomainError("book is already borrowed")
)

func BorrowDecider() architecturekit.Decider[BorrowBook, Book] {
	return architecturekit.Decider[BorrowBook, Book]{
		State: BookState(),
		Decide: func(_ context.Context, cmd BorrowBook, book Book) ([]architecturekit.Event, error) {
			if !book.IsAcquired {
				return nil, ErrBookNotAcquired
			}
			if book.BorrowedBy != "" {
				return nil, ErrBookAlreadyBorrowed
			}

			return []architecturekit.Event{BookBorrowed{ReaderID: cmd.ReaderID}}, nil
		},
	}
}

architecturekit.Execute runs all of this: it reads the subject's events, folds them into the state, lets the decider decide, and writes the result together with the command's preconditions. Nothing in it touches HTTP; it takes a context, a store that wraps the Go client, a decider, and a command.

When a Rule Spans Subjects#

The state a decider sees comes from a single subject: the kit reads that subject's events and folds them, nothing else. For many decisions that is exactly right, as we argued in There Is No Row to Lock. For a rule that spans several subjects, the command declares an EventQL precondition, a condition written in EventSourcingDB's query language that the database checks at the moment of writing. A reader may hold at most three books at once, and those books are three different subjects, so BorrowBook gets a second precondition:

func (c BorrowBook) Preconditions() []eventsourcingdb.Precondition {
	return []eventsourcingdb.Precondition{
		eventsourcingdb.NewIsSubjectOnEventIDPrecondition(c.Subject(), c.ExpectedEventID),
		eventsourcingdb.NewIsEventQLQueryTruePrecondition(FewerThanThreeLoans(c.ReaderID)),
	}
}

func FewerThanThreeLoans(readerID string) string {
	return fmt.Sprintf(`FROM e IN events
WHERE e.data.readerId == %q
PROJECT INTO
  COUNT(e.type == "io.eventsourcingdb.library.book-borrowed") -
  COUNT(e.type == "io.eventsourcingdb.library.book-returned") < 3`, readerID)
}

The %q escapes quotes and backslashes, so a reader ID cannot break out of the string literal. If you have not used EventQL preconditions yet, they are worth a look: a write goes through only while a query over the whole store still holds, so a rule across several subjects needs no lock and no extra check in your code. They are also the most expensive of EventSourcingDB's four precondition types: there is no index over event payloads, so their cost grows with every event stored, and other writes wait while one runs, as that post shows. Keep them for the rules that genuinely span subjects; the preconditions documentation shows how to write one.

From Request to Command#

When the request arrives over HTTP, httpapi, the kit's optional HTTP package, builds the command. You give httpapi.NewAPI the store and a function that tells from a request who is asking, as a User type of your own, and register routes on the resulting api and an ordinary http.ServeMux, here mux. The domain code above lives in a package named library, and a request type you define carries the JSON tags so that the command does not have to:

type borrowBookRequest struct {
	BookID          string `json:"bookId"`
	ExpectedEventID string `json:"expectedEventId"`
}

func (r borrowBookRequest) ToCommand(user User) (library.BorrowBook, error) {
	return library.BorrowBook{
		BookID:          r.BookID,
		ReaderID:        user.ID,
		ExpectedEventID: r.ExpectedEventID,
	}, nil
}

httpapi.Route[borrowBookRequest](api, mux, "POST /api/borrow-book", library.BorrowDecider())

A request whose user cannot be determined is answered with 401 and never reaches a command. Authorization belongs in ToCommand: acquiring new books is for librarians, so the request type for acquiring a book returns httpapi.ErrForbidden for everyone else, and the caller gets a 403 before a command even exists. The reader ID comes from the signed-in user rather than from the body, so nobody borrows in someone else's name.

What Happens When the Answer Is No?#

Execute does not try again. When a precondition does not hold, it reports a conflict and returns, because only the caller knows whether a second attempt is worth it, and because a retry hides contention instead of showing it.

Every error Execute raises itself carries a category you ask for with errors.Is, so errors added later do not break your code: ErrDomain when a business rule said no, ErrConflict when a precondition did not hold, ErrTransient when trying again may help, and ErrPermanent when it will not. A conflict counts as transient, so check for it first.

Two of the ways a borrow can fail show how the work is divided. If the book is already out, the decider sees it and rejects the command with a domain error. If several readers ask for it at the same moment, their commands may all find it available, and the precondition lets exactly one through. In our test with ten readers at a time, five rounds in a row, every round had one winner, and the others got a conflict or a domain error, depending on whether their command read the book's events before or after the winner wrote.

There is a third way: a reader who already holds three books fails the EventQL precondition, and since the database checks that rule rather than the decider, the result is a conflict as well. The database does not say which precondition failed, so a conflict does not always mean that reloading and trying again will help.

The decider answers what it can see, the precondition what it cannot.

On HTTP, httpapi turns the four categories into 422, 409, 503, and 500, and it adds three rules. Unknown fields are rejected with a 400, because a misspelled field would otherwise turn into a zero value in silence. application/json is required, and anything else gets a 415: browsers send form data and plain text to other origins without asking, but JSON only with the server's consent, so other sites cannot write on your users' behalf. And a body larger than one mebibyte is answered with 413, because the kit reads it into memory before decoding it.

A View Is Not a Query#

On the read side, a projection applies the events, and it tells the kit what it can do through the interfaces it implements. A plain one is rebuilt from the beginning on every start. One that implements Resumable continues from a checkpoint, and since a crash can fall between an event and its checkpoint, it has to apply events idempotently. One that implements Transactional makes data and checkpoint durable together, which closes that gap; the kit calls these three modes.

What a projection fills is a view, often called a read model, and a view is what is stored, whereas a query is what runs over it. The kit ships one view, ItemView, which lives in memory. The catalog is an ItemView of entries, and its projection uses the subject scheme the other way round, reading the book's ID out of an event's subject:

type CatalogEntry struct {
	BookID      string `json:"bookId"`
	Title       string `json:"title"`
	IsAvailable bool   `json:"isAvailable"`
	EventID     string `json:"eventId"`
}

func CatalogProjection(catalog *architecturekit.ItemView[CatalogEntry]) architecturekit.Projection {
	return architecturekit.ProjectionFunc(func(_ context.Context, event eventsourcingdb.Event) error {
		values, isBook := BookSubject.Match(event.Subject)
		if !isBook {
			return nil
		}

		bookID := values["book"]
		isThisBook := func(entry CatalogEntry) bool { return entry.BookID == bookID }

		switch event.Type {
		case BookAcquired{}.EventType():
			var acquired BookAcquired
			if err := json.Unmarshal(event.Data, &acquired); err != nil {
				return err
			}
			catalog.Insert(CatalogEntry{BookID: bookID, Title: acquired.Title, IsAvailable: true, EventID: event.ID})
		case BookBorrowed{}.EventType():
			catalog.Update(isThisBook, func(entry *CatalogEntry) { entry.IsAvailable = false; entry.EventID = event.ID })
		case BookReturned{}.EventType():
			catalog.Update(isThisBook, func(entry *CatalogEntry) { entry.IsAvailable = true; entry.EventID = event.ID })
		}

		return nil
	})
}

A query is an ordinary function over the View interface. Its input is a plain struct with the caller's parameters, and the kit's query package supplies the steps. A view backed by a database can take the in-memory one's place without the query noticing:

type ListBooks struct {
	OnlyAvailable bool
}

func ListBooksIn(catalog architecturekit.View[CatalogEntry]) func(context.Context, ListBooks) ([]CatalogEntry, error) {
	return func(ctx context.Context, ask ListBooks) ([]CatalogEntry, error) {
		entries, err := catalog.All(ctx)
		if err != nil {
			return nil, err
		}

		if ask.OnlyAvailable {
			entries = query.Where(entries, func(entry CatalogEntry) bool { return entry.IsAvailable })
		}
		entries = query.OrderBy(entries, func(entry CatalogEntry) string { return entry.Title })

		return slices.Collect(entries), nil
	}
}

We made the case for keeping view and query apart in Your Read Model Doesn't Always Need a Database.

Note the EventID field. A read model has to carry the ID of the latest event on the book's subject, because that is the ExpectedEventID the next borrow command needs; a projection that skipped one of the subject's event types would hand out an outdated ID, and every borrow after such an event would fail its precondition. This is where the loop closes: what the read side shows is what the write side checks against.

Reading Your Own Writes Without Hoping#

A reader borrows a book, the page reloads, and the book is still listed as available. They reload again, and now it is gone. Nothing is broken; the projection had not caught up yet. A common fix is a short pause after every write, in the hope that the read model has caught up.

ArchitectureKit replaces that hope with a condition, built on what we call a revision: an event ID that marks how far something has come. Because EventSourcingDB hands out IDs as a single ascending sequence across all subjects, any two revisions can be compared. A command deals with two of them: the one it decided on, ExpectedEventID, and the one it produced, the ID of the last event it wrote. A view has one, the ID of the last event its projection has seen.

Execute returns the events it wrote, so the caller knows the revision it produced. For the view to know its own, the projection is wrapped in Tracking and driven by RunProjection:

catalog := architecturekit.NewItemView[library.CatalogEntry]()

go architecturekit.RunProjection(ctx, store, "/", true,
	architecturekit.Tracking(catalog, library.CatalogProjection(catalog)))

RunProjection follows every subject below /, catching up first and then staying live. Tracking records every event that reaches the projection, including the ones the projection ignores, since a caller may be waiting for exactly that event's ID. A view that knows its revision can be asked to wait for one, and together with the preconditions this gives both directions the same shape: a command says "I decided on this revision", a query says "I want to see at least this revision".

The waiting happens in the query, not in the command. Writes stay fast, only a caller that depends on its own write pays for the wait, and whichever instance answers can do the waiting.

On HTTP, the answer to a command lists the IDs of the events it wrote, and the caller sends the highest of them along with its next query in a Wait-For-Revision header. QueryRevisioned holds the answer back until the view has reached that revision. Running out of time is not an error: after DefaultWait, five seconds, the query answers with what the view has, and X-Revision and the ETag say which revision that is, so a caller that needs more can ask again.

httpapi.QueryRevisioned(api, mux, "GET /api/books", catalog,
	func(r *http.Request, _ User) (library.ListBooks, error) {
		return library.ListBooks{OnlyAvailable: r.URL.Query().Get("available") == "true"}, nil
	},
	library.ListBooksIn(catalog),
	httpapi.DefaultWait,
)

Tests That Catch What Compiles#

Because a decision is a pure function, it can be tested without a database, as Testing Without Mocks argues. The architecturekittest package writes such a test as given, when, then:

func TestBorrowBook(t *testing.T) {
	architecturekittest.Given(t, library.BorrowDecider(), library.BookAcquired{Title: "Dune"}).
		When(library.BorrowBook{BookID: "42", ReaderID: "23", ExpectedEventID: "7"}).
		ThenEvents(library.BookBorrowed{ReaderID: "23"}).
		ThenPreconditions(
			architecturekittest.OnEventID("/books/42", "7"),
			architecturekittest.OnQuery(library.FewerThanThreeLoans("23")),
		)
}

A rejection is asserted the same way. Because the domain errors are named variables, a test says ThenRejected(library.ErrBookAlreadyBorrowed.Error()) and names the rule it expects to see broken.

The assertion that matters most here is ThenPreconditions. Since the kit adds no preconditions of its own, a command that forgets its revision check would pass every other assertion and write unguarded in production. It compiles, its tests are green, and the first sign of trouble is a book lent to two readers at once. ThenPreconditions is the test that notices, by comparing what the command declares rather than evaluating it.

ExpectMode checks how a projection will be driven. The three modes come from optional interfaces, so a typo in a method signature leaves one of them unfulfilled, and the projection quietly falls back to being rebuilt on every start; a test that asserts architecturekit.ModeTransactional fails instead.

GivenStored builds the history from events in their stored shape and runs them through the state's upcasters, the functions that bring an older event shape up to date whenever a decider's state is built. It is how a given-when-then test reaches an upcaster, since an old event type usually has no Go type left.

From Luck to Conditions#

We started with the two places where event-sourced applications easily end up relying on luck. On the way in, a command now says under which conditions its events may be written, and the database checks them at the moment of writing. On the way out, a query can be asked to wait until the view has reached the revision the caller has just produced, and the server checks that too. Neither needs a guessed pause, and neither leaves you hoping: if the view cannot catch up in time, the answer says which revision it shows.

If you'd like to try it yourself, ArchitectureKit is on GitHub, and its README explains each concept. All you need is a running EventSourcingDB, which Getting Started walks you through, and Go 1.27 or later, because the kit relies on generic methods.

We have already rebuilt one of our own applications on ArchitectureKit, and it runs in production, with a complete reference application to follow. The version number still starts with a zero, so the API may change before 1.0. And if you already use the Go client, you do not have to move everything at once: architecturekit.NewStore takes the client you already have, so you can start with the write side and keep your HTTP layer as it is.

Written by
Golo Roden

CTO and founder at the native web. www.thenativeweb.io