Conqueress is a ports-and-adapters CQRS and event sourcing framework for Go. It borrows heavily from the .NET space, so parts of it are not the most idiomatic Go you will read. The persistence store is the part that benefits most: an aggregate knows nothing about where its events end up, and you swap Firestore for MongoDB or an in-memory store by passing a different event store to the repository.
go get github.com/iamkoch/conqueressThe storage adapters are separate modules, so install only the one you need:
go get github.com/iamkoch/conqueress/firestore
go get github.com/iamkoch/conqueress/mongoThe core module requires Go 1.23 or later, because the aggregate and repository
types are generic. Both storage adapters require Go 1.25 or later, because the
patched versions of golang.org/x/crypto and golang.org/x/net do.
| Module | Contents |
|---|---|
github.com/iamkoch/conqueress |
Mediator, events, aggregates, repositories, projections, the in-memory event store, and the sample domain |
github.com/iamkoch/conqueress/firestore |
Firestore event store |
github.com/iamkoch/conqueress/mongo |
MongoDB event store |
Both adapters declare package store, so alias the import if you use them
together.
The core module holds these packages:
conqueress— the mediator, theEventandCommandtypes, and projections.conqueress/domain—AggregateRootBaseand the aggregate interfaces.conqueress/eventstore— the repository, and the event store interfaces the adapters implement.conqueress/eventstore/inmemory— an event store that keeps everything in a map, for tests.conqueress/guid— the identifier type, a thin wrapper overxid.conqueress/example— a worked inventory example, used by the adapter tests.
An aggregate embeds domain.AggregateRootBase[TID], where TID is the type of
its identifier. Use guid.Guid unless you have a reason not to.
type InventoryItem struct {
domain.AggregateRootBase[guid.Guid]
name string
}
type InventoryItemCreated struct {
*cqrs.BaseEvent
Id guid.Guid
Name string
}Every aggregate needs a default constructor that wires its event handler. The repository calls this constructor to get an empty aggregate before it replays events into it, so keep it free of business logic.
func DefaultInventoryItem() *InventoryItem {
ii := InventoryItem{
AggregateRootBase: domain.NewAggregate[guid.Guid](),
}
ii.SetInnerApply(ii.handleEvent)
return &ii
}
func (ii *InventoryItem) handleEvent(e cqrs.Event) {
switch evt := e.(type) {
case InventoryItemCreated:
ii.SetId(evt.Id)
ii.SetVersion(evt.Ver)
ii.name = evt.Name
}
}The handler mutates state and nothing else. Do not validate in it, because it runs both for new events and for events replayed from storage.
Behaviour goes in methods that raise events. cqrs.NewEvent fills in the
message ID and version on the embedded BaseEvent, and ApplyChange runs the
handler and records the event as uncommitted.
func NewInventoryItem(id guid.Guid, name string) *InventoryItem {
i := DefaultInventoryItem()
i.ApplyChange(cqrs.NewEvent[InventoryItemCreated](func(e *InventoryItemCreated) {
e.Id = id
e.Name = name
}))
return i
}A repository pairs an event store with an aggregate's default constructor.
GetById replays the stored events into a fresh aggregate, and Save appends
the uncommitted ones.
m := cqrs.NewMediator(false)
store := inmemory.NewInMemoryEventStore[guid.Guid](m)
repo := eventstore.NewRepository[*InventoryItem](store, DefaultInventoryItem)GetById returns eventstore.ErrAggregateNotFound when the stream is empty.
Save takes an expected version, and the store rejects the write if the stream
has moved on. Pass -1 when you create an aggregate, which asserts that no
stream exists yet. Pass aggregate.Version() when you load and modify one,
which asserts that nothing has written to the stream since you read it.
func (h Handlers) HandleRenameInventoryItem(cmd cqrs.Command) error {
c := cmd.(RenameInventoryItem)
item, err := h.repository.GetById(c.InventoryItemId)
if err != nil {
return err
}
item.Rename(c.NewName)
return h.repository.Save(item, item.Version())
}Version() is the version of the last stored event, so applying new changes
does not move it and you can read it either side of the call.
Saving a loaded aggregate with -1 fails against any store that enforces the
check. The in-memory store treats -1 as "do not check", so a mistake here
passes in unit tests and fails against Firestore.
The mediator routes commands to a single handler each, and events to any number of processors. Register handlers before you dispatch anything.
m := cqrs.NewMediator(false)
handlers := NewInventoryCommandHandler(repo)
cqrs.RegisterCommandHandler[CreateInventoryItem](m, handlers.HandleCreateInventoryItem)
cqrs.RegisterEventHandlers[InventoryItemCreated](m, readModel.HandleCreated)
m.Dispatch(NewCreateInventoryItem(guid.New(), "widget"), nil)Dispatch returns an error straight away if no handler is registered for the
command type. Otherwise it queues the command for the mediator's own goroutine
and returns nil, so the handler has not run yet when it returns. To get the
handler's error back, pass a channel as the second argument and read from it.
DispatchSync runs the handler on the calling goroutine and returns its error
directly.
Publish calls each processor on its own goroutine, and PublishSync calls
them in turn on the calling goroutine. Both return an error when no processor
is registered for the event type.
Pass true to NewMediator to insert random delays before handling commands
and publishing events. Use it to shake out code that assumes a read model is
up to date the moment a command returns.
A projection is a read model with an identifier and a version.
BaseProjectionHandler takes a load function, a save function, and a factory,
and handles the read-modify-write cycle.
type InventoryItemReadModel struct {
cqrs.BaseProjection
name string
}
func (i Handler) HandleRenamed(e cqrs.Event) error {
evt := e.(InventoryItemRenamed)
return i.UpdateProjection(evt.Id, evt, func(p *InventoryItemReadModel, e cqrs.Event) {
p.name = evt.NewName
})
}Both adapters need a type map, which tells the store how to turn a stored type name back into a Go type. Register every event type an aggregate can raise.
tm := store.NewTypeMap().
Add(InventoryItemCreated{}).
Add(InventoryItemRenamed{})
s, err := store.NewFirestoreEventStore(context.Background(), "my-project", tm)Pass firestore.DetectProjectID instead of a project to take it from the
environment, which reads GOOGLE_CLOUD_PROJECT and then the credentials the
process is running under. NewFirestoreEventStoreWithClient wraps a client you
have configured yourself, for the client options the constructor does not
expose.
The MongoDB adapter takes a connection string and a database name:
s, err := store.NewMongoEventStore(
store.ConnectionString("mongodb://localhost:27017/?replicaSet=rs0"),
"my-database",
tm)It stores events and aggregates in the events and aggregates collections of
that database, and writes both in one transaction. MongoDB only supports
transactions on a replica set or a sharded cluster, so a standalone server
rejects the write.
Neither adapter publishes events. The in-memory store does, because it holds a mediator, so a read model that updates in unit tests will not update against Firestore or MongoDB. Publish from your command handlers if you need both.
The in-memory store also fails the save when the mediator has no processor registered for an event it is publishing. Register a processor for every event type your aggregates raise before you use it, even one that does nothing.
The repository is a Go workspace, and go.work covers the core module and both
adapters. A pattern of ./... matches only the module you are standing in, so
name the adapters as well:
go test ./... ./mongo/... -raceThe Firestore tests run against the emulator. Take it from the image Google publishes, which carries its own Java:
docker run -d --name firestore-emulator -p 8722:8722 \
gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators \
gcloud emulators firestore start --host-port=0.0.0.0:8722 --project=conqueress-ci
FIRESTORE_EMULATOR_HOST=127.0.0.1:8722 go test ./firestore/... -raceA local gcloud emulators firestore start works too, but it needs a Java 21 or
later runtime on PATH. On macOS, /usr/libexec/java_home -v 21 prints the
path to one.
The MongoDB tests need a replica set, because the store writes in a transaction. A single-node one is enough:
docker run -d --name conqueress-mongo -p 27017:27017 mongo:7 --replSet rs0 --bind_ip_all
docker exec conqueress-mongo mongosh --quiet \
--eval 'rs.initiate({_id:"rs0",members:[{_id:0,host:"127.0.0.1:27017"}]})'
go test ./mongo/... -raceEach test creates its own database, so they do not see each other's aggregates
and can run in any order. Set MONGO_CONNECTION_STRING to point at a different
deployment.
.github/workflows/ci.yml runs on every push to main and every pull
request. It builds, vets, and checks formatting across all three modules, runs
the core and MongoDB tests, then starts the Firestore emulator container and
runs the Firestore tests. A second job builds each module with GOWORK=off and fails if
go mod tidy would change anything, which catches a module that imports a
package it does not require. A third runs govulncheck over all three.
Tagging is the release. Push a tag and .github/workflows/release.yml checks
that the tag names a real module, builds that module without the workspace,
creates the GitHub release, and asks the Go module proxy to fetch the version
so go get resolves it straight away.
git tag -a v0.1.2 -m 'v0.1.2'
git push origin v0.1.2Tag the adapters with a firestore/ or mongo/ prefix. Tag the core module
first when the adapters need to require the new version, because the workspace
substitutes the code locally but Go still reads the go.mod of whatever version
they name.
Neither adapter has an index on aggregate_id, so loading an aggregate scans
the events collection. Add one before either sees a stream of any size.
The example package ships in the core module because all three test suites
import it. Its package comment says it is exempt from semver, but it is still
on the public API surface.