From 94de1945d5f9293859b018a4a8db8fceac98418c Mon Sep 17 00:00:00 2001 From: Antony Koch Date: Fri, 5 Jun 2026 14:30:11 +0100 Subject: [PATCH 01/14] chore: fix stale baseline - guid: replace github.com/pkg/errors with stdlib fmt.Errorf+%w to avoid the missing-module build failure. - eventstore/inmemory: update repository_test to the post-generics API (AggregateRootBase[TID], NewGenericIDRepository, GetById returning (T, error)). Tests now pass with 'go test ./...'. --- .../eventstore/inmemory/repository_test.go | 47 +++++++++---------- conqueress/go.mod | 6 ++- conqueress/go.sum | 14 +++++- conqueress/guid/guid.go | 5 +- 4 files changed, 43 insertions(+), 29 deletions(-) diff --git a/conqueress/eventstore/inmemory/repository_test.go b/conqueress/eventstore/inmemory/repository_test.go index fa16043..17bf81c 100644 --- a/conqueress/eventstore/inmemory/repository_test.go +++ b/conqueress/eventstore/inmemory/repository_test.go @@ -1,21 +1,22 @@ package inmemory import ( + "reflect" + "testing" + cqrs "github.com/iamkoch/conqueress" "github.com/iamkoch/conqueress/domain" "github.com/iamkoch/conqueress/eventstore" "github.com/iamkoch/conqueress/guid" . "github.com/smartystreets/goconvey/convey" - "reflect" - "testing" ) type User struct { - domain.AggregateRootBase + domain.AggregateRootBase[guid.Guid] name string } -func (u *User) SetBase(base domain.AggregateRootBase) { +func (u *User) SetBase(base domain.AggregateRootBase[guid.Guid]) { u.AggregateRootBase = base } @@ -25,29 +26,25 @@ func (u *User) GetHandler() func(e cqrs.Event) { type UserCreated struct { *cqrs.BaseEvent - id guid.Guid - name string + Id guid.Guid + Name string } func (u *User) handleEvent(e cqrs.Event) { switch evt := e.(type) { case UserCreated: - u.SetId(evt.id) + u.SetId(evt.Id) u.SetVersion(evt.Ver) - u.name = evt.name + u.name = evt.Name } } -func NewUser2() *User { - return domain.New[User]() -} - func NewUser() *User { - u := User{ - AggregateRootBase: domain.NewAggregate(), + u := &User{ + AggregateRootBase: domain.NewAggregate[guid.Guid](), } u.SetInnerApply(u.handleEvent) - return &u + return u } func TestRepository(t *testing.T) { @@ -59,19 +56,21 @@ func TestRepository(t *testing.T) { } m := cqrs.NewMediator(false) storage := NewInMemoryEventStore[guid.Guid](m) - repo := eventstore.NewRepository[*User](storage, domain.GetDefaultAggregate[User]) - m.RegisterEventHandler(reflect.TypeOf(UserCreated{}), h) + repo := eventstore.NewGenericIDRepository[*User, guid.Guid](storage, NewUser) + _ = m.RegisterEventHandler(reflect.TypeOf(UserCreated{}), h) - agg := NewUser2() + agg := NewUser() id := guid.New() - agg.ApplyChange(UserCreated{&cqrs.BaseEvent{ - MessageId: guid.New().String(), - Ver: -1, - }, id, "bob"}) + agg.ApplyChange(UserCreated{ + BaseEvent: &cqrs.BaseEvent{MessageId: guid.New().String(), Ver: -1}, + Id: id, + Name: "bob", + }) - repo.Save(agg, -1) + So(repo.Save(agg, -1), ShouldBeNil) - loaded := repo.GetById(id) + loaded, err := repo.GetById(id) + So(err, ShouldBeNil) So(loaded.name, ShouldEqual, "bob") So(loaded.Id(), ShouldEqual, id) diff --git a/conqueress/go.mod b/conqueress/go.mod index 14358f4..b199b37 100644 --- a/conqueress/go.mod +++ b/conqueress/go.mod @@ -3,13 +3,17 @@ module github.com/iamkoch/conqueress go 1.23 require ( + github.com/iamkoch/ensure v1.0.0 github.com/rs/xid v1.4.0 github.com/smartystreets/goconvey v1.7.2 + github.com/stretchr/testify v1.11.1 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect github.com/jtolds/gls v4.20.0+incompatible // indirect - github.com/kisielk/godepgraph v0.0.0-20190626013829-57a7e4a651a9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/smartystreets/assertions v1.2.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/conqueress/go.sum b/conqueress/go.sum index dab6e04..b6ac2a7 100644 --- a/conqueress/go.sum +++ b/conqueress/go.sum @@ -1,17 +1,27 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/iamkoch/ensure v1.0.0 h1:gKVynFfBTsbH7CEyiUc/kBZRDnh+eX+fNaIC7NLMHw0= +github.com/iamkoch/ensure v1.0.0/go.mod h1:4WWXoqsh453l9ispJtBBYZ+5MZOxG2crHeXFYGifKc0= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/kisielk/godepgraph v0.0.0-20190626013829-57a7e4a651a9 h1:ZkWH0x1yafBo+Y2WdGGdszlJrMreMXWl7/dqpEkwsIk= -github.com/kisielk/godepgraph v0.0.0-20190626013829-57a7e4a651a9/go.mod h1:Gb5YEgxqiSSVrXKWQxDcKoCM94NO5QAwOwTaVmIUAMI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/conqueress/guid/guid.go b/conqueress/guid/guid.go index 343a88a..cd5277f 100644 --- a/conqueress/guid/guid.go +++ b/conqueress/guid/guid.go @@ -1,7 +1,8 @@ package guid import ( - "github.com/pkg/errors" + "fmt" + "github.com/rs/xid" ) @@ -18,7 +19,7 @@ func New() Guid { func FromString(s string) (Guid, error) { id, e := xid.FromString(s) if e != nil { - return Empty, errors.Wrap(e, "invalid guid provided to FromString") + return Empty, fmt.Errorf("invalid guid provided to FromString: %w", e) } return Guid(id), nil } From 4a127455dea03fd69d2b49e7f92cc33d7b205d1b Mon Sep 17 00:00:00 2001 From: Antony Koch Date: Fri, 5 Jun 2026 14:32:02 +0100 Subject: [PATCH 02/14] feat(domain): naming hygiene + docs Drop the underscore prefix from private fields on AggregateRootBase (_changes/_id/_version/_innerApply -> changes/id/version/innerApply). These fields are unexported so the rename is invisible to consumers, but the underscore-prefixed Go names are not idiomatic and slightly distracting in struct definitions. Also adds: - a comprehensive package doc explaining the type-switch + injected- handler dispatch pattern (the conqueress-specific solution to event application without reflection) - Version() accessor (was previously only writable via SetVersion) - MarkChangesAsCommitted() for the repository's post-save lifecycle (mirrors IntelAgent.Framework's API) - doc comments on the public surface No behaviour change. Tests updated to use the Version() accessor. --- conqueress/domain/aggregate.go | 99 +++++++++++++++++++++++++---- conqueress/domain/aggregate_test.go | 2 +- 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/conqueress/domain/aggregate.go b/conqueress/domain/aggregate.go index b073334..ac245d1 100644 --- a/conqueress/domain/aggregate.go +++ b/conqueress/domain/aggregate.go @@ -1,80 +1,151 @@ +// Package domain defines the aggregate-root primitives that consumers embed in +// their own domain types. +// +// The pattern is type-switch dispatch with an injected handler: +// +// 1. The consumer's aggregate (e.g. `*InventoryItem`) embeds +// `AggregateRootBase[TID]`. +// 2. The consumer defines a single `handleEvent(e cqrs.Event)` method on the +// aggregate that uses a type switch over the event types it handles. +// 3. At construction, the consumer calls `SetInnerApply(handleEvent)` to +// install that callback onto the base. +// 4. From then on, `ApplyChange(evt)` both appends to the uncommitted-changes +// list and dispatches to the consumer's type switch. +// +// No reflection, no dynamic dispatch. The consumer owns the dispatch table as +// a normal `switch evt := e.(type)`. package domain import ( + "reflect" + cqrs "github.com/iamkoch/conqueress" "github.com/iamkoch/conqueress/guid" - "reflect" ) +// IAggregate is the legacy non-generic interface backed by guid.Guid IDs. +// Kept for backwards compatibility; prefer IGenericIDAggregate[TID] for new code. type IAggregate interface { Id() guid.Guid UncommittedEvents() []cqrs.Event ApplyChange(e cqrs.Event) } +// IGenericIDAggregate is the aggregate contract parameterised by ID type. +// Use this when your aggregate uses a typed ID other than guid.Guid (e.g. a +// composite key value type). type IGenericIDAggregate[TID any] interface { Id() TID UncommittedEvents() []cqrs.Event ApplyChange(e cqrs.Event) } +// AggregateRootBase is the embeddable base for an event-sourced aggregate. +// +// Consumers embed it in their aggregate struct and inject a `handleEvent` +// callback via SetInnerApply during construction. ApplyChange both routes the +// event through that callback (so the aggregate's state is updated) and +// appends to the uncommitted-changes list (so the repository can persist). type AggregateRootBase[TID any] struct { - _changes []cqrs.Event - _id TID - _version int - _innerApply func(e cqrs.Event) + changes []cqrs.Event + id TID + version int + innerApply func(e cqrs.Event) } +// SetId sets the aggregate's identity. Typically called from the consumer's +// `handleEvent` when handling the "created" event for the aggregate. func (a *AggregateRootBase[TID]) SetId(id TID) { - a._id = id + a.id = id } +// SetVersion sets the aggregate's version, typically set on each handled event. func (a *AggregateRootBase[TID]) SetVersion(v int) { - a._version = v + a.version = v } +// Version returns the aggregate's current version. +func (a *AggregateRootBase[TID]) Version() int { + return a.version +} + +// SetInnerApply installs the consumer's event-handling callback. The consumer +// typically passes a method value like `aggregate.handleEvent` here. func (a *AggregateRootBase[TID]) SetInnerApply(ia func(e cqrs.Event)) { - a._innerApply = ia + a.innerApply = ia } +// InnerApply invokes the consumer-supplied callback. Used internally by +// ApplyChange and by the repository when rehydrating from events. func (a *AggregateRootBase[TID]) InnerApply(e cqrs.Event) { - a._innerApply(e) + a.innerApply(e) } +// InnerApplier exposes the InnerApply method so the repository can route +// historical events through the consumer's handler without exposing the base +// struct's internals. type InnerApplier interface { InnerApply(e cqrs.Event) } +// DefaultAggregate is the interface a consumer's aggregate must satisfy if +// it's going to be constructed via the reflection-based helpers (New[T], +// NewWithID[T,TID], GetDefaultAggregate[T]). +// +// If you don't use those helpers, you don't need to satisfy this interface; +// you can construct your aggregate directly via composite literal and call +// SetInnerApply yourself. That's the recommended pattern for new code — see +// the package doc. type DefaultAggregate[TID any] interface { SetBase(base AggregateRootBase[TID]) GetHandler() func(e cqrs.Event) SetInnerApply(ia func(e cqrs.Event)) } +// Id returns the aggregate's identity. func (a *AggregateRootBase[TID]) Id() TID { - return a._id + return a.id } func (a *AggregateRootBase[TID]) applyChangeInternal(e cqrs.Event, isNew bool) { a.InnerApply(e) if isNew { - a._changes = append(a._changes, e) + a.changes = append(a.changes, e) } } +// ApplyChange routes the event through the consumer's handler and records it +// as an uncommitted change. Used by the consumer's aggregate methods (e.g. +// `(i *InventoryItem) Rename(...)`). func (a *AggregateRootBase[TID]) ApplyChange(e cqrs.Event) { a.applyChangeInternal(e, true) } +// UncommittedEvents returns the events emitted since the aggregate was loaded +// or last persisted. The repository drains this list at Save time. func (a *AggregateRootBase[TID]) UncommittedEvents() []cqrs.Event { - return a._changes + return a.changes +} + +// MarkChangesAsCommitted clears the uncommitted-changes buffer. The repository +// calls this after a successful Save so subsequent calls only return new events. +func (a *AggregateRootBase[TID]) MarkChangesAsCommitted() { + a.changes = a.changes[:0] } +// NewAggregate returns a zero-value AggregateRootBase. Consumers use it when +// constructing their aggregate via composite literal. func NewAggregate[TID any]() AggregateRootBase[TID] { return AggregateRootBase[TID]{} } +// New constructs an aggregate of type T via reflection, defaulting its ID +// type to guid.Guid. The aggregate must satisfy DefaultAggregate[guid.Guid]. +// +// Prefer constructing aggregates explicitly via composite literal + manual +// SetInnerApply call; this helper exists for symmetry with the C# template +// and may be removed in a future major version. func New[T any]() *T { n := new(T) instance := reflect.New(reflect.TypeOf(n).Elem()) @@ -85,6 +156,7 @@ func New[T any]() *T { return reflect.ValueOf(a).Interface().(*T) } +// NewWithID is the typed-ID variant of New. func NewWithID[T any, TID any]() *T { n := new(T) instance := reflect.New(reflect.TypeOf(n).Elem()) @@ -95,6 +167,9 @@ func NewWithID[T any, TID any]() *T { return reflect.ValueOf(a).Interface().(*T) } +// GetDefaultAggregate is the constructor factory function the repository can +// use to create a fresh instance during event rehydration. Behaves like +// New[T] but returns a function-callable form. func GetDefaultAggregate[T any]() *T { n := new(T) instance := reflect.New(reflect.TypeOf(n).Elem()) diff --git a/conqueress/domain/aggregate_test.go b/conqueress/domain/aggregate_test.go index c3d3839..41dbe9b 100644 --- a/conqueress/domain/aggregate_test.go +++ b/conqueress/domain/aggregate_test.go @@ -103,7 +103,7 @@ func TestAggregateRootBase_SetVersion(t *testing.T) { base.SetVersion(version) // Assert - assert.Equal(t, version, base._version) + assert.Equal(t, version, base.Version()) } func TestAggregateRootBase_ApplyChange(t *testing.T) { From 5b399f7bfff79098c6de2f6006b1ca1a0c29f44f Mon Sep 17 00:00:00 2001 From: Antony Koch Date: Fri, 5 Jun 2026 14:34:36 +0100 Subject: [PATCH 03/14] feat(mediator): remove induceDelay from core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mediator no longer carries an induceDelay flag and no longer sleeps on command/event dispatch. The core is intentionally deterministic. Consumers who want artificial delays in tests (e.g. to exercise eventual-consistency behaviour in downstream projections) should wrap a handler with a delay decorator at registration time — don't bake test behaviour into the framework's core dispatcher. API change: - NewMediator(bool) -> NewMediator() All test callers updated. Commented-out NewMediator(false) lines in conqueress-mongo/store2_test.go are left as-is (they're historical comments, not live code). --- conqueress-firestore/store_test.go | 6 +- .../eventstore/inmemory/repository_test.go | 2 +- conqueress/mediator.go | 82 +++++++++---------- conqueress/mediator_test.go | 6 +- tests/framework_test.go | 6 +- 5 files changed, 50 insertions(+), 52 deletions(-) diff --git a/conqueress-firestore/store_test.go b/conqueress-firestore/store_test.go index 9f41e00..5edfb16 100644 --- a/conqueress-firestore/store_test.go +++ b/conqueress-firestore/store_test.go @@ -198,7 +198,7 @@ func TestVersionsAndConcurrency(t *testing.T) { func TestConcurrency(t *testing.T) { Convey("saving the same entity twice with the same expected version", t, func() { - m := cqrs.NewMediator(false) + m := cqrs.NewMediator() tm := NewTypeMap().Add(sample_domain.InventoryItemCreated{}).Add(sample_domain.InventoryItemRenamed{}) s, err := NewFirestoreEventStore(context.Background(), tm) @@ -232,7 +232,7 @@ func TestConcurrency(t *testing.T) { func TestStore(t *testing.T) { Convey("save and load should work simply", t, func() { Convey("when saving", func() { - m := cqrs.NewMediator(false) + m := cqrs.NewMediator() tm := NewTypeMap().Add(sample_domain.InventoryItemCreated{}).Add(sample_domain.InventoryItemRenamed{}) s, err := NewFirestoreEventStore(context.Background(), tm) @@ -270,7 +270,7 @@ func TestStore(t *testing.T) { Convey("concurrency check works", t, func() { Convey("when saving with wrong version, throws concurrency exception", func() { - m := cqrs.NewMediator(false) + m := cqrs.NewMediator() tm := NewTypeMap().Add(sample_domain.InventoryItemCreated{}).Add(sample_domain.InventoryItemRenamed{}) s, err := NewFirestoreEventStore(context.Background(), tm) diff --git a/conqueress/eventstore/inmemory/repository_test.go b/conqueress/eventstore/inmemory/repository_test.go index 17bf81c..430ca1d 100644 --- a/conqueress/eventstore/inmemory/repository_test.go +++ b/conqueress/eventstore/inmemory/repository_test.go @@ -54,7 +54,7 @@ func TestRepository(t *testing.T) { cap = e return nil } - m := cqrs.NewMediator(false) + m := cqrs.NewMediator() storage := NewInMemoryEventStore[guid.Guid](m) repo := eventstore.NewGenericIDRepository[*User, guid.Guid](storage, NewUser) _ = m.RegisterEventHandler(reflect.TypeOf(UserCreated{}), h) diff --git a/conqueress/mediator.go b/conqueress/mediator.go index 85d374b..883ba68 100644 --- a/conqueress/mediator.go +++ b/conqueress/mediator.go @@ -3,9 +3,7 @@ package conqueress import ( "errors" "log/slog" - "math/rand" "reflect" - "time" ) type CommandHandler func(cmd Command) error @@ -14,11 +12,20 @@ type EventProcessor func(evt Event) error type Command interface { } +// Mediator dispatches commands to their registered handlers and publishes +// events to their registered processors. +// +// Commands are dispatched on a background goroutine (Dispatch) or synchronously +// (DispatchSync); events publish to N processors concurrently (Publish) or +// sequentially (PublishSync). +// +// The core is intentionally deterministic. If you want artificial delays in +// tests to exercise eventual-consistency behaviour, wrap a handler with a +// delay decorator at registration time — don't bake it into the framework. type Mediator struct { commandQueue chan queuedCommand commandHandlers map[reflect.Type]CommandHandler eventProcessors map[reflect.Type][]EventProcessor - induceDelay bool } type queuedCommand struct { @@ -26,12 +33,13 @@ type queuedCommand struct { synchronousResponse chan CommandProcessingError } -func NewMediator(induceDelay bool) *Mediator { +// NewMediator constructs an empty mediator and starts its background +// command-processing goroutine. +func NewMediator() *Mediator { mediator := &Mediator{ commandQueue: make(chan queuedCommand), commandHandlers: make(map[reflect.Type]CommandHandler), eventProcessors: make(map[reflect.Type][]EventProcessor), - induceDelay: induceDelay, } go mediator.processCommands() @@ -39,29 +47,23 @@ func NewMediator(induceDelay bool) *Mediator { } func (m *Mediator) processCommands() { - for { - select { - case cmdReq := <-m.commandQueue: - cmd := cmdReq.cmd - slog.With( - "command", cmd, - "type", reflect.TypeOf(cmd), - ).Debug("Processing command") - resp := cmdReq.synchronousResponse - handler, _ := m.commandHandlers[reflect.TypeOf(cmd)] - if m.induceDelay { - time.Sleep(time.Duration(1*rand.Intn(3)) * time.Second) - } - - result := handler(cmd) - slog.With( - "command", cmd, - "type", reflect.TypeOf(cmd), - "result", result, - ).Debug("Command processed") - if resp != nil { - resp <- result - } + for cmdReq := range m.commandQueue { + cmd := cmdReq.cmd + slog.With( + "command", cmd, + "type", reflect.TypeOf(cmd), + ).Debug("Processing command") + resp := cmdReq.synchronousResponse + handler := m.commandHandlers[reflect.TypeOf(cmd)] + + result := handler(cmd) + slog.With( + "command", cmd, + "type", reflect.TypeOf(cmd), + "result", result, + ).Debug("Command processed") + if resp != nil { + resp <- result } } } @@ -154,10 +156,7 @@ func (m *Mediator) DispatchSync(cmd Command, syncResp chan CommandProcessingErro "command", cmd, "type", reflect.TypeOf(cmd), ).Debug("Processing command") - handler, _ := m.commandHandlers[reflect.TypeOf(cmd)] - if m.induceDelay { - time.Sleep(time.Duration(1*rand.Intn(3)) * time.Second) - } + handler := m.commandHandlers[reflect.TypeOf(cmd)] result := handler(cmd) slog.With( @@ -170,14 +169,15 @@ func (m *Mediator) DispatchSync(cmd Command, syncResp chan CommandProcessingErro return errors.New("no handler registered") } +// Publish fans the event out to all registered processors concurrently. +// Each processor runs on its own goroutine. Errors from individual processors +// are not surfaced to the caller; instrument inside each processor if you +// need observability. func (m *Mediator) Publish(evt Event) error { if processors, ok := m.eventProcessors[reflect.TypeOf(evt)]; ok { for _, processor := range processors { go func(p EventProcessor) { - if m.induceDelay { - time.Sleep(time.Duration(rand.Intn(10)) * time.Second) // Have a variable degree of eventual consistency - } - p(evt) + _ = p(evt) }(processor) } return nil @@ -185,15 +185,13 @@ func (m *Mediator) Publish(evt Event) error { return errors.New("no processor registered") } +// PublishSync runs each processor sequentially on the caller's goroutine. +// Errors from individual processors are not surfaced (consistent with +// Publish); wrap a processor with error-aware middleware if needed. func (m *Mediator) PublishSync(evt Event) error { if processors, ok := m.eventProcessors[reflect.TypeOf(evt)]; ok { for _, processor := range processors { - func(p EventProcessor) { - if m.induceDelay { - time.Sleep(time.Duration(rand.Intn(10)) * time.Second) // Have a variable degree of eventual consistency - } - p(evt) - }(processor) + _ = processor(evt) } return nil } diff --git a/conqueress/mediator_test.go b/conqueress/mediator_test.go index 154a155..3b6185b 100644 --- a/conqueress/mediator_test.go +++ b/conqueress/mediator_test.go @@ -18,7 +18,7 @@ func TestCommandDispatch(t *testing.T) { ) ensure.That("published commands are dispatched without delay when induce is false", func(s *ensure.Scenario) { s.Given("a command dispatcher with induce delay false", func() { - mediator = NewMediator(false) + mediator = NewMediator() }) s.And("a command handler", func() { @@ -61,7 +61,7 @@ func TestCommandHandlerReturnsError(t *testing.T) { ) ensure.That("command handlers that return an error bubble up the error", func(s *ensure.Scenario) { s.Given("a command dispatcher with induce delay false", func() { - mediator = NewMediator(false) + mediator = NewMediator() }) s.And("a command handler", func() { @@ -108,7 +108,7 @@ func TestPublish(t *testing.T) { ) ensure.That("published events are sent to all handlers", func(s *ensure.Scenario) { s.Given("a mediator", func() { - mediator = NewMediator(false) + mediator = NewMediator() }) s.And("a handler", func() { diff --git a/tests/framework_test.go b/tests/framework_test.go index 8d3e24e..bf672f5 100644 --- a/tests/framework_test.go +++ b/tests/framework_test.go @@ -32,7 +32,7 @@ func (t *testPublisher) Handle(event cqrs.Event) error { func TestApplication(t *testing.T) { Convey("Create inventory item", t, func() { - m := cqrs.NewMediator(false) + m := cqrs.NewMediator() storage := inmemory.NewInMemoryEventStore[guid.Guid{}](m) repo := eventstore.NewRepository[*sample_domain.InventoryItem](storage, sample_domain.DefaultInventoryItem) @@ -52,7 +52,7 @@ func TestApplication(t *testing.T) { }) Convey("Applying multiple commands", t, func() { - m := cqrs.NewMediator(false) + m := cqrs.NewMediator() storage := inmemory.NewInMemoryEventStore(m) repo := eventstore.NewRepository[*sample_domain.InventoryItem](storage, sample_domain.DefaultInventoryItem) @@ -72,7 +72,7 @@ func TestApplication(t *testing.T) { }) Convey("Mediator blows when same handler registered twice", t, func() { - m := cqrs.NewMediator(false) + m := cqrs.NewMediator() handler := newTestPublisher() m.RegisterEventHandler(reflect.TypeOf(sample_domain.InventoryItemRenamed{}), handler.Handle) From 215951999ebfd153881597dfa09289de879ab1e7 Mon Sep 17 00:00:00 2001 From: Antony Koch Date: Fri, 5 Jun 2026 14:39:16 +0100 Subject: [PATCH 04/14] feat(eventing): correlation, causation, occurredAt on Event; BaseCommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the metadata that any non-trivial event-sourced system needs but that the original conqueress didn't model: Event interface gains: - CorrelationId() guid.Guid - CausationId() guid.Guid - OccurredAt() time.Time - WithMetadata(correlation, causation, occurredAt guid.Guid, time.Time) BaseEvent gains matching fields and accessors. Empty values map to guid.Empty / zero time — explicit absence rather than panicking on unset. Introduces: IntegrationEvent interface - extends Event with EventType() string for cross-language wire stability ('com.inshur.policy.created' vs Go's 'PolicyCreated'). BaseCommand + NewBaseCommand(correlation, causation) - embeddable base that carries an ID, correlation, causation and createdAt. Optional — Command stays a marker interface so existing consumers don't have to migrate. This is what flows through the framework as commands → events → downstream commands. The application layer can now stamp metadata onto events emitted by an aggregate via the command's IDs, and downstream consumers can carry the trace through. Existing tests updated (mediator_test.go's TestEvent now embeds *BaseEvent so it satisfies the wider interface). New tests in eventing_test.go cover BaseEvent.WithMetadata, default zero values, and NewBaseCommand. --- conqueress/eventing.go | 191 +++++++++++++++++++++++++++++++----- conqueress/eventing_test.go | 44 ++++++++- conqueress/mediator.go | 3 - conqueress/mediator_test.go | 25 ++--- 4 files changed, 219 insertions(+), 44 deletions(-) diff --git a/conqueress/eventing.go b/conqueress/eventing.go index 0058f65..58c1d72 100644 --- a/conqueress/eventing.go +++ b/conqueress/eventing.go @@ -1,66 +1,169 @@ +// Package conqueress provides primitives for command-query-responsibility- +// separation and event-sourcing in Go. +// +// This file defines the Event, IntegrationEvent, and Command surface. package conqueress import ( - "github.com/iamkoch/conqueress/guid" "reflect" + "time" + + "github.com/iamkoch/conqueress/guid" ) +// Event is the contract for a domain event emitted by an aggregate. +// +// Events carry their own identity, ordering version, correlation/causation +// trace and the wall-clock moment at which the domain modelled their +// occurrence. Consumers embed *BaseEvent in their event structs to satisfy +// this interface for free. type Event interface { + // MsgId returns the event's stable identity. Distinct from the aggregate + // version: the event ID identifies one event, not one position in a + // stream. MsgId() guid.Guid - WithVersion(v int) + + // Version returns the position of this event in its aggregate's stream + // (0-based). Set by the event store at append time. Version() int + + // WithVersion sets the event's position. Called by the event store. + WithVersion(v int) + + // CorrelationId returns the trace correlation ID. Propagated from the + // originating command (or external trigger) through every event and + // downstream command it causes. Empty if not yet set. + CorrelationId() guid.Guid + + // CausationId returns the ID of the message that directly caused this + // event. For an event emitted by a command handler, this is the + // command's ID. Empty if not yet set. + CausationId() guid.Guid + + // OccurredAt is the wall-clock moment the domain modelled the event as + // having occurred. Distinct from "the time the event was persisted" — + // the domain owns this timestamp. + OccurredAt() time.Time + + // WithMetadata stamps correlation/causation/occurredAt onto the event, + // returning the event for fluent chaining. Used by the command handler + // or framework integration code, not by aggregates themselves. + WithMetadata(correlationId, causationId guid.Guid, occurredAt time.Time) +} + +// IntegrationEvent is a marker interface for events intended to cross +// service boundaries on a message bus. Domain events stay internal to the +// aggregate; integration events are the cross-context contract. +// +// Implementations typically wrap (or translate from) a domain event. The +// integrationevents ACL in a service is responsible for that translation. +type IntegrationEvent interface { + Event + + // EventType is the wire-stable name used to route the event to consumers + // in other services (e.g. "com.inshur.policy.created"). Distinct from + // the Go type name so wire schemas can outlive Go renames. + EventType() string } +// BaseEvent is the embeddable base type for events. Consumers embed it as +// *BaseEvent in their event structs: +// +// type InventoryItemCreated struct { +// *conqueress.BaseEvent +// Id guid.Guid +// Name string +// } type BaseEvent struct { - MessageId string `json:"message_id"` - Ver int `json:"version"` + MessageId string `json:"message_id"` + Ver int `json:"version"` + Correlation string `json:"correlation_id,omitempty"` + Causation string `json:"causation_id,omitempty"` + Occurred time.Time `json:"occurred_at,omitempty"` } +// Version returns the event's stream-position. func (b *BaseEvent) Version() int { return b.Ver } +// MsgId returns the event's stable identity. func (b *BaseEvent) MsgId() guid.Guid { return guid.MustFromString(b.MessageId) } +// WithVersion sets the event's stream-position. Called by the event store. func (b *BaseEvent) WithVersion(v int) { b.Ver = v } +// CorrelationId returns the trace correlation ID, or guid.Empty if unset. +func (b *BaseEvent) CorrelationId() guid.Guid { + if b.Correlation == "" { + return guid.Empty + } + id, err := guid.FromString(b.Correlation) + if err != nil { + return guid.Empty + } + return id +} + +// CausationId returns the ID of the message that caused this event, or +// guid.Empty if unset. +func (b *BaseEvent) CausationId() guid.Guid { + if b.Causation == "" { + return guid.Empty + } + id, err := guid.FromString(b.Causation) + if err != nil { + return guid.Empty + } + return id +} + +// OccurredAt returns the domain wall-clock time stamped onto the event. +func (b *BaseEvent) OccurredAt() time.Time { + return b.Occurred +} + +// WithMetadata stamps correlation/causation/occurredAt onto the event in +// place. Typically called by the command handler that emitted the event, +// before the repository persists it. +func (b *BaseEvent) WithMetadata(correlationId, causationId guid.Guid, occurredAt time.Time) { + b.Correlation = correlationId.String() + b.Causation = causationId.String() + b.Occurred = occurredAt +} + func defaultBaseEvent() *BaseEvent { return &BaseEvent{Ver: -1, MessageId: guid.New().String()} } -// NewEvent creates a new instance of the specified type T and populates its BaseEvent field if present and settable. -// T cannot be a pointer +// NewEvent constructs an instance of T and populates its embedded BaseEvent +// field if one is present and settable. T must not be a pointer type. +// +// Optional setters can supply field values inline: +// +// created := conqueress.NewEvent[InventoryItemCreated](func(e *InventoryItemCreated) { +// e.Id = id +// e.Name = name +// }) func NewEvent[T any](setters ...func(*T)) T { var t T - // Determine if T is a pointer type tType := reflect.TypeOf(t) - isPtr := tType.Kind() == reflect.Ptr - - if isPtr { + if tType.Kind() == reflect.Ptr { panic("T cannot be a pointer") } - // Create an instance of T - var tValue reflect.Value - // Create a new instance of T as a value - tValue = reflect.New(tType).Elem() - - // Initialize the BaseEvent + tValue := reflect.New(tType).Elem() baseEvent := defaultBaseEvent() - // Access the underlying value (for setting fields) - tValueElem := tValue - - // Iterate over fields of T and set BaseEvent if applicable - for i := 0; i < tValueElem.NumField(); i++ { - field := tValueElem.Type().Field(i) + for i := 0; i < tValue.NumField(); i++ { + field := tValue.Type().Field(i) if field.Name == "BaseEvent" { - fieldValue := tValueElem.FieldByName("BaseEvent") + fieldValue := tValue.FieldByName("BaseEvent") if fieldValue.CanSet() { if field.Type.Kind() == reflect.Ptr { fieldValue.Set(reflect.ValueOf(baseEvent)) @@ -82,3 +185,45 @@ func NewEvent[T any](setters ...func(*T)) T { return outputValue } + +// Command is the marker interface for commands accepted by a Mediator. +// Plain structs satisfy it without any embedded base; embed *BaseCommand +// if you want correlation/causation/createdAt for free. +type Command interface{} + +// BaseCommand is the embeddable base for a command that needs to carry +// correlation/causation through the framework. +type BaseCommand struct { + CommandId string `json:"command_id"` + Correlation string `json:"correlation_id"` + Causation string `json:"causation_id"` + CreatedAt time.Time `json:"created_at"` +} + +// NewBaseCommand constructs a BaseCommand with a fresh ID and now() timestamp. +// The caller supplies correlation and causation; for an externally-triggered +// command (e.g. from HTTP), set correlation to a fresh ID and causation to +// the same value (or to the upstream trace ID). +func NewBaseCommand(correlation, causation guid.Guid) BaseCommand { + return BaseCommand{ + CommandId: guid.New().String(), + Correlation: correlation.String(), + Causation: causation.String(), + CreatedAt: time.Now().UTC(), + } +} + +// Id returns the command's stable identity. +func (c BaseCommand) Id() guid.Guid { + return guid.MustFromString(c.CommandId) +} + +// CorrelationId returns the command's trace correlation ID. +func (c BaseCommand) CorrelationId() guid.Guid { + return guid.MustFromString(c.Correlation) +} + +// CausationId returns the ID of the message that caused this command. +func (c BaseCommand) CausationId() guid.Guid { + return guid.MustFromString(c.Causation) +} diff --git a/conqueress/eventing_test.go b/conqueress/eventing_test.go index 9edea23..e8468ff 100644 --- a/conqueress/eventing_test.go +++ b/conqueress/eventing_test.go @@ -1,6 +1,12 @@ package conqueress -import "testing" +import ( + "testing" + "time" + + "github.com/iamkoch/conqueress/guid" + "github.com/stretchr/testify/assert" +) type IsEvent struct { *BaseEvent @@ -51,3 +57,39 @@ func TestNewEventCreationWithModifiers(t *testing.T) { // t.Error("event.Ver should be -1") // } //} + +func TestBaseEvent_WithMetadataPopulatesCorrelationCausationAndOccurredAt(t *testing.T) { + correlation := guid.New() + causation := guid.New() + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + + evt := NewEvent[IsEvent](func(e *IsEvent) { + e.Name = "widget" + }) + evt.WithMetadata(correlation, causation, now) + + assert.Equal(t, correlation, evt.CorrelationId()) + assert.Equal(t, causation, evt.CausationId()) + assert.Equal(t, now, evt.OccurredAt()) + assert.Equal(t, "widget", evt.Name) +} + +func TestBaseEvent_DefaultsReturnEmptyGuidsAndZeroTime(t *testing.T) { + evt := NewEvent[IsEvent]() + + assert.Equal(t, guid.Empty, evt.CorrelationId()) + assert.Equal(t, guid.Empty, evt.CausationId()) + assert.True(t, evt.OccurredAt().IsZero()) +} + +func TestNewBaseCommand_PopulatesIdAndCorrelationAndCausation(t *testing.T) { + correlation := guid.New() + causation := guid.New() + + cmd := NewBaseCommand(correlation, causation) + + assert.NotEqual(t, guid.Empty, cmd.Id()) + assert.Equal(t, correlation, cmd.CorrelationId()) + assert.Equal(t, causation, cmd.CausationId()) + assert.False(t, cmd.CreatedAt.IsZero()) +} diff --git a/conqueress/mediator.go b/conqueress/mediator.go index 883ba68..2ac13bd 100644 --- a/conqueress/mediator.go +++ b/conqueress/mediator.go @@ -9,9 +9,6 @@ import ( type CommandHandler func(cmd Command) error type EventProcessor func(evt Event) error -type Command interface { -} - // Mediator dispatches commands to their registered handlers and publishes // events to their registered processors. // diff --git a/conqueress/mediator_test.go b/conqueress/mediator_test.go index 3b6185b..db56ae2 100644 --- a/conqueress/mediator_test.go +++ b/conqueress/mediator_test.go @@ -2,11 +2,11 @@ package conqueress import ( "fmt" - "github.com/iamkoch/conqueress/guid" - "github.com/iamkoch/ensure" - "github.com/stretchr/testify/assert" "testing" "time" + + "github.com/iamkoch/ensure" + "github.com/stretchr/testify/assert" ) func TestCommandDispatch(t *testing.T) { @@ -140,21 +140,12 @@ func TestPublish(t *testing.T) { }, t) } +// TestEvent embeds *BaseEvent so it satisfies the full Event interface +// (MsgId, Version, WithVersion, CorrelationId, CausationId, OccurredAt, +// WithMetadata) for free. Tests construct it via NewEvent[TestEvent] so the +// embedded base is populated. type TestEvent struct { -} - -func (t TestEvent) MsgId() guid.Guid { - //TODO implement me - panic("implement me") -} - -func (t TestEvent) WithVersion(v int) { - //TODO implement me - panic("implement me") -} - -func (t TestEvent) Version() int { - panic("implement me") + *BaseEvent } type TestCmd struct { From aaa53bc4942b9f2aad21bc289d1e36883a9be6f9 Mon Sep 17 00:00:00 2001 From: Antony Koch Date: Fri, 5 Jun 2026 14:42:23 +0100 Subject: [PATCH 05/14] feat: HandleCommand[T] typed handlers, IntegrationEventPublisher, README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the typed function-alias surface that mirrors IntelAgent.Framework's HandleCommand delegate: HandleCommand[T any] — typed command handler HandleQuery[Q, R any] — typed query handler (read-side, no causation) HandleEvent[T Event] — typed event handler IntegrationEventPublisher — outbound port for cross-service publishing These let an application layer compose handlers via factory functions that close over their dependencies — useful when you don't want to go through the reflection-based Mediator. The Mediator remains for cases where loose in-process routing is wanted. handlers_test.go covers: - factory composition of a typed HandleCommand - error propagation - IntegrationEventPublisher accepting any IntegrationEvent README rewritten end-to-end with a quick-start walkthrough covering aggregate definition, command handler composition, and the domain-event-to-integration-event translation pattern. --- README.md | 178 +++++++++++++++++++++++++++++++++++- conqueress/handlers.go | 50 ++++++++++ conqueress/handlers_test.go | 74 +++++++++++++++ 3 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 conqueress/handlers.go create mode 100644 conqueress/handlers_test.go diff --git a/README.md b/README.md index d427bc1..66e622e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,178 @@ # Conqueress -## Background -This is an attempt at a ports-and-adapters style CQRS framework for use with Go. It borrows heavily from the dot net space, and as such may not be the most idiomatic Go solution out there. That being said, I think it exhibits the core values of a hexagonally architected solution, especially with the hot-swappable persistence store. +A small ports-and-adapters CQRS / event-sourcing kit for Go. Inspired by Greg +Young's "simplest possible thing" talks and the C# pattern at +[`iamkoch/platform`](https://github.com/iamkoch/platform/tree/main/libraries/IntelAgent.Framework). + +## What's in the box + +### `conqueress/` + +The core. Domain primitives, dispatcher, eventing. + +- **`Event` interface + `BaseEvent`** — events carry their ID, version, + correlation, causation and `OccurredAt`. Consumers embed `*BaseEvent` for + free implementations. +- **`IntegrationEvent` interface** — extends `Event` with `EventType() string` + for cross-language / cross-service wire stability. Distinct from domain + events that stay inside the aggregate. +- **`Command` (marker) + `BaseCommand`** — commands optionally carry their + own ID, correlation, causation and createdAt; embed `BaseCommand` for the + free implementation. +- **`HandleCommand[T]`, `HandleQuery[Q,R]`, `HandleEvent[T]`** — typed + function aliases for handler composition. Factory functions close over + dependencies and return the right handler shape. +- **`IntegrationEventPublisher` interface** — the outbound port for + publishing integration events to a message bus. +- **`Mediator`** — reflection-based command/event dispatcher. Use it for + in-process routing when you want loose coupling; use the typed handlers + above when you want statically-typed factory composition. + +### `conqueress/domain/` + +- **`AggregateRootBase[TID]`** — embed this in your aggregate. The base + tracks uncommitted changes and an injected `innerApply` callback. +- **Dispatch pattern**: each aggregate writes its own + `handleEvent(e cqrs.Event)` method containing a `switch evt := e.(type)` + over the event types it handles, then calls `SetInnerApply(handleEvent)` + at construction. No reflection in the dispatch path. + +### `conqueress/eventstore/` + +- **`Repository[T]` + `GenericIDRepository[T, TID]`** — `GetById(id)` / + `Save(aggregate, expectedVersion)` with optimistic concurrency control. +- **`IEventStore` / `IGenericIDEventStore[TID]`** — the persistence port. +- **`inmemory/`** — in-process implementation for tests. + +### `conqueress/guid/` + +- **`guid.Guid`** — xid-backed ID type with `New()` / `FromString` / + `MustFromString`. + +### Sibling Mongo + Firestore implementations + +- **`conqueress-mongo/`** — MongoDB-backed event store with transactional + saves. +- **`conqueress-firestore/`** — Firestore-backed event store. + +## Quick start + +### Define an aggregate + +```go +package inventory + +import ( + cqrs "github.com/iamkoch/conqueress" + "github.com/iamkoch/conqueress/domain" + "github.com/iamkoch/conqueress/guid" +) + +type Item struct { + domain.AggregateRootBase[guid.Guid] + name string +} + +type ItemCreated struct { + *cqrs.BaseEvent + Id guid.Guid + Name string +} + +type ItemRenamed struct { + *cqrs.BaseEvent + NewName string +} + +func NewItem(id guid.Guid, name string) *Item { + item := &Item{AggregateRootBase: domain.NewAggregate[guid.Guid]()} + item.SetInnerApply(item.handleEvent) + item.ApplyChange(cqrs.NewEvent[ItemCreated](func(e *ItemCreated) { + e.Id = id + e.Name = name + })) + return item +} + +// handleEvent is the type-switch the consumer owns. The base struct's +// ApplyChange routes every event through this method. +func (i *Item) handleEvent(e cqrs.Event) { + switch evt := e.(type) { + case ItemCreated: + i.SetId(evt.Id) + i.SetVersion(evt.Ver) + i.name = evt.Name + case ItemRenamed: + i.name = evt.NewName + i.SetVersion(evt.Ver) + } +} + +func (i *Item) Rename(name string) { + i.ApplyChange(cqrs.NewEvent[ItemRenamed](func(e *ItemRenamed) { + e.NewName = name + })) +} +``` + +### Compose a command handler + +```go +func MakeCreateItemHandler(repo Repository) cqrs.HandleCommand[CreateItem] { + return func(ctx context.Context, cmd CreateItem, correlation, causation guid.Guid) error { + item := NewItem(guid.New(), cmd.Name) + return repo.Save(ctx, item, -1, correlation, causation) + } +} +``` + +The HTTP-command-handler module receives the typed `HandleCommand[CreateItem]` +at construction time and invokes it per request. + +### Map a domain event to an integration event + +The integrationevents ACL in your service subscribes to internal `ItemCreated` +events and translates them into the public wire-stable event: + +```go +type ItemCreatedIntegrationEvent struct { + *cqrs.BaseEvent + ItemId string + Name string +} + +func (ItemCreatedIntegrationEvent) EventType() string { + return "com.example.inventory.item-created" +} + +func (a *Acl) OnItemCreated(ctx context.Context, evt ItemCreated, correlationId guid.Guid) error { + integration := cqrs.NewEvent[ItemCreatedIntegrationEvent](func(e *ItemCreatedIntegrationEvent) { + e.ItemId = evt.Id.String() + e.Name = evt.Name + }) + return a.publisher.Publish(ctx, integration, correlationId, evt.MsgId()) +} +``` + +## Architecture + +- **No central God-object.** The Mediator exists as a convenience for + in-process dispatch; you don't have to use it. Typed function aliases + (`HandleCommand[T]`, `HandleEvent[T]`) let you compose handlers via + factories that close over their dependencies. +- **Domain Event vs Integration Event** as first-class. Domain events stay + inside the aggregate's bounded context. Integration events cross service + boundaries via a published bus. The translation between the two is + explicit and lives in your ACL layer. +- **Correlation, causation, occurredAt on every event.** No envelope wrapper + required; the metadata lives on the event itself. +- **Generic aggregate IDs.** `AggregateRootBase[TID]` and + `GenericIDRepository[T, TID]` accept arbitrary ID types. `guid.Guid` is + the common case; composite/value-typed IDs (e.g. `PolicyVersion`) are + first-class. + +## Status + +Experimental. The framework is being actively shaped by use in extracting a +bind capability from a larger Spring service into a Go service. Expect the +API to evolve; pin to a commit SHA in your `go.mod` and update deliberately. diff --git a/conqueress/handlers.go b/conqueress/handlers.go new file mode 100644 index 0000000..b196f0b --- /dev/null +++ b/conqueress/handlers.go @@ -0,0 +1,50 @@ +// This file defines the function-type aliases used for typed handler +// composition in the style of IntelAgent.Framework's HandleCommand +// delegate. +// +// The mediator gives you reflection-based dispatch by Go type. These typed +// function aliases let you compose handlers via factory functions that close +// over their dependencies — useful when you don't need a central dispatcher +// and just want statically-typed command and event handling at the +// application-layer boundary. +package conqueress + +import ( + "context" + + "github.com/iamkoch/conqueress/guid" +) + +// HandleCommand is a typed command-handler function. A factory in the +// application layer composes one of these by closing over its dependencies +// (repository, ACLs, logger, etc.): +// +// func MakeCreatePolicyHandler(repo PolicyRepo, log Logger) conqueress.HandleCommand[CreatePolicy] { +// return func(ctx context.Context, cmd CreatePolicy, correlation, causation guid.Guid) error { +// log.Info("creating policy", "correlation", correlation) +// policy := NewPolicy(cmd) +// return repo.Save(ctx, policy, -1, correlation, causation) +// } +// } +// +// The HTTP-command-handler module receives the typed handler at construction +// time and calls it with the parsed command from the request body. +type HandleCommand[T any] func(ctx context.Context, cmd T, correlationId, causationId guid.Guid) error + +// HandleQuery is a typed query-handler function. Symmetric with HandleCommand +// but returns a result and carries no causation (queries don't cause domain +// changes; they read state). +type HandleQuery[Q any, R any] func(ctx context.Context, query Q, correlationId guid.Guid) (R, error) + +// HandleEvent is a typed event-handler function. An aggregate emits a domain +// event; an in-process subscriber (an ACL, a projection updater) consumes it +// via one of these. Distinct from the EventProcessor type used by the +// reflection-based Mediator: HandleEvent[T] is statically typed. +type HandleEvent[T Event] func(ctx context.Context, evt T, correlationId guid.Guid) error + +// IntegrationEventPublisher publishes integration events onto the cross- +// service message bus. The integrationevents ACL implements this; the +// application layer depends on the interface, not a concrete bus. +type IntegrationEventPublisher interface { + Publish(ctx context.Context, evt IntegrationEvent, correlationId, causationId guid.Guid) error +} diff --git a/conqueress/handlers_test.go b/conqueress/handlers_test.go new file mode 100644 index 0000000..6cae63b --- /dev/null +++ b/conqueress/handlers_test.go @@ -0,0 +1,74 @@ +package conqueress + +import ( + "context" + "errors" + "testing" + + "github.com/iamkoch/conqueress/guid" + "github.com/stretchr/testify/assert" +) + +// MakeCreateThingHandler is a representative factory composition: +// closes over a fake repository and returns a typed HandleCommand[T]. +func makeCreateThingHandler(saved *[]string) HandleCommand[createThing] { + return func(_ context.Context, cmd createThing, _, _ guid.Guid) error { + if cmd.Name == "" { + return errors.New("name required") + } + *saved = append(*saved, cmd.Name) + return nil + } +} + +type createThing struct { + BaseCommand + Name string +} + +func TestHandleCommand_TypedHandlerComposition(t *testing.T) { + var saved []string + handle := makeCreateThingHandler(&saved) + + err := handle(context.Background(), createThing{Name: "widget"}, guid.New(), guid.New()) + + assert.NoError(t, err) + assert.Equal(t, []string{"widget"}, saved) +} + +func TestHandleCommand_PropagatesFailureFromHandler(t *testing.T) { + var saved []string + handle := makeCreateThingHandler(&saved) + + err := handle(context.Background(), createThing{Name: ""}, guid.New(), guid.New()) + + assert.Error(t, err) + assert.Empty(t, saved) +} + +// fakeIntegrationEvent satisfies IntegrationEvent for the publisher test. +type fakeIntegrationEvent struct { + *BaseEvent +} + +func (fakeIntegrationEvent) EventType() string { return "test.fake" } + +type fakePublisher struct { + published []IntegrationEvent +} + +func (p *fakePublisher) Publish(_ context.Context, evt IntegrationEvent, _, _ guid.Guid) error { + p.published = append(p.published, evt) + return nil +} + +func TestIntegrationEventPublisher_AcceptsAnyIntegrationEvent(t *testing.T) { + var pub IntegrationEventPublisher = &fakePublisher{} + evt := NewEvent[fakeIntegrationEvent]() + + err := pub.Publish(context.Background(), evt, guid.New(), guid.New()) + + assert.NoError(t, err) + assert.Equal(t, 1, len(pub.(*fakePublisher).published)) + assert.Equal(t, "test.fake", pub.(*fakePublisher).published[0].(IntegrationEvent).EventType()) +} From 9bd2eee03943fc98f1c8124fcbcdcf2966c4616e Mon Sep 17 00:00:00 2001 From: Antony Koch Date: Fri, 5 Jun 2026 14:46:23 +0100 Subject: [PATCH 06/14] fix(layout): promote framework module to repo root The go.mod declaring 'module github.com/iamkoch/conqueress' was living in the '/conqueress/' subdirectory. This worked locally but broke external consumption: when a downstream service ran 'go get github.com/iamkoch/conqueress', Go found no module at the repo root and could not resolve the package paths. Move the framework's source files to the repo root so the module's location matches its declared path. Tests pass after the move because all internal imports reference the module path, not the directory. Sibling modules (conqueress-mongo, conqueress-firestore, reflection, sample_domain, tests) remain in their subdirectories with their own go.mod files; they were already not externally consumable due to similar path mismatches, but addressing those is out of scope here. --- {conqueress/domain => domain}/aggregate.go | 0 {conqueress/domain => domain}/aggregate_test.go | 0 conqueress/eventing.go => eventing.go | 0 conqueress/eventing_test.go => eventing_test.go | 0 {conqueress/eventstore => eventstore}/inmemory/repository_test.go | 0 {conqueress/eventstore => eventstore}/inmemory/store.go | 0 {conqueress/eventstore => eventstore}/repository.go | 0 conqueress/go.mod => go.mod | 0 conqueress/go.sum => go.sum | 0 {conqueress/guid => guid}/guid.go | 0 {conqueress/guid => guid}/guid_test.go | 0 conqueress/handlers.go => handlers.go | 0 conqueress/handlers_test.go => handlers_test.go | 0 conqueress/mediator.go => mediator.go | 0 conqueress/mediator_test.go => mediator_test.go | 0 conqueress/projections.go => projections.go | 0 16 files changed, 0 insertions(+), 0 deletions(-) rename {conqueress/domain => domain}/aggregate.go (100%) rename {conqueress/domain => domain}/aggregate_test.go (100%) rename conqueress/eventing.go => eventing.go (100%) rename conqueress/eventing_test.go => eventing_test.go (100%) rename {conqueress/eventstore => eventstore}/inmemory/repository_test.go (100%) rename {conqueress/eventstore => eventstore}/inmemory/store.go (100%) rename {conqueress/eventstore => eventstore}/repository.go (100%) rename conqueress/go.mod => go.mod (100%) rename conqueress/go.sum => go.sum (100%) rename {conqueress/guid => guid}/guid.go (100%) rename {conqueress/guid => guid}/guid_test.go (100%) rename conqueress/handlers.go => handlers.go (100%) rename conqueress/handlers_test.go => handlers_test.go (100%) rename conqueress/mediator.go => mediator.go (100%) rename conqueress/mediator_test.go => mediator_test.go (100%) rename conqueress/projections.go => projections.go (100%) diff --git a/conqueress/domain/aggregate.go b/domain/aggregate.go similarity index 100% rename from conqueress/domain/aggregate.go rename to domain/aggregate.go diff --git a/conqueress/domain/aggregate_test.go b/domain/aggregate_test.go similarity index 100% rename from conqueress/domain/aggregate_test.go rename to domain/aggregate_test.go diff --git a/conqueress/eventing.go b/eventing.go similarity index 100% rename from conqueress/eventing.go rename to eventing.go diff --git a/conqueress/eventing_test.go b/eventing_test.go similarity index 100% rename from conqueress/eventing_test.go rename to eventing_test.go diff --git a/conqueress/eventstore/inmemory/repository_test.go b/eventstore/inmemory/repository_test.go similarity index 100% rename from conqueress/eventstore/inmemory/repository_test.go rename to eventstore/inmemory/repository_test.go diff --git a/conqueress/eventstore/inmemory/store.go b/eventstore/inmemory/store.go similarity index 100% rename from conqueress/eventstore/inmemory/store.go rename to eventstore/inmemory/store.go diff --git a/conqueress/eventstore/repository.go b/eventstore/repository.go similarity index 100% rename from conqueress/eventstore/repository.go rename to eventstore/repository.go diff --git a/conqueress/go.mod b/go.mod similarity index 100% rename from conqueress/go.mod rename to go.mod diff --git a/conqueress/go.sum b/go.sum similarity index 100% rename from conqueress/go.sum rename to go.sum diff --git a/conqueress/guid/guid.go b/guid/guid.go similarity index 100% rename from conqueress/guid/guid.go rename to guid/guid.go diff --git a/conqueress/guid/guid_test.go b/guid/guid_test.go similarity index 100% rename from conqueress/guid/guid_test.go rename to guid/guid_test.go diff --git a/conqueress/handlers.go b/handlers.go similarity index 100% rename from conqueress/handlers.go rename to handlers.go diff --git a/conqueress/handlers_test.go b/handlers_test.go similarity index 100% rename from conqueress/handlers_test.go rename to handlers_test.go diff --git a/conqueress/mediator.go b/mediator.go similarity index 100% rename from conqueress/mediator.go rename to mediator.go diff --git a/conqueress/mediator_test.go b/mediator_test.go similarity index 100% rename from conqueress/mediator_test.go rename to mediator_test.go diff --git a/conqueress/projections.go b/projections.go similarity index 100% rename from conqueress/projections.go rename to projections.go From 1898043cdcf63d1faba3cff39e5de2d60f3ff655 Mon Sep 17 00:00:00 2001 From: Antony Koch Date: Fri, 5 Jun 2026 15:16:00 +0100 Subject: [PATCH 07/14] ci: GitHub Actions, race-free Publish, README rewrite CI: - New .github/workflows/test.yml runs go build, go vet and 'go test -race -count=1 -coverprofile=...' across Go 1.23 and 1.24 on every push and pull request, with a coverage summary printed at the end. Race fix: - Mediator.Publish now waits for every processor goroutine via sync.WaitGroup before returning. Previously fire-and-forget, which meant downstream side effects were untestable and any handler error was silently swallowed. Test sleeps removed; tests now read from the resp channel for command-dispatch synchronisation. README: - Rewritten end-to-end in British English with the AI prose tells scrubbed (no em dashes, no 'not x but y' constructs, no marketing vocabulary). Adds a quick test-runner section and a CI pointer. --- .github/workflows/test.yml | 38 ++++++++++ README.md | 140 ++++++++++++++++++++----------------- mediator.go | 35 ++++++---- mediator_test.go | 40 +++++------ 4 files changed, 156 insertions(+), 97 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1f790ef --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: go ${{ matrix.go-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go-version: ['1.23', '1.24'] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + check-latest: true + + - name: build + run: go build ./... + + - name: vet + run: go vet ./... + + - name: test + run: go test -race -count=1 -coverprofile=coverage.out ./... + + - name: coverage summary + run: go tool cover -func=coverage.out | tail -20 diff --git a/README.md b/README.md index 66e622e..3378d07 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,57 @@ # Conqueress -A small ports-and-adapters CQRS / event-sourcing kit for Go. Inspired by Greg -Young's "simplest possible thing" talks and the C# pattern at +A small CQRS and event-sourcing kit for Go in the ports-and-adapters style. +Inspired by Greg Young's "simplest possible thing" talks and the C# pattern in [`iamkoch/platform`](https://github.com/iamkoch/platform/tree/main/libraries/IntelAgent.Framework). -## What's in the box +## What's here -### `conqueress/` +### Core (repository root) -The core. Domain primitives, dispatcher, eventing. +Domain primitives, dispatcher, eventing. -- **`Event` interface + `BaseEvent`** — events carry their ID, version, - correlation, causation and `OccurredAt`. Consumers embed `*BaseEvent` for - free implementations. -- **`IntegrationEvent` interface** — extends `Event` with `EventType() string` - for cross-language / cross-service wire stability. Distinct from domain - events that stay inside the aggregate. -- **`Command` (marker) + `BaseCommand`** — commands optionally carry their - own ID, correlation, causation and createdAt; embed `BaseCommand` for the - free implementation. -- **`HandleCommand[T]`, `HandleQuery[Q,R]`, `HandleEvent[T]`** — typed - function aliases for handler composition. Factory functions close over - dependencies and return the right handler shape. -- **`IntegrationEventPublisher` interface** — the outbound port for - publishing integration events to a message bus. -- **`Mediator`** — reflection-based command/event dispatcher. Use it for - in-process routing when you want loose coupling; use the typed handlers - above when you want statically-typed factory composition. +- `Event` interface and `BaseEvent`. Events carry an ID, version, correlation, + causation and `OccurredAt`. Consumers embed `*BaseEvent` to satisfy the + interface. +- `IntegrationEvent` interface. Extends `Event` with `EventType() string` for + cross-language wire stability when an event has to leave the service. +- `Command` (marker) and `BaseCommand`. Commands optionally carry an ID, + correlation, causation and `CreatedAt`. Embed `BaseCommand` when you want + those fields populated. +- `HandleCommand[T]`, `HandleQuery[Q,R]`, `HandleEvent[T]`. Typed function + aliases for handler composition. Factory functions close over dependencies + and return one of these. +- `IntegrationEventPublisher` interface. Outbound port for publishing + integration events to a message bus. +- `Mediator`. Reflection-based command and event dispatcher. Optional. -### `conqueress/domain/` +### `domain/` -- **`AggregateRootBase[TID]`** — embed this in your aggregate. The base - tracks uncommitted changes and an injected `innerApply` callback. -- **Dispatch pattern**: each aggregate writes its own - `handleEvent(e cqrs.Event)` method containing a `switch evt := e.(type)` - over the event types it handles, then calls `SetInnerApply(handleEvent)` - at construction. No reflection in the dispatch path. +`AggregateRootBase[TID]`. Embed in your aggregate. The base tracks uncommitted +changes and an injected `innerApply` callback. -### `conqueress/eventstore/` +Dispatch is a normal Go type switch that the consumer owns. Each aggregate +declares a `handleEvent(e cqrs.Event)` method containing a +`switch evt := e.(type)` over the events it handles, then calls +`SetInnerApply(handleEvent)` at construction. The base routes every event +through that callback. No reflection in the dispatch path. -- **`Repository[T]` + `GenericIDRepository[T, TID]`** — `GetById(id)` / - `Save(aggregate, expectedVersion)` with optimistic concurrency control. -- **`IEventStore` / `IGenericIDEventStore[TID]`** — the persistence port. -- **`inmemory/`** — in-process implementation for tests. +### `eventstore/` -### `conqueress/guid/` +- `Repository[T]` and `GenericIDRepository[T, TID]`. `GetById(id)` and + `Save(aggregate, expectedVersion)` with optimistic concurrency. +- `IEventStore` and `IGenericIDEventStore[TID]`. The persistence port. +- `inmemory/`. In-process implementation for tests. -- **`guid.Guid`** — xid-backed ID type with `New()` / `FromString` / - `MustFromString`. +### `guid/` -### Sibling Mongo + Firestore implementations +`guid.Guid`. xid-backed ID type with `New()`, `FromString` and +`MustFromString`. -- **`conqueress-mongo/`** — MongoDB-backed event store with transactional - saves. -- **`conqueress-firestore/`** — Firestore-backed event store. +### Sibling adapters + +- `conqueress-mongo/`. MongoDB-backed event store with transactional saves. +- `conqueress-firestore/`. Firestore-backed event store. ## Quick start @@ -94,8 +92,6 @@ func NewItem(id guid.Guid, name string) *Item { return item } -// handleEvent is the type-switch the consumer owns. The base struct's -// ApplyChange routes every event through this method. func (i *Item) handleEvent(e cqrs.Event) { switch evt := e.(type) { case ItemCreated: @@ -126,13 +122,13 @@ func MakeCreateItemHandler(repo Repository) cqrs.HandleCommand[CreateItem] { } ``` -The HTTP-command-handler module receives the typed `HandleCommand[CreateItem]` -at construction time and invokes it per request. +The HTTP command-handler module receives the typed +`HandleCommand[CreateItem]` at construction time and invokes it per request. -### Map a domain event to an integration event +### Translate a domain event to an integration event -The integrationevents ACL in your service subscribes to internal `ItemCreated` -events and translates them into the public wire-stable event: +The integration-events ACL in your service subscribes to internal +`ItemCreated` events and publishes the wire-stable equivalent: ```go type ItemCreatedIntegrationEvent struct { @@ -156,23 +152,37 @@ func (a *Acl) OnItemCreated(ctx context.Context, evt ItemCreated, correlationId ## Architecture -- **No central God-object.** The Mediator exists as a convenience for - in-process dispatch; you don't have to use it. Typed function aliases - (`HandleCommand[T]`, `HandleEvent[T]`) let you compose handlers via - factories that close over their dependencies. -- **Domain Event vs Integration Event** as first-class. Domain events stay - inside the aggregate's bounded context. Integration events cross service - boundaries via a published bus. The translation between the two is - explicit and lives in your ACL layer. -- **Correlation, causation, occurredAt on every event.** No envelope wrapper - required; the metadata lives on the event itself. -- **Generic aggregate IDs.** `AggregateRootBase[TID]` and - `GenericIDRepository[T, TID]` accept arbitrary ID types. `guid.Guid` is - the common case; composite/value-typed IDs (e.g. `PolicyVersion`) are - first-class. +There is no central God-object. The Mediator is one option for in-process +dispatch. You can ignore it and compose handlers via factory functions that +return `HandleCommand[T]`. + +Domain events stay inside the aggregate's bounded context. Integration events +cross service boundaries via a published bus. The translation between the two +lives in your ACL layer and is explicit. + +Every event carries correlation, causation and `OccurredAt`. No envelope +wrapper is required; the metadata sits on the event itself. + +Aggregate IDs are generic. `AggregateRootBase[TID]` and +`GenericIDRepository[T, TID]` accept any ID type. `guid.Guid` is the common +case. Composite or value-typed IDs such as a `(policyId, version)` tuple work +the same way. + +## Tests + +```sh +go test -race -count=1 ./... +``` + +CI runs the same command across Go 1.23 and 1.24 on every push and pull +request. See `.github/workflows/test.yml`. ## Status -Experimental. The framework is being actively shaped by use in extracting a -bind capability from a larger Spring service into a Go service. Expect the -API to evolve; pin to a commit SHA in your `go.mod` and update deliberately. +Experimental. The API is being shaped by use in extracting a bind capability +from a Spring service into Go. Pin to a commit SHA in your `go.mod` and +update deliberately. + +## Licence + +See `LICENSE`. diff --git a/mediator.go b/mediator.go index 2ac13bd..c75ea91 100644 --- a/mediator.go +++ b/mediator.go @@ -4,6 +4,7 @@ import ( "errors" "log/slog" "reflect" + "sync" ) type CommandHandler func(cmd Command) error @@ -166,20 +167,30 @@ func (m *Mediator) DispatchSync(cmd Command, syncResp chan CommandProcessingErro return errors.New("no handler registered") } -// Publish fans the event out to all registered processors concurrently. -// Each processor runs on its own goroutine. Errors from individual processors -// are not surfaced to the caller; instrument inside each processor if you -// need observability. +// Publish fans the event out to all registered processors concurrently and +// blocks until every processor has returned. Errors from individual +// processors are not surfaced to the caller; instrument inside each +// processor if you need observability. +// +// Blocking on completion is deliberate. Fire-and-forget publish makes +// downstream side effects untestable and hides handler errors. If you want +// non-blocking semantics, wrap the call in a goroutine at the call site. func (m *Mediator) Publish(evt Event) error { - if processors, ok := m.eventProcessors[reflect.TypeOf(evt)]; ok { - for _, processor := range processors { - go func(p EventProcessor) { - _ = p(evt) - }(processor) - } - return nil + processors, ok := m.eventProcessors[reflect.TypeOf(evt)] + if !ok { + return errors.New("no processor registered") } - return errors.New("no processor registered") + + var wg sync.WaitGroup + wg.Add(len(processors)) + for _, processor := range processors { + go func(p EventProcessor) { + defer wg.Done() + _ = p(evt) + }(processor) + } + wg.Wait() + return nil } // PublishSync runs each processor sequentially on the caller's goroutine. diff --git a/mediator_test.go b/mediator_test.go index db56ae2..e501ca8 100644 --- a/mediator_test.go +++ b/mediator_test.go @@ -3,7 +3,6 @@ package conqueress import ( "fmt" "testing" - "time" "github.com/iamkoch/ensure" "github.com/stretchr/testify/assert" @@ -13,11 +12,12 @@ func TestCommandDispatch(t *testing.T) { var ( mediator *Mediator handler *TestCmdHandler - resp = make(chan CommandProcessingError) + resp = make(chan CommandProcessingError, 1) commandDispatchError error + processingError CommandProcessingError ) - ensure.That("published commands are dispatched without delay when induce is false", func(s *ensure.Scenario) { - s.Given("a command dispatcher with induce delay false", func() { + ensure.That("commands dispatched onto the mediator are handled", func(s *ensure.Scenario) { + s.Given("a mediator", func() { mediator = NewMediator() }) @@ -26,18 +26,22 @@ func TestCommandDispatch(t *testing.T) { _ = RegisterCommandHandler[TestCmd](mediator, handler.Handle) }) - s.When("I publish a command", func() { + s.When("I dispatch a command and wait for the handler to complete", func() { commandDispatchError = mediator.Dispatch(TestCmd{ v1: "test", v2: 5, }, resp) - time.Sleep(10 * time.Millisecond) + processingError = <-resp }) - s.Then("it should not error", func() { + s.Then("the submission should not error", func() { assert.Nil(t, commandDispatchError) }) + s.And("the processing should not error", func() { + assert.Nil(t, processingError) + }) + s.And("it should be handled", func() { assert.Equal(t, 1, len(handler.received)) }) @@ -56,38 +60,35 @@ func TestCommandHandlerReturnsError(t *testing.T) { var ( mediator *Mediator handler *TestCmdHandler - resp = make(chan CommandProcessingError) + resp = make(chan CommandProcessingError, 1) commandDispatchError error + processingError CommandProcessingError ) ensure.That("command handlers that return an error bubble up the error", func(s *ensure.Scenario) { - s.Given("a command dispatcher with induce delay false", func() { + s.Given("a mediator", func() { mediator = NewMediator() }) - s.And("a command handler", func() { + s.And("a command handler that returns an error", func() { handler = &TestCmdHandler{} _ = RegisterCommandHandler[TestCmd](mediator, handler.ErrorHandle) }) - s.When("I publish a command", func() { + s.When("I dispatch a command and wait for the handler to complete", func() { commandDispatchError = mediator.Dispatch(TestCmd{ v1: "test", v2: 5, }, resp) - time.Sleep(500 * time.Millisecond) + processingError = <-resp }) s.Then("it should be handled", func() { assert.Equal(t, 1, len(handler.received)) }) - s.And("it should error", func() { - var caughtErr error - select { - case caughtErr = <-resp: - } + s.And("the submission should not error and the processing should error", func() { assert.Nil(t, commandDispatchError) - assert.NotNil(t, caughtErr) + assert.NotNil(t, processingError) }) s.And("the command should match", func() { @@ -116,8 +117,7 @@ func TestPublish(t *testing.T) { }) s.When("I publish an event", func() { - mediator.Publish(TestEvent{}) - time.Sleep(100 * time.Millisecond) + _ = mediator.Publish(TestEvent{}) }) s.Then("it should be handled", func() { From ba46e6f2a92f3ea83ba8ab7ee45260447ca47803 Mon Sep 17 00:00:00 2001 From: Antony Koch Date: Fri, 5 Jun 2026 15:23:04 +0100 Subject: [PATCH 08/14] ci: bump test matrix to Go 1.25 and 1.26 Per Go's release policy (a release is supported until two newer major releases ship), the actively supported versions are now 1.25 and 1.26. The previous 1.23/1.24 matrix was a release behind. --- .github/workflows/test.yml | 2 +- README.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f790ef..fd91d09 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - go-version: ['1.23', '1.24'] + go-version: ['1.25', '1.26'] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 3378d07..08300e6 100644 --- a/README.md +++ b/README.md @@ -174,8 +174,9 @@ the same way. go test -race -count=1 ./... ``` -CI runs the same command across Go 1.23 and 1.24 on every push and pull -request. See `.github/workflows/test.yml`. +CI runs the same command across the two actively supported Go releases +(currently 1.25 and 1.26) on every push and pull request. See +`.github/workflows/test.yml`. ## Status From 6c597deaa863305866aba8db545a27f80ac489bd Mon Sep 17 00:00:00 2001 From: Antony Koch Date: Fri, 5 Jun 2026 16:16:39 +0100 Subject: [PATCH 09/14] docs: drop private-repo and internal-work references from README --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 08300e6..3ae89a4 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ # Conqueress A small CQRS and event-sourcing kit for Go in the ports-and-adapters style. -Inspired by Greg Young's "simplest possible thing" talks and the C# pattern in -[`iamkoch/platform`](https://github.com/iamkoch/platform/tree/main/libraries/IntelAgent.Framework). +Inspired by Greg Young's "simplest possible thing" talks. ## What's here @@ -180,9 +179,8 @@ CI runs the same command across the two actively supported Go releases ## Status -Experimental. The API is being shaped by use in extracting a bind capability -from a Spring service into Go. Pin to a commit SHA in your `go.mod` and -update deliberately. +Experimental. The API is still evolving. Pin to a commit SHA in your `go.mod` +and update deliberately. ## Licence From 5902c8e8390d0327cbd0f610042fa9ff20663f72 Mon Sep 17 00:00:00 2001 From: Antony Koch Date: Fri, 5 Jun 2026 16:43:04 +0100 Subject: [PATCH 10/14] docs: show domain.New[T] construction in the README example The previous example used the long-hand 'embed + SetInnerApply' form for every construction site. Switch to the helper: item := domain.New[Item]() This relies on the aggregate satisfying DefaultAggregate[TID] via two one-line methods (SetBase, GetHandler), shown in the example. Trade-off is two lines of method declarations per type for one-line construction at every call site. --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ae89a4..41e3e99 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ type Item struct { name string } +// Two one-line helpers per aggregate so domain.New[Item]() can construct it. +func (i *Item) SetBase(b domain.AggregateRootBase[guid.Guid]) { i.AggregateRootBase = b } +func (i *Item) GetHandler() func(cqrs.Event) { return i.handleEvent } + type ItemCreated struct { *cqrs.BaseEvent Id guid.Guid @@ -82,8 +86,7 @@ type ItemRenamed struct { } func NewItem(id guid.Guid, name string) *Item { - item := &Item{AggregateRootBase: domain.NewAggregate[guid.Guid]()} - item.SetInnerApply(item.handleEvent) + item := domain.New[Item]() item.ApplyChange(cqrs.NewEvent[ItemCreated](func(e *ItemCreated) { e.Id = id e.Name = name @@ -110,6 +113,9 @@ func (i *Item) Rename(name string) { } ``` +For aggregates with a non-`guid.Guid` ID, use `domain.NewWithID[Item, OrderID]()` +and have `SetBase` accept the right base type. + ### Compose a command handler ```go From 6ed454d31a67bdec039c038f24d35c4b18fa48ef Mon Sep 17 00:00:00 2001 From: Antony Koch Date: Fri, 5 Jun 2026 16:49:50 +0100 Subject: [PATCH 11/14] docs: add read-model projection example to README BaseProjection, BaseProjectionHandler[T], LoadProjection[T] and SaveProjection[T] were undocumented. The example shows the canonical 'load, mutate, save' update pattern that the handler provides and notes that the delegates are the persistence port (any store works behind them). --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/README.md b/README.md index 41e3e99..5210674 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,45 @@ func MakeCreateItemHandler(repo Repository) cqrs.HandleCommand[CreateItem] { The HTTP command-handler module receives the typed `HandleCommand[CreateItem]` at construction time and invokes it per request. +### Maintain a read-model projection + +`BaseProjection` carries identity and a monotonic version. `BaseProjectionHandler[TProjection]` +wraps a `LoadProjection` and `SaveProjection` pair so the handler can express the update +as "load, mutate, save" without spelling it out at every call site: + +```go +type ItemSummary struct { + cqrs.BaseProjection + Name string +} + +type ItemSummaryHandler struct { + cqrs.BaseProjectionHandler[*ItemSummary] +} + +func NewItemSummaryHandler(load cqrs.LoadProjection[*ItemSummary], save cqrs.SaveProjection[*ItemSummary]) *ItemSummaryHandler { + return &ItemSummaryHandler{ + BaseProjectionHandler: *cqrs.NewBaseProjectionHandler(load, save, func(id guid.Guid) *ItemSummary { + p := &ItemSummary{} + p.BaseProjection = cqrs.NewBaseProjection(id, -1) + return p + }), + } +} + +func (h *ItemSummaryHandler) OnCreated(e cqrs.Event) error { + iic := e.(ItemCreated) + return h.UpdateProjection(iic.Id, iic, func(p *ItemSummary, e cqrs.Event) { + evt := e.(ItemCreated) + p.Name = evt.Name + p.IncrementVersion() + }) +} +``` + +The `LoadProjection` and `SaveProjection` delegates are the persistence port; pick any +store (Mongo, Postgres, in-memory) and supply closures that match the delegate signatures. + ### Translate a domain event to an integration event The integration-events ACL in your service subscribes to internal From f1e40a93bea57f2f53a88900b81f553bbc406070 Mon Sep 17 00:00:00 2001 From: Antony Koch Date: Mon, 8 Jun 2026 10:43:43 +0100 Subject: [PATCH 12/14] docs: add facts-versus-commands pattern note A telemetry-heavy system often has high-volume inbound writes that are observations (sensor readings, webhooks, packets) rather than decisions. Routing them through commands and aggregates triggers optimistic- concurrency contention without protecting any invariant. doc/patterns/facts-vs-commands.md captures the distinction, the criteria for when a write qualifies as a fact, the recommended pattern (idempotent upsert to a fact store on a natural key), what you give up, the anti-patterns to avoid, and references to public material that backs the guidance. README gains a Patterns section linking to it. --- README.md | 8 ++ doc/patterns/facts-vs-commands.md | 155 ++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 doc/patterns/facts-vs-commands.md diff --git a/README.md b/README.md index 5210674..09167bd 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,14 @@ CI runs the same command across the two actively supported Go releases (currently 1.25 and 1.26) on every push and pull request. See `.github/workflows/test.yml`. +## Patterns + +Guidance notes on when to reach for which mechanism live in `doc/patterns/`. + +- [Facts versus commands](doc/patterns/facts-vs-commands.md). When an + inbound write is an observation rather than a decision, route it to + idempotent durable storage rather than through commands and aggregates. + ## Status Experimental. The API is still evolving. Pin to a commit SHA in your `go.mod` diff --git a/doc/patterns/facts-vs-commands.md b/doc/patterns/facts-vs-commands.md new file mode 100644 index 0000000..d68f862 --- /dev/null +++ b/doc/patterns/facts-vs-commands.md @@ -0,0 +1,155 @@ +# Facts versus commands + +A guidance note on when to event-source an inbound write and when to treat it +as a fact that needs idempotent storage instead. + +## TL;DR + +Not every inbound write should land as a command on an aggregate. + +A **command** is a request to make a decision: "create the order", "cancel +the booking", "approve the application". The domain can reject it; the +aggregate exists to protect the invariants that justify the rejection. + +A **fact** is an observation that has already happened: a sensor reading, a +payment received, a webhook from an external system, a packet from a device. +The domain has no decision to make. The fact is either recorded or, due to +retry, recorded twice and deduplicated; in neither case is the fact rejected. + +Routing facts through commands and aggregates is the wrong mechanism. At low +volume it works and you might not notice. At high volume the impedance +mismatch surfaces as duplicate-key errors, optimistic concurrency contention, +and aggregates that bloat to tens of thousands of events without protecting +any invariant. + +## The shape of the problem + +Suppose a service accepts biometric samples from a wearable. The natural +modelling impulse, once you have a CQRS/ES skeleton, is: + +1. Sample arrives at `POST /samples`. +2. Controller issues a `RecordSampleCommand`. +3. Handler loads the `Session` aggregate, applies the command, emits a + `SampleRecorded` event. +4. Event store persists the event with optimistic concurrency on the + aggregate version. +5. Subscribers project the event into read models. + +This is the orthodox path. It also explodes when two samples for the same +session arrive concurrently. Both handlers load the session at version `N`, +both try to append `SampleRecorded` at version `N+1`, one wins, the other +retries. Under a burst (a strap drains 50,000 historical packets in one +sync; a fleet of devices opens streams at the same minute) the retries +cascade and the duplicate-key errors propagate to the caller. + +The clue that something is off: the aggregate's `Apply(SampleRecorded)` +method has no meaningful effect on aggregate state. It increments a counter +and stops. The aggregate is being used as a queue for facts, not as a +decision boundary. + +## When a write is a fact + +A write is a fact when all of these hold: + +1. **The producer is not asking permission.** The data already exists in + the real world (a measurement, a payment, a packet). Rejecting it has no + meaning; the fact is true regardless of what the service thinks. +2. **There is a natural deduplication key.** Timestamps, content hashes, + external IDs. Two arrivals with the same key are the same fact. +3. **The aggregate the orthodox path would route through enforces no + invariant on the fact.** Its `Apply` method just records the fact and + updates a counter. +4. **The volume is non-trivial.** Either steady (a stream) or bursty (a + periodic sync of accumulated history). + +If all four hold, the orthodox path is overhead with no benefit. + +## What to do instead + +Route facts to **idempotent durable storage** directly. Do not route them +through commands and aggregates. The ingestion path becomes: + +1. Fact arrives at the controller. +2. Controller writes it (or them, in a batch) to a fact store via an + idempotent upsert on the natural key. +3. Done. No aggregate load, no version, no retry loop. + +The fact store is the source of truth for that data, not a projection. Read +models that depend on the facts query the fact store (or a derived +projection of it) rather than replaying the event stream. + +Decisions stay event-sourced. In the wearable example, opening and closing +a session is a decision (it transitions the session's state, and competing +opens / closes need to be serialised); the samples accumulated within an +open session are facts. The same service uses both patterns side by side. + +## Practical guidance + +- **Pick the natural key carefully.** For a wearable, `(owner_id, + sample_timestamp)` is enough if timestamps have sufficient resolution. + For external webhooks, the upstream's event ID is usually right. For + packet streams, a content hash is robust against accidental re-sends. +- **Batch the writes.** A controller receiving 1,000 samples should do one + `UpsertBatch`, not 1,000 individual upserts. Most databases have an + efficient idiom for this: Postgres `INSERT ... ON CONFLICT`, MongoDB + `BulkWrite` with upsert, etc. +- **Index for the read patterns up front.** Fact stores are often hot on + range scans by time. Get the indexes right at creation time; backfilling + them later on a large table is expensive. +- **Don't replay the fact log into the event log.** That defeats the + purpose. The fact store is its own source of truth. +- **Keep decision aggregates small.** Once facts are out, the aggregate's + event stream is the lifecycle events (opened, configured, closed) and + nothing else. It rehydrates quickly and snapshots become unnecessary. + +## What you give up + +- **Cross-aggregate transactional consistency** between a fact and its + containing decision. A sample can land for a session that has just been + closed. Treat the closed session as a soft signal: store the sample + anyway (it really happened), surface a warning in observability, decide + in the projection layer whether to include it. +- **A single audit log of everything.** Facts live in the fact store; the + event stream has only the decisions. Reconstructing "what happened on + date X" needs to query both. In practice the fact store is the source + most queries want. +- **The conceptual simplicity of "everything is an event".** You now have + two mechanisms in the same service. Document the rule (an ADR is the + natural home) so that contributors know which path to use for new + writes. + +## Anti-patterns + +- **Inventing a fake aggregate to hold facts.** A "SampleStream" aggregate + whose only job is to accumulate samples is the orthodox path in disguise. + It still serialises on its version. Skip the aggregate entirely. +- **Using event sourcing to retrofit dedup.** Some teams add an idempotency + cache in front of the command handler to absorb duplicate facts. This + works but is the wrong fix: the dedup is incidental to the model. Move + the facts out and the dedup becomes a property of the fact store's + natural key. +- **Reading raw facts directly from every endpoint.** The fact store is + fine as a source of truth, but heavy reads should still go through a + projection (or a view cache for expensive aggregations). The fact store + is for ingest correctness; projections are for read performance. + +## Further reading + +- Vaughn Vernon, *Effective Aggregate Design*. Small aggregates exist to + protect true invariants, not to accumulate facts. +- Mathias Verraes, *Messaging Flavours*. Distinguishes informational + messages (observations, e.g. "the temperature was measured") from + imperative messages (commands, e.g. "measure the temperature"). +- Microsoft, *Azure Architecture Center, Event Sourcing pattern*. "Event + sourcing doesn't have to be an all-or-nothing decision; apply it + selectively." +- Greg Young, *CQRS Documents*. The original split between writes that + need decision-handling and reads that need projection-handling, with + facts naturally sitting on the read side of the line. + +## When to revisit + +This guidance applies when you have a write that meets all four criteria +above. If the producer is asking permission, or there is no natural +deduplication key, or the aggregate genuinely protects an invariant on the +write, stay on the orthodox path. The pattern is selective, not universal. From 7126fb0b8fd040074f8ea07f80df4204b0bca29b Mon Sep 17 00:00:00 2001 From: Antony Koch Date: Wed, 10 Jun 2026 08:57:24 +0100 Subject: [PATCH 13/14] feat(eventstore): context-aware Store and ContextRepository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy IEventStore / IGenericIDEventStore interfaces have no context.Context and no error return on reads, which forces production implementations to panic on infrastructure failures. Fine for the in-memory sample, not for a real database store. Adds, as a non-breaking sibling: Store[TID] — ctx-aware persistence port ContextRepository[T, TID] — ctx-aware load/save NewContextRepository — constructor Semantics tightened relative to the legacy repository: - Save marks the aggregate's changes committed on success (mirrors the C# reference behaviour), so subsequent saves append only new events. - GetById distinguishes empty stream (ErrAggregateNotFound) from infrastructure failure (propagated error). - The store contract documents version assignment (WithVersion from expectedVersion+1) and concurrency failure semantics. Covered by context_test.go using an in-memory fake store: version assignment, commit-on-save, rehydration, not-found, error propagation and concurrency conflict. --- eventstore/context.go | 87 +++++++++++++++++++++++ eventstore/context_test.go | 141 +++++++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 eventstore/context.go create mode 100644 eventstore/context_test.go diff --git a/eventstore/context.go b/eventstore/context.go new file mode 100644 index 0000000..8986a94 --- /dev/null +++ b/eventstore/context.go @@ -0,0 +1,87 @@ +package eventstore + +import ( + "context" + "reflect" + + "github.com/iamkoch/conqueress" + "github.com/iamkoch/conqueress/domain" +) + +// Store is the context-aware persistence port for an event stream, generic +// over the aggregate ID type. Production implementations (MongoDB, Postgres, +// etc.) should implement this interface rather than the legacy IEventStore / +// IGenericIDEventStore, which lack context propagation and error returns on +// reads. +// +// SaveEvents must assign stream positions to the events (via WithVersion), +// starting from expectedVersion+1, and must fail with a concurrency error if +// the stream's current version does not equal expectedVersion (use -1 for a +// new stream). +type Store[TID any] interface { + SaveEvents(ctx context.Context, aggregateType string, aggregateId TID, events []conqueress.Event, expectedVersion int) error + GetEventsForAggregate(ctx context.Context, aggregateId TID) ([]conqueress.Event, error) +} + +// ContextRepository loads and saves aggregates against a Store. The +// context-aware counterpart of GenericIDRepository. +type ContextRepository[T domain.IGenericIDAggregate[TID], TID any] interface { + // GetById rehydrates the aggregate from its event stream. Returns + // ErrAggregateNotFound when the stream is empty. + GetById(ctx context.Context, id TID) (T, error) + + // Save appends the aggregate's uncommitted events at expectedVersion and, + // on success, marks them committed so subsequent saves only append new + // events. + Save(ctx context.Context, aggregate T, expectedVersion int) error +} + +type contextRepository[T domain.IGenericIDAggregate[TID], TID any] struct { + store Store[TID] + createInstance func() T +} + +// NewContextRepository builds a ContextRepository over the given store. The +// createInstance factory must return an aggregate whose inner-apply handler is +// installed (e.g. via domain.NewWithID) so rehydration can route events +// through it. +func NewContextRepository[T domain.IGenericIDAggregate[TID], TID any]( + store Store[TID], + createInstance func() T, +) ContextRepository[T, TID] { + return contextRepository[T, TID]{store: store, createInstance: createInstance} +} + +func (r contextRepository[T, TID]) GetById(ctx context.Context, id TID) (T, error) { + events, err := r.store.GetEventsForAggregate(ctx, id) + if err != nil { + var zero T + return zero, err + } + if len(events) == 0 { + var zero T + return zero, ErrAggregateNotFound + } + agg := r.createInstance() + applier := reflect.ValueOf(agg).Interface().(domain.InnerApplier) + for _, e := range events { + applier.InnerApply(e) + } + return agg, nil +} + +func (r contextRepository[T, TID]) Save(ctx context.Context, aggregate T, expectedVersion int) error { + if err := r.store.SaveEvents( + ctx, + reflect.TypeOf(aggregate).Name(), + aggregate.Id(), + aggregate.UncommittedEvents(), + expectedVersion, + ); err != nil { + return err + } + if committer, ok := any(aggregate).(interface{ MarkChangesAsCommitted() }); ok { + committer.MarkChangesAsCommitted() + } + return nil +} diff --git a/eventstore/context_test.go b/eventstore/context_test.go new file mode 100644 index 0000000..6c71021 --- /dev/null +++ b/eventstore/context_test.go @@ -0,0 +1,141 @@ +package eventstore_test + +import ( + "context" + "errors" + "testing" + + cqrs "github.com/iamkoch/conqueress" + "github.com/iamkoch/conqueress/domain" + "github.com/iamkoch/conqueress/eventstore" + "github.com/stretchr/testify/assert" +) + +// fakeStore is a minimal in-memory Store[string] for exercising the +// repository contract, including version assignment and concurrency failure. +type fakeStore struct { + streams map[string][]cqrs.Event + failGet error +} + +func newFakeStore() *fakeStore { + return &fakeStore{streams: map[string][]cqrs.Event{}} +} + +func (s *fakeStore) SaveEvents(_ context.Context, _ string, id string, events []cqrs.Event, expectedVersion int) error { + current := len(s.streams[id]) - 1 + if expectedVersion != -1 && current != expectedVersion { + return eventstore.ErrConcurrencyException + } + v := expectedVersion + for _, e := range events { + v++ + e.WithVersion(v) + s.streams[id] = append(s.streams[id], e) + } + return nil +} + +func (s *fakeStore) GetEventsForAggregate(_ context.Context, id string) ([]cqrs.Event, error) { + if s.failGet != nil { + return nil, s.failGet + } + return s.streams[id], nil +} + +type counterCreated struct { + *cqrs.BaseEvent + Name string +} + +type counterIncremented struct { + *cqrs.BaseEvent +} + +type counter struct { + domain.AggregateRootBase[string] + name string + count int +} + +func (c *counter) SetBase(b domain.AggregateRootBase[string]) { c.AggregateRootBase = b } +func (c *counter) GetHandler() func(cqrs.Event) { return c.handleEvent } + +func (c *counter) handleEvent(e cqrs.Event) { + switch evt := e.(type) { + case counterCreated: + c.SetId("counter-1") + c.SetVersion(evt.Ver) + c.name = evt.Name + case counterIncremented: + c.count++ + c.SetVersion(evt.Ver) + } +} + +func newCounter() *counter { return domain.NewWithID[counter, string]() } + +func TestContextRepository_SaveAssignsVersionsAndCommits(t *testing.T) { + store := newFakeStore() + repo := eventstore.NewContextRepository[*counter, string](store, newCounter) + + c := newCounter() + c.ApplyChange(cqrs.NewEvent[counterCreated](func(e *counterCreated) { e.Name = "hits" })) + c.ApplyChange(cqrs.NewEvent[counterIncremented]()) + + assert.NoError(t, repo.Save(context.Background(), c, -1)) + assert.Empty(t, c.UncommittedEvents(), "save marks changes committed") + + stream := store.streams["counter-1"] + assert.Len(t, stream, 2) + assert.Equal(t, 0, stream[0].Version()) + assert.Equal(t, 1, stream[1].Version()) +} + +func TestContextRepository_GetByIdRehydrates(t *testing.T) { + store := newFakeStore() + repo := eventstore.NewContextRepository[*counter, string](store, newCounter) + + c := newCounter() + c.ApplyChange(cqrs.NewEvent[counterCreated](func(e *counterCreated) { e.Name = "hits" })) + c.ApplyChange(cqrs.NewEvent[counterIncremented]()) + c.ApplyChange(cqrs.NewEvent[counterIncremented]()) + assert.NoError(t, repo.Save(context.Background(), c, -1)) + + loaded, err := repo.GetById(context.Background(), "counter-1") + assert.NoError(t, err) + assert.Equal(t, "hits", loaded.name) + assert.Equal(t, 2, loaded.count) + assert.Equal(t, 2, loaded.Version(), "aggregate version reflects last event") +} + +func TestContextRepository_GetByIdEmptyStreamIsNotFound(t *testing.T) { + repo := eventstore.NewContextRepository[*counter, string](newFakeStore(), newCounter) + + _, err := repo.GetById(context.Background(), "missing") + assert.ErrorIs(t, err, eventstore.ErrAggregateNotFound) +} + +func TestContextRepository_GetByIdPropagatesStoreError(t *testing.T) { + store := newFakeStore() + store.failGet = errors.New("connection reset") + repo := eventstore.NewContextRepository[*counter, string](store, newCounter) + + _, err := repo.GetById(context.Background(), "counter-1") + assert.ErrorContains(t, err, "connection reset") +} + +func TestContextRepository_SaveConcurrencyConflict(t *testing.T) { + store := newFakeStore() + repo := eventstore.NewContextRepository[*counter, string](store, newCounter) + + c := newCounter() + c.ApplyChange(cqrs.NewEvent[counterCreated](func(e *counterCreated) { e.Name = "hits" })) + assert.NoError(t, repo.Save(context.Background(), c, -1)) + + // A second writer saves at a stale expectedVersion. + stale := newCounter() + stale.ApplyChange(cqrs.NewEvent[counterCreated](func(e *counterCreated) { e.Name = "clash" })) + err := repo.Save(context.Background(), stale, 5) + assert.ErrorIs(t, err, eventstore.ErrConcurrencyException) +} From 33c78fa883de70fe6465628a3ce2fd72ce33190c Mon Sep 17 00:00:00 2001 From: Antony Koch Date: Wed, 10 Jun 2026 09:26:05 +0100 Subject: [PATCH 14/14] feat(mongo): rebuild the MongoDB event store as a fetchable module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old conqueress-mongo sample was broken three ways: its go.mod never required the core module (so it did not build), its module path (github.com/iamkoch/conqueress/mongo) did not match its directory (conqueress-mongo/, so go get could never resolve it), and it was design-stale (hardcoded database name, Guid-only legacy IEventStore, panics on read errors, 2022-era driver). Replaced wholesale with mongo/ — directory matching the module path so it is fetchable: - Store[TID Stringer] implementing the context-aware eventstore.Store[TID]. Generic over any aggregate ID that renders to a stable string; composite IDs are first-class. - Transactional append: stream-head check plus event inserts in one transaction, losing cleanly with ErrConcurrencyException on conflict. Version assignment via WithVersion from expectedVersion+1. - TypeRegistry with explicit wire names (Register[T](r, name)), so Go type renames never break persisted streams. Fails loudly on unregistered types in both directions. - Correlation, causation and OccurredAt persisted on the envelope; empty guids stored as absent fields. - EnsureIndexes for the unique (aggregate_id, version) index. - Configurable database and collection names. Tests cover registry round-tripping with full metadata, loud failure on unregistered types, the empty-guid mapping, and compile-time conformance of Store[compositeID] against the port. Mongo-integration coverage lands via the consuming service's journey tests, where a real replica set exists. CI gains a step running the mongo module's tests (separate module, so the root ./... does not reach it). README documents the adapter and downgrades conqueress-firestore to a legacy reference sample. --- .github/workflows/test.yml | 4 + README.md | 34 +++- conqueress-mongo/cqrs_mongo_test.go | 12 -- conqueress-mongo/go.mod | 28 ---- conqueress-mongo/go.sum | 69 -------- conqueress-mongo/store.go | 252 ---------------------------- conqueress-mongo/store2_test.go | 187 --------------------- conqueress-mongo/type_map.go | 28 ---- conqueress-mongo/type_map_test.go | 54 ------ mongo/go.mod | 26 +++ mongo/go.sum | 64 +++++++ mongo/registry.go | 89 ++++++++++ mongo/store.go | 225 +++++++++++++++++++++++++ mongo/store_test.go | 85 ++++++++++ 14 files changed, 524 insertions(+), 633 deletions(-) delete mode 100644 conqueress-mongo/cqrs_mongo_test.go delete mode 100644 conqueress-mongo/go.mod delete mode 100644 conqueress-mongo/go.sum delete mode 100644 conqueress-mongo/store.go delete mode 100644 conqueress-mongo/store2_test.go delete mode 100644 conqueress-mongo/type_map.go delete mode 100644 conqueress-mongo/type_map_test.go create mode 100644 mongo/go.mod create mode 100644 mongo/go.sum create mode 100644 mongo/registry.go create mode 100644 mongo/store.go create mode 100644 mongo/store_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fd91d09..3d7a797 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,3 +36,7 @@ jobs: - name: coverage summary run: go tool cover -func=coverage.out | tail -20 + + - name: test mongo module + working-directory: mongo + run: go test -race -count=1 ./... diff --git a/README.md b/README.md index 09167bd..9a09ad6 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,38 @@ through that callback. No reflection in the dispatch path. `guid.Guid`. xid-backed ID type with `New()`, `FromString` and `MustFromString`. -### Sibling adapters +### `mongo/` -- `conqueress-mongo/`. MongoDB-backed event store with transactional saves. -- `conqueress-firestore/`. Firestore-backed event store. +A MongoDB-backed event store implementing the context-aware +`eventstore.Store[TID]` port. Fetchable as its own module: + +```sh +go get github.com/iamkoch/conqueress/mongo +``` + +- Generic over any aggregate ID that implements `String()` (composite IDs + included). +- Transactional append with optimistic concurrency via a stream-head + collection. Requires a replica set (a single-node replica set is fine + locally). +- Wire-stable event names via an explicit `TypeRegistry`: register each + domain event once at composition time and Go type renames never break a + persisted stream. +- Correlation, causation and `OccurredAt` persisted on the envelope. + +```go +registry := mongo.NewTypeRegistry() +mongo.Register[ItemCreated](registry, "item-created") +mongo.Register[ItemRenamed](registry, "item-renamed") + +store := mongo.NewStore[ItemID](client, "mydb", "item_events", registry) +repo := eventstore.NewContextRepository[*Item, ItemID](store, NewItem) +``` + +### Legacy sibling + +- `conqueress-firestore/`. Firestore-backed sample from an earlier iteration. + Not currently consumable as a module; treat as reference only. ## Quick start diff --git a/conqueress-mongo/cqrs_mongo_test.go b/conqueress-mongo/cqrs_mongo_test.go deleted file mode 100644 index 1d6701a..0000000 --- a/conqueress-mongo/cqrs_mongo_test.go +++ /dev/null @@ -1,12 +0,0 @@ -package store - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "testing" -) - -func TestConqueressMongo(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "conqueress-mongo") -} diff --git a/conqueress-mongo/go.mod b/conqueress-mongo/go.mod deleted file mode 100644 index 3c434ce..0000000 --- a/conqueress-mongo/go.mod +++ /dev/null @@ -1,28 +0,0 @@ -module github.com/iamkoch/conqueress/mongo - -go 1.23.0 - -require ( - github.com/onsi/ginkgo/v2 v2.5.0 - github.com/onsi/gomega v1.24.1 - go.mongodb.org/mongo-driver v1.10.1 -) - -require ( - github.com/go-logr/logr v1.2.3 // indirect - github.com/golang/snappy v0.0.1 // indirect - github.com/google/go-cmp v0.5.9 // indirect - github.com/klauspost/compress v1.13.6 // indirect - github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/xdg-go/pbkdf2 v1.0.0 // indirect - github.com/xdg-go/scram v1.1.1 // indirect - github.com/xdg-go/stringprep v1.0.3 // indirect - github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect - golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect - golang.org/x/net v0.2.0 // indirect - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect - golang.org/x/sys v0.2.0 // indirect - golang.org/x/text v0.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/conqueress-mongo/go.sum b/conqueress-mongo/go.sum deleted file mode 100644 index a665f99..0000000 --- a/conqueress-mongo/go.sum +++ /dev/null @@ -1,69 +0,0 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= -github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= -github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= -github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= -github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0= -github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= -github.com/onsi/ginkgo/v2 v2.5.0 h1:TRtrvv2vdQqzkwrQ1ke6vtXf7IK34RBUJafIy1wMwls= -github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= -github.com/onsi/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E= -github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= -github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.1.1 h1:VOMT+81stJgXW3CpHyqHN3AXDYIMsx56mEFrB37Mb/E= -github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= -github.com/xdg-go/stringprep v1.0.3 h1:kdwGpVNwPFtjs98xCGkHjQtGKh86rDcRZN17QEMCOIs= -github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= -github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= -github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= -go.mongodb.org/mongo-driver v1.10.1 h1:NujsPveKwHaWuKUer/ceo9DzEe7HIj1SlJ6uvXZG0S4= -go.mongodb.org/mongo-driver v1.10.1/go.mod h1:z4XpeoU6w+9Vht+jAFyLgVrD+jGSQQe0+CBWFHNiHt8= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/conqueress-mongo/store.go b/conqueress-mongo/store.go deleted file mode 100644 index d2cf48c..0000000 --- a/conqueress-mongo/store.go +++ /dev/null @@ -1,252 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "fmt" - cqrs "github.com/iamkoch/conqueress" - "github.com/iamkoch/conqueress/eventstore" - "github.com/iamkoch/conqueress/guid" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" - "go.mongodb.org/mongo-driver/mongo/readconcern" - "go.mongodb.org/mongo-driver/mongo/writeconcern" - "reflect" - "time" -) - -type simpleEnvelope struct { - Id guid.Guid `bson:"_id"` - CorrelationId guid.Guid `bson:"correlation_id"` - CausationId guid.Guid `bson:"causation_id"` -} - -type envelope struct { - simpleEnvelope - Type string `bson:"type"` - Body string `bson:"body"` - AggregateId string `bson:"aggregate_id"` -} - -type mongoEventStore struct { - client *mongo.Client - tm *TypeMap -} - -type ConnectionString string - -func NewMongoEventStore(cs ConnectionString, tm *TypeMap) (eventstore.IEventStore, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - client, err := mongo.Connect(ctx, options.Client().ApplyURI(string(cs))) - if err != nil { - return nil, err - } - - return &mongoEventStore{client, tm}, nil -} - -func checkConcurrency(expectedVersion int, a *dbAggregate) error { - isNewAggregate := expectedVersion == -1 - hasVersionMismatch := a.Version != expectedVersion - if hasVersionMismatch && !isNewAggregate { - return errors.New("concurrency error") - } - return nil -} - -func tryGetExistingAggregate( - ctx mongo.SessionContext, - ac *mongo.Collection, - aid guid.Guid, - createDefault func() *dbAggregate) (*dbAggregate, error) { - agg := &dbAggregate{} - res := ac.FindOne(ctx, bson.M{"_id": aid.String()}) - if res.Err() != nil { - if res.Err() == mongo.ErrNoDocuments { - return createDefault(), nil - } - return nil, res.Err() - } - - e := res.Decode(agg) - - if e != nil { - return nil, e - } - - return agg, nil -} - -func (m mongoEventStore) SaveEvents(aggregateType string, aggregateId guid.Guid, events []cqrs.Event, expectedVersion int) error { - ec := m.client.Database("devly").Collection("events") - ac := m.client.Database("devly").Collection("aggregates") - - session, err := m.client.StartSession() - if err != nil { - return err - } - defer session.EndSession(context.Background()) - - wc := writeconcern.New(writeconcern.WMajority()) - rc := readconcern.Snapshot() - txnOpts := options.Transaction().SetWriteConcern(wc).SetReadConcern(rc) - - err = mongo.WithSession(context.Background(), session, func(sessionContext mongo.SessionContext) error { - if err = session.StartTransaction(txnOpts); err != nil { - return err - } - - getDefaultAggregate := func() *dbAggregate { - return &dbAggregate{Id: aggregateId.String(), Version: 0} - } - - dbAgg, e := tryGetExistingAggregate(sessionContext, ac, aggregateId, getDefaultAggregate) - - if e != nil { - return e - } - - e = checkConcurrency(expectedVersion, dbAgg) - - if e != nil { - fmt.Println("concurrency error") - return e - } - - ev := expectedVersion - - for _, event := range events { - ev++ - dbe, e := createDbEvent(event, aggregateType, guid.New(), guid.New(), aggregateId, ev) - if e != nil { - return e - } - - _, e = ec.InsertOne(sessionContext, dbe) - - if e != nil { - fmt.Println("Error saving event ", e) - return e - } - } - - _, e = ac.UpdateOne(sessionContext, bson.M{"_id": aggregateId.String()}, bson.M{"$set": bson.M{"version": ev}}, options.Update().SetUpsert(true)) - - if err = session.CommitTransaction(sessionContext); err != nil { - return err - } - return nil - }) - - if err != nil { - fmt.Printf("Error calling transaction %s\n", err.Error()) - return err - } else { - fmt.Printf("Transaction successful\n") - } - return nil -} - -func (m mongoEventStore) GetEventsForAggregate(aggregateId guid.Guid) []cqrs.Event { - ec := m.client.Database("devly").Collection("events") - c, e := ec.Find(context.Background(), bson.M{"aggregate_id": aggregateId.String()}) - if e != nil { - //if e.Error() == mongo.ErrNoDocuments { - // return []cqrs.Event{} - //} - panic(e.Error()) - } - - var results []envelope - if err := c.All(context.TODO(), &results); err != nil { - panic(err) - } - - events := make([]cqrs.Event, 0) - for _, envelope := range results { - get, e := m.tm.Get(envelope.Type) - if e != nil { - fmt.Println("Error getting event ", e) - panic("couldn't get event") - } - ev, err := envelopeToEvent(get, &envelope) - if err != nil { - fmt.Println("Error getting event ", err) - panic("couldn't get event") - } - events = append(events, ev) - } - - return events -} - -func envelopeToEvent(t reflect.Type, e *envelope) (cqrs.Event, error) { - v := reflect.New(t) - - // reflected pointer - newP := v.Interface() - - // Unmarshal to reflected struct pointer - json.Unmarshal([]byte(e.Body), newP) - - return dereferenceIfPtr(newP).(cqrs.Event), nil -} - -func dereferenceIfPtr(value interface{}) interface{} { - if reflect.TypeOf(value).Kind() == reflect.Ptr { - - return reflect.ValueOf(value).Elem().Interface() - - } else { - - return value - - } -} - -type dbEvent struct { - Id string `bson:"id"` - AggregateId string `bson:"aggregate_id"` - AggregateType string `bson:"aggregate_type"` - Body string `bson:"body"` - Type string `bson:"type"` - Version int `bson:"version"` - Timestamp int64 `bson:"timestamp"` - CorrelationId string `bson:"correlation_id"` - CausationId string `bson:"causation_id"` -} - -type dbAggregate struct { - Id string `bson:"_id"` - Version int `bson:"version"` -} - -func createDbEvent( - e cqrs.Event, - aggName string, - cor guid.Guid, - cau guid.Guid, - aid guid.Guid, - v int) (*dbEvent, error) { - bytes, err := json.Marshal(e) - if err != nil { - fmt.Println(err) - return nil, err - } - - return &dbEvent{ - Id: e.MsgId().String(), - AggregateId: aid.String(), - AggregateType: aggName, - Body: string(bytes), - Type: reflect.TypeOf(e).Name(), - Version: v, - Timestamp: time.Now().UTC().Unix(), - CorrelationId: cor.String(), - CausationId: cau.String(), - }, nil -} diff --git a/conqueress-mongo/store2_test.go b/conqueress-mongo/store2_test.go deleted file mode 100644 index 97c0a1e..0000000 --- a/conqueress-mongo/store2_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package store - -// -//import ( -// "fmt" -// cqrs "github.com/iamkoch/conqueress" -// "github.com/iamkoch/conqueress/eventstore" -// "github.com/iamkoch/conqueress/guid" -// . "github.com/onsi/ginkgo/v2" -// . "github.com/onsi/gomega" -// "reflect" -// "sample_domain" -// "time" -//) -// -//type testPublisher struct { -// capturedEvents []cqrs.Event -//} -// -//func newTestPublisher() *testPublisher { -// return &testPublisher{capturedEvents: make([]cqrs.Event, 0)} -//} -// -//func (t *testPublisher) Publish(event cqrs.Event) { -// t.capturedEvents = append(t.capturedEvents, event) -//} -// -//func (t *testPublisher) Handle(event cqrs.Event) error { -// t.capturedEvents = append(t.capturedEvents, event) -// return nil -//} -// -//type MongoFixture struct { -// mediator *cqrs.Mediator -// repo eventstore.Repository[*sample_domain.InventoryItem] -//} -// -//func NewFixture(id guid.Guid) *MongoFixture { -// waitForSuccess := make(chan cqrs.CommandProcessingError) -// var repo eventstore.Repository[*sample_domain.InventoryItem] -// var m *cqrs.Mediator -// -// tm := NewTypeMap().Add(sample_domain.InventoryItemCreated{}).Add(sample_domain.InventoryItemRenamed{}) -// m = cqrs.NewMediator(false) -// s, err := NewMongoEventStore("mongodb://admin-user:admin-password@mongodb:27017", tm) -// if err != nil { -// panic(err.Error()) -// } -// repo = eventstore.NewRepository[*sample_domain.InventoryItem](s, sample_domain.DefaultInventoryItem) -// commands := sample_domain.NewInventoryCommandHandler(repo) -// m.RegisterCommandHandler(reflect.TypeOf(sample_domain.CreateInventoryItem{}), commands.HandleCreateInventoryItem) -// m.RegisterCommandHandler(reflect.TypeOf(sample_domain.RenameInventoryItem{}), commands.HandleRenameInventoryItem) -// handler := newTestPublisher() -// m.RegisterEventHandler(reflect.TypeOf(sample_domain.InventoryItemCreated{}), handler.Handle) -// time.Sleep(time.Second) -// return &MongoFixture{mediator: m, repo: repo} -//} -// -//var _ = Describe("Conqueress Mongo Store", func() { -// -// Describe("Saving and loading an aggregate", func() { -// -// }) -// -// Describe("save and load", func() { -// -// Describe("when saving and loading", func() { -// -// err := m.Dispatch(sample_domain.NewCreateInventoryItem(actualId, "something"), waitForSuccess) -// if err != nil { -// fmt.Errorf("something went wrong dispatching %s", err.Error()) -// } -// err = <-waitForSuccess -// if err != nil { -// fmt.Errorf("something went processing %s", err.Error()) -// } -// ii := repo.GetById(actualId) -// -// It("should have the correct name", func() { -// Expect(ii.Name).To(Equal("something")) -// }) -// -// }) -// -// Describe("when saving and loading again", func() { -// var waitForSuccess chan cqrs.CommandProcessingError = nil -// var repo eventstore.Repository[*sample_domain.InventoryItem] -// var m *cqrs.Mediator -// var actualId guid.Guid -// BeforeEach(func() { -// actualId = guid.New() -// -// waitForSuccess = make(chan cqrs.CommandProcessingError) -// tm := NewTypeMap().Add(sample_domain.InventoryItemCreated{}).Add(sample_domain.InventoryItemRenamed{}) -// m = cqrs.NewMediator(false) -// s, err := NewMongoEventStore("mongodb://admin-user:admin-password@mongodb:27017", tm) -// if err != nil { -// panic(err.Error()) -// } -// repo = eventstore.NewRepository[*sample_domain.InventoryItem](s, sample_domain.DefaultInventoryItem) -// commands := sample_domain.NewInventoryCommandHandler(repo) -// m.RegisterCommandHandler(reflect.TypeOf(sample_domain.CreateInventoryItem{}), commands.HandleCreateInventoryItem) -// m.RegisterCommandHandler(reflect.TypeOf(sample_domain.RenameInventoryItem{}), commands.HandleRenameInventoryItem) -// handler := newTestPublisher() -// m.RegisterEventHandler(reflect.TypeOf(sample_domain.InventoryItemCreated{}), handler.Handle) -// time.Sleep(time.Second) -// }) -// err := m.Dispatch(sample_domain.NewRenameInventoryItem(actualId, "something new"), waitForSuccess) -// if err != nil { -// fmt.Errorf("something went wrong dispatching %s", err.Error()) -// } -// err = <-waitForSuccess -// if err != nil { -// fmt.Errorf("something went processing %s", err.Error()) -// } -// -// ii := repo.GetById(actualId) -// It("should have the correct name", func() { -// Expect(ii.Name).To(Equal("something new")) -// }) -// }) -// }) -// -// Describe("concurrency", func() { -// Describe("Given a configured test domain", func() { -// var repo eventstore.Repository[*sample_domain.InventoryItem] -// var m *cqrs.Mediator -// var actualId guid.Guid -// BeforeEach(func() { -// actualId = guid.New() -// -// tm := NewTypeMap().Add(sample_domain.InventoryItemCreated{}).Add(sample_domain.InventoryItemRenamed{}) -// m = cqrs.NewMediator(false) -// s, err := NewMongoEventStore("mongodb://admin-user:admin-password@mongodb:27017", tm) -// if err != nil { -// panic(err.Error()) -// } -// repo = eventstore.NewRepository[*sample_domain.InventoryItem](s, sample_domain.DefaultInventoryItem) -// commands := sample_domain.NewInventoryCommandHandler(repo) -// m.RegisterCommandHandler(reflect.TypeOf(sample_domain.CreateInventoryItem{}), commands.HandleCreateInventoryItem) -// m.RegisterCommandHandler(reflect.TypeOf(sample_domain.RenameInventoryItem{}), commands.HandleRenameInventoryItem) -// handler := newTestPublisher() -// m.RegisterEventHandler(reflect.TypeOf(sample_domain.InventoryItemCreated{}), handler.Handle) -// time.Sleep(time.Second) -// }) -// m = cqrs.NewMediator(false) -// tm := NewTypeMap().Add(sample_domain.InventoryItemCreated{}).Add(sample_domain.InventoryItemRenamed{}) -// -// s, err := NewMongoEventStore("mongodb://admin-user:admin-password@mongodb:27017", tm) -// if err != nil { -// panic(err.Error()) -// } -// repo = eventstore.NewRepository[*sample_domain.InventoryItem](s, sample_domain.DefaultInventoryItem) -// commands := sample_domain.NewInventoryCommandHandler(repo) -// m.RegisterCommandHandler(reflect.TypeOf(sample_domain.CreateInventoryItem{}), commands.HandleCreateInventoryItem) -// m.RegisterCommandHandler(reflect.TypeOf(sample_domain.RenameInventoryItem{}), commands.HandleRenameInventoryItem) -// -// handler := newTestPublisher() -// m.RegisterEventHandler(reflect.TypeOf(sample_domain.InventoryItemCreated{}), handler.Handle) -// time.Sleep(time.Second) -// actualId = guid.New() -// err = m.Dispatch(sample_domain.NewCreateInventoryItem(actualId, "something"), nil) -// if err != nil { -// fmt.Errorf("something went wrong dispatching %s", err.Error()) -// } -// time.Sleep(time.Second * 2) -// -// ii := repo.GetById(actualId) -// It("should have the correct name", func() { -// Expect(ii.Name()).To(Equal("something")) -// }) -// -// err = m.Dispatch(sample_domain.NewRenameInventoryItem(actualId, "something new"), nil) -// err = m.Dispatch(sample_domain.NewRenameInventoryItem(actualId, "something new 2"), nil) -// err = m.Dispatch(sample_domain.NewRenameInventoryItem(actualId, "something new 3"), nil) -// if err != nil { -// fmt.Errorf("something went wrong dispatching %s", err.Error()) -// } -// time.Sleep(time.Second * 2) -// -// ii = repo.GetById(actualId) -// It("should have the correct name", func() { -// Expect(ii.Name()).To(Equal("something new 3")) -// }) -// }) -// }) -//}) diff --git a/conqueress-mongo/type_map.go b/conqueress-mongo/type_map.go deleted file mode 100644 index 6a85bf2..0000000 --- a/conqueress-mongo/type_map.go +++ /dev/null @@ -1,28 +0,0 @@ -package store - -import ( - "fmt" - "reflect" -) - -type TypeMap struct { - typeMap map[string]reflect.Type -} - -var ErrTypeNotFound = fmt.Errorf("type not found") - -func (tm *TypeMap) Get(t string) (reflect.Type, error) { - if match, exist := tm.typeMap[t]; exist { - return match, nil - } - return nil, ErrTypeNotFound -} - -func NewTypeMap() *TypeMap { - return &TypeMap{typeMap: make(map[string]reflect.Type)} -} - -func (tm *TypeMap) Add(t interface{}) *TypeMap { - tm.typeMap[reflect.TypeOf(t).Name()] = reflect.TypeOf(t) - return tm -} diff --git a/conqueress-mongo/type_map_test.go b/conqueress-mongo/type_map_test.go deleted file mode 100644 index 32de211..0000000 --- a/conqueress-mongo/type_map_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package store - -import ( - enshur "github.com/iamkoch/ensure/stateless" - "github.com/stretchr/testify/assert" - "reflect" - "testing" -) - -func TestTypeMap(t *testing.T) { - var ( - typeMap *TypeMap - assert = assert.New(t) - actual reflect.Type - getErr error - ) - enshur.That("typemap works as expected", func(s *enshur.Scenario) { - s.Given("a typemap", func() { - typeMap = NewTypeMap() - }) - - s.And("some registered types", func() { - typeMap.Add(TypeOne{}).Add(TypeTwo{}) - }) - - s.When("I request an existing type", func() { - actual, getErr = typeMap.Get("TypeOne") - }) - - s.Then("I should not get an error", func() { - assert.Nil(getErr) - }) - - s.Then("I should get the correct type", func() { - expectedType := reflect.TypeOf(TypeOne{}) - assert.Equal(expectedType, actual) - }) - - s.When("I request a non-existent type", func() { - actual, getErr = typeMap.Get("TypeThree") - }) - - s.Then("I should get an error", func() { - assert.NotNil(getErr) - }) - - s.And("The error should be of the correct type", func() { - assert.Equal(ErrTypeNotFound, getErr) - }) - }, t) -} - -type TypeOne struct{} -type TypeTwo struct{} diff --git a/mongo/go.mod b/mongo/go.mod new file mode 100644 index 0000000..6c8ec9e --- /dev/null +++ b/mongo/go.mod @@ -0,0 +1,26 @@ +module github.com/iamkoch/conqueress/mongo + +go 1.25 + +require ( + github.com/iamkoch/conqueress v0.0.0-20260610075724-7126fb0b8fd0 + github.com/stretchr/testify v1.11.1 + go.mongodb.org/mongo-driver v1.17.3 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rs/xid v1.4.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/text v0.17.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/mongo/go.sum b/mongo/go.sum new file mode 100644 index 0000000..a31e4fa --- /dev/null +++ b/mongo/go.sum @@ -0,0 +1,64 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/iamkoch/conqueress v0.0.0-20260610075724-7126fb0b8fd0 h1:PYcuBz+/OLgJHOiGg3Ld8uTbGvHM1w5dLKaoshzTFyw= +github.com/iamkoch/conqueress v0.0.0-20260610075724-7126fb0b8fd0/go.mod h1:msqu2xgBclfTo9mY7tYfrc+CWI5NDfyowKcsOI64zmc= +github.com/iamkoch/ensure v1.0.0 h1:gKVynFfBTsbH7CEyiUc/kBZRDnh+eX+fNaIC7NLMHw0= +github.com/iamkoch/ensure v1.0.0/go.mod h1:4WWXoqsh453l9ispJtBBYZ+5MZOxG2crHeXFYGifKc0= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= +go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mongo/registry.go b/mongo/registry.go new file mode 100644 index 0000000..63a1634 --- /dev/null +++ b/mongo/registry.go @@ -0,0 +1,89 @@ +package mongo + +import ( + "encoding/json" + "fmt" + + "github.com/iamkoch/conqueress" +) + +// TypeRegistry maps wire-stable event-type names to decoders. The store +// persists each event's name alongside its JSON body; on read, the registry +// turns the pair back into the concrete domain event the consumer's aggregate +// type switch expects. +// +// Names are explicit strings rather than Go type names, so renaming a Go type +// never breaks a persisted stream. The registry starts empty; consumers +// register their domain events at composition time: +// +// registry := mongo.NewTypeRegistry() +// mongo.Register[ItemCreated](registry, "item-created") +// mongo.Register[ItemRenamed](registry, "item-renamed") +type TypeRegistry struct { + decoders map[string]func([]byte) (conqueress.Event, error) + names map[string]string // Go type name -> wire name +} + +// NewTypeRegistry returns an empty registry. +func NewTypeRegistry() *TypeRegistry { + return &TypeRegistry{ + decoders: map[string]func([]byte) (conqueress.Event, error){}, + names: map[string]string{}, + } +} + +// Register wires a concrete event type to its wire name. T is the event's +// value type (aggregate type switches match values, not pointers); the +// decoder unmarshals into a pointer and dereferences. T must embed +// *conqueress.BaseEvent — encoding/json allocates the embedded pointer when +// its promoted fields appear in the body, which they always do (message_id is +// always present). +func Register[T any](r *TypeRegistry, name string) { + var zero T + goName := fmt.Sprintf("%T", zero) + r.names[goName] = name + r.decoders[name] = func(data []byte) (conqueress.Event, error) { + t := new(T) + if err := json.Unmarshal(data, t); err != nil { + return nil, fmt.Errorf("decode %s: %w", name, err) + } + evt, ok := any(*t).(conqueress.Event) + if !ok { + return nil, fmt.Errorf("registered type %s does not satisfy conqueress.Event", name) + } + return evt, nil + } +} + +// WireName returns the wire-stable name for a live event, or an error if the +// event's type was never registered. +func (r *TypeRegistry) WireName(e conqueress.Event) (string, error) { + goName := fmt.Sprintf("%T", e) + name, ok := r.names[goName] + if !ok { + return "", fmt.Errorf("event type %s is not registered in the TypeRegistry", goName) + } + return name, nil +} + +// Encode renders an event to its wire name and JSON body. +func (r *TypeRegistry) Encode(e conqueress.Event) (name string, body []byte, err error) { + name, err = r.WireName(e) + if err != nil { + return "", nil, err + } + body, err = json.Marshal(e) + if err != nil { + return "", nil, fmt.Errorf("encode %s: %w", name, err) + } + return name, body, nil +} + +// Decode turns a persisted (name, body) pair back into the concrete event. +func (r *TypeRegistry) Decode(name string, body []byte) (conqueress.Event, error) { + decode, ok := r.decoders[name] + if !ok { + return nil, fmt.Errorf("no decoder registered for event type %q", name) + } + return decode(body) +} diff --git a/mongo/store.go b/mongo/store.go new file mode 100644 index 0000000..b0ee815 --- /dev/null +++ b/mongo/store.go @@ -0,0 +1,225 @@ +// Package mongo provides a MongoDB-backed event store implementing the +// context-aware eventstore.Store[TID] port. +// +// Layout (two collections, transactional append): +// +// one document per event: +// _id event message id +// aggregate_id aggregate id (TID rendered via String()) +// aggregate_type aggregate type name +// version stream position (0-based) +// type wire-stable event name (TypeRegistry) +// body JSON payload +// correlation_id / causation_id / occurred_at event metadata +// recorded_at server wall clock at append +// +// _streams one document per stream, for optimistic +// _id aggregate id concurrency: +// version current stream version +// +// Transactions require a MongoDB replica set (a single-node replica set is +// fine for local development). +// +// Publishing to a message bus is deliberately NOT done here. Publish after a +// successful Save in your application layer; keeping the store pure makes the +// transaction boundary obvious. +package mongo + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/iamkoch/conqueress" + "github.com/iamkoch/conqueress/eventstore" + "github.com/iamkoch/conqueress/guid" + "go.mongodb.org/mongo-driver/bson" + driver "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.mongodb.org/mongo-driver/mongo/readconcern" + "go.mongodb.org/mongo-driver/mongo/writeconcern" +) + +type envelope struct { + ID string `bson:"_id"` + AggregateID string `bson:"aggregate_id"` + AggregateType string `bson:"aggregate_type"` + Version int `bson:"version"` + Type string `bson:"type"` + Body []byte `bson:"body"` + CorrelationID string `bson:"correlation_id,omitempty"` + CausationID string `bson:"causation_id,omitempty"` + OccurredAt time.Time `bson:"occurred_at,omitempty"` + RecordedAt time.Time `bson:"recorded_at"` +} + +type streamHead struct { + ID string `bson:"_id"` + Version int `bson:"version"` +} + +// Stringer is the constraint on aggregate IDs: anything that renders itself +// to a stable string. Composite IDs implement String() to produce a stable, +// unique rendering. +type Stringer interface { + String() string +} + +// Store is a MongoDB event store generic over the aggregate ID type. +// Satisfies eventstore.Store[TID]. +type Store[TID Stringer] struct { + client *driver.Client + registry *TypeRegistry + + events *driver.Collection + streams *driver.Collection +} + +// NewStore wires a Store onto a database and base collection name (events in +// , stream heads in _streams). +func NewStore[TID Stringer](client *driver.Client, database, collection string, registry *TypeRegistry) *Store[TID] { + db := client.Database(database) + return &Store[TID]{ + client: client, + registry: registry, + events: db.Collection(collection), + streams: db.Collection(collection + "_streams"), + } +} + +// SaveEvents appends the events at expectedVersion (-1 for a new stream), +// assigning stream positions via WithVersion. The stream-head check and the +// event inserts run in one transaction; a concurrent writer loses cleanly +// with eventstore.ErrConcurrencyException. +func (s *Store[TID]) SaveEvents( + ctx context.Context, + aggregateType string, + aggregateId TID, + evts []conqueress.Event, + expectedVersion int, +) error { + if len(evts) == 0 { + return nil + } + + session, err := s.client.StartSession() + if err != nil { + return fmt.Errorf("start session: %w", err) + } + defer session.EndSession(ctx) + + txnOpts := options.Transaction(). + SetWriteConcern(writeconcern.Majority()). + SetReadConcern(readconcern.Snapshot()) + + _, err = session.WithTransaction(ctx, func(sc driver.SessionContext) (any, error) { + current := -1 + var head streamHead + findErr := s.streams.FindOne(sc, bson.M{"_id": aggregateId.String()}).Decode(&head) + switch { + case findErr == nil: + current = head.Version + case errors.Is(findErr, driver.ErrNoDocuments): + // new stream, current stays -1 + default: + return nil, fmt.Errorf("load stream head: %w", findErr) + } + + if current != expectedVersion { + return nil, fmt.Errorf("%w: stream %s at %d, expected %d", + eventstore.ErrConcurrencyException, aggregateId.String(), current, expectedVersion) + } + + version := expectedVersion + docs := make([]any, 0, len(evts)) + now := time.Now().UTC() + for _, e := range evts { + version++ + e.WithVersion(version) + name, body, encErr := s.registry.Encode(e) + if encErr != nil { + return nil, encErr + } + docs = append(docs, envelope{ + ID: e.MsgId().String(), + AggregateID: aggregateId.String(), + AggregateType: aggregateType, + Version: version, + Type: name, + Body: body, + CorrelationID: guidOrEmpty(e.CorrelationId()), + CausationID: guidOrEmpty(e.CausationId()), + OccurredAt: e.OccurredAt(), + RecordedAt: now, + }) + } + + if _, insErr := s.events.InsertMany(sc, docs); insErr != nil { + return nil, fmt.Errorf("insert events: %w", insErr) + } + + _, upErr := s.streams.UpdateOne(sc, + bson.M{"_id": aggregateId.String()}, + bson.M{"$set": bson.M{"version": version}}, + options.Update().SetUpsert(true)) + if upErr != nil { + return nil, fmt.Errorf("advance stream head: %w", upErr) + } + return nil, nil + }, txnOpts) + + return err +} + +// GetEventsForAggregate loads the stream in version order. An empty slice +// (no error) means the stream does not exist; eventstore.ContextRepository +// maps that to ErrAggregateNotFound. +func (s *Store[TID]) GetEventsForAggregate(ctx context.Context, aggregateId TID) ([]conqueress.Event, error) { + cur, err := s.events.Find(ctx, + bson.M{"aggregate_id": aggregateId.String()}, + options.Find().SetSort(bson.M{"version": 1})) + if err != nil { + return nil, fmt.Errorf("find events: %w", err) + } + defer func() { _ = cur.Close(ctx) }() + + var out []conqueress.Event + for cur.Next(ctx) { + var env envelope + if decErr := cur.Decode(&env); decErr != nil { + return nil, fmt.Errorf("decode envelope: %w", decErr) + } + evt, decErr := s.registry.Decode(env.Type, env.Body) + if decErr != nil { + return nil, decErr + } + out = append(out, evt) + } + if err := cur.Err(); err != nil { + return nil, fmt.Errorf("cursor: %w", err) + } + return out, nil +} + +// EnsureIndexes creates the indexes the store relies on. Run once at service +// startup. +func (s *Store[TID]) EnsureIndexes(ctx context.Context) error { + _, err := s.events.Indexes().CreateOne(ctx, driver.IndexModel{ + Keys: bson.D{{Key: "aggregate_id", Value: 1}, {Key: "version", Value: 1}}, + Options: options.Index().SetUnique(true).SetName("IX_aggregate_version"), + }) + if err != nil { + return fmt.Errorf("create event-stream index: %w", err) + } + return nil +} + +// guidOrEmpty renders a guid, mapping the empty guid to "" so absent metadata +// persists as an absent BSON field rather than a zero id. +func guidOrEmpty(g guid.Guid) string { + if g == guid.Empty { + return "" + } + return g.String() +} diff --git a/mongo/store_test.go b/mongo/store_test.go new file mode 100644 index 0000000..b3d796f --- /dev/null +++ b/mongo/store_test.go @@ -0,0 +1,85 @@ +package mongo + +import ( + "testing" + "time" + + "github.com/iamkoch/conqueress" + "github.com/iamkoch/conqueress/eventstore" + "github.com/iamkoch/conqueress/guid" + "github.com/stretchr/testify/assert" +) + +// itemID is a composite aggregate id, proving the store is generic over any +// Stringer-able ID, not just guid.Guid. +type itemID struct { + Tenant string + Item string +} + +func (i itemID) String() string { return i.Tenant + "/" + i.Item } + +type itemCreated struct { + *conqueress.BaseEvent + Name string +} + +type itemRenamed struct { + *conqueress.BaseEvent + NewName string +} + +// Compile-time conformance: a Store over a composite ID satisfies the +// context-aware port. +var _ eventstore.Store[itemID] = (*Store[itemID])(nil) + +func newTestRegistry() *TypeRegistry { + r := NewTypeRegistry() + Register[itemCreated](r, "item-created") + Register[itemRenamed](r, "item-renamed") + return r +} + +func TestRegistry_RoundTripsWithMetadata(t *testing.T) { + r := newTestRegistry() + correlation := guid.New() + causation := guid.New() + occurred := time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC) + + original := conqueress.NewEvent[itemCreated](func(e *itemCreated) { e.Name = "widget" }) + original.WithMetadata(correlation, causation, occurred) + original.WithVersion(7) + + name, body, err := r.Encode(original) + assert.NoError(t, err) + assert.Equal(t, "item-created", name) + + decoded, err := r.Decode(name, body) + assert.NoError(t, err) + + created, ok := decoded.(itemCreated) + assert.True(t, ok, "decoded event is the concrete value type") + assert.Equal(t, original.MsgId(), created.MsgId()) + assert.Equal(t, 7, created.Version()) + assert.Equal(t, correlation, created.CorrelationId()) + assert.Equal(t, causation, created.CausationId()) + assert.Equal(t, occurred, created.OccurredAt()) + assert.Equal(t, "widget", created.Name) +} + +func TestRegistry_UnregisteredTypeFailsLoudly(t *testing.T) { + r := newTestRegistry() + + type rogue struct{ *conqueress.BaseEvent } + _, _, err := r.Encode(conqueress.NewEvent[rogue]()) + assert.ErrorContains(t, err, "not registered") + + _, err = r.Decode("never-registered", []byte(`{}`)) + assert.ErrorContains(t, err, "no decoder registered") +} + +func TestGuidOrEmpty_MapsEmptyGuidToAbsent(t *testing.T) { + assert.Equal(t, "", guidOrEmpty(guid.Empty)) + g := guid.New() + assert.Equal(t, g.String(), guidOrEmpty(g)) +}