Add a command
A command is anything that changes an aggregate: register a user, change their email, link Google. There is no command bus. A command is a method on the aggregate, called from a use case, which is a method on the service’s app.
This guide adds one in the order that keeps each step testable. The samples are the user service’s own email change, from internal/ and internal/.
-
Name the event. It is a fact, in the past tense, with a versioned name:
user.display_name_changed.v1. Its payload is a struct with JSON tags ininternal/, holding only what the fact needs. No secrets, and no personal data beyond that.user/ domain/ events.go internal/user/domain/events.go // EmailChanged means the user confirmed a new address.type EmailChanged struct {Old Email `json:"old"`New Email `json:"new"`} -
Register it in
RegisterEvents, next to the others. The name is stored with every event and never changes.internal/user/domain/events.go // RegisterEvents names the user events in r. The names are stored with// every event, so they never change.func RegisterEvents(r *es.Registry) {es.Register[Registered](r, "user.registered.v1")es.Register[EmailChanged](r, "user.email_changed.v1")es.Register[GoogleLinked](r, "user.google_linked.v1")} -
Add the aggregate method and its
Applycase. The method checks the rule and raises the event.Applyonly assigns: it never fails on a rule, because stored events are facts.internal/user/domain/user.go // User is someone who can sign in: an address, and at most one Google// account.type User struct {es.Changesid uuid.UUIDemail EmailgoogleSubject string}// New returns an empty User, to register or to rebuild from its stream.func New() *User { return &User{} }// ID implements es.Aggregate.func (u *User) ID() uuid.UUID { return u.id }// ChangeEmail moves the account to the address to.func (u *User) ChangeEmail(to Email) error {switch {case !u.registered():return errNotRegisteredcase to.IsZero():return apperr.Invalid("email", "is required")case to == u.email:return ErrSameEmail}return es.Raise(u, EmailChanged{Old: u.email, New: to})}// Apply implements es.Aggregate.func (u *User) Apply(event any) error {switch e := event.(type) {case Registered:u.id, u.email = e.UserID, e.Emailcase EmailChanged:u.email = e.Newcase GoogleLinked:u.googleSubject = e.Subjectdefault:return fmt.Errorf("user: can't apply %T", event)}return nil} -
Test the aggregate with Given/When/Then. Given the history, when the method runs, then exactly these events are raised, or this error and nothing else.
internal/user/domain/user_test.go func TestChangeEmail(t *testing.T) {u := estest.Given(t, domain.New(), registered()...).When(func(u *domain.User) error { return u.ChangeEmail(grace) }).Then(domain.EmailChanged{Old: ada, New: grace})if u.Email() != grace {t.Errorf("email = %v, want %v", u.Email(), grace)}}func TestChangeEmailRules(t *testing.T) {tests := []struct {name stringhistory []anyto domain.Emailwant error}{{"to the same address", registered(), ada, domain.ErrSameEmail},{"to the same address in another case", registered(), mustEmail("ADA@example.COM"), domain.ErrSameEmail},{"to no address", registered(), domain.Email{}, apperr.ErrInvalid},{"before registering", nil, grace, apperr.ErrNotFound},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {estest.Given(t, domain.New(), tt.history...).When(func(u *domain.User) error { return u.ChangeEmail(tt.to) }).ThenError(tt.want)})}}Write one test per rule, for both outcomes. These tests need no database. Add the event to
TestEventsKeepTheirStoredShapetoo, which pins each event’s stored JSON. -
Project it in every backend. Each store’s
Project, ininternal/,user/ store/ memory mysqlandmongodb, refuses an event it has no case for, so a new event can’t be saved until all three handle it, even when it changes no column. Write the rule intointernal/first; a newuser/ store/ storetest store.Userfield also goes in itsassertUser. In MongoDB the write is thebson.Dinproject;userDocis only for reads. Add a projection covers new columns and indexes. -
Write the use case in
internal/. It gets the caller fromuser/ app/ app.go Actor(ctx, scope), which also checks the scope, never from the request body. Load the user and call the aggregate method befores.uow.Do. Inside it, only write:s.repo.Save, withmetanaming the actor, ands.outbox.Addfor any message. A backend may run that function again, so it must not change the aggregate:internal/user/app/app.go // ConfirmEmailChange uses up the calling user's email-change code and moves// the account to the address it was mailed to. Another user's code is no// code at all. An address another account has taken since is// store.ErrEmailTaken, and the code stays usable.func (s *Service) ConfirmEmailChange(ctx context.Context, code string) (store.User, error) {id, err := Actor(ctx, ScopeUserWrite)if err != nil {return store.User{}, err}hash, ok := codeHash(code)if !ok {return store.User{}, errBadEmailChangeCode}now := s.clock()lc, err := s.codes.Find(ctx, hash, store.PurposeEmailChange, now)switch {case errors.Is(err, store.ErrCodeUnusable), err == nil && lc.UserID != id:return store.User{}, errBadEmailChangeCodecase err != nil:return store.User{}, fmt.Errorf("confirm email change: %w", err)}addr, err := domain.ParseEmail(lc.Email)if err != nil {return store.User{}, fmt.Errorf("confirm email change: stored address %q: %w", lc.Email, err)}user, err := s.repo.Load(ctx, id)if err != nil {return store.User{}, fmt.Errorf("confirm email change: %w", err)}if err := user.ChangeEmail(addr); err != nil {return store.User{}, err}err = s.uow.Do(ctx, func(tx uow.Tx) error {if _, err := s.codes.Consume(ctx, tx, hash, store.PurposeEmailChange, now); err != nil {return err}_, err := s.repo.Save(ctx, tx, user, meta(ctx))return err})if errors.Is(err, store.ErrCodeUnusable) {return store.User{}, errBadEmailChangeCode}if err != nil {return store.User{}, fmt.Errorf("confirm email change: %w", err)}return s.saved(ctx, id)}If the change has a side effect, such as an email, make the message with
outbox.NewMessageand add it inside the sameDocall:internal/user/app/app.go // RequestEmailChange mails a confirmation link to email, the address the// calling user wants instead of theirs. The address changes only when the// link's code comes back through ConfirmEmailChange, and whether another// account has it is told only then.func (s *Service) RequestEmailChange(ctx context.Context, email string) error {id, err := Actor(ctx, ScopeUserWrite)if err != nil {return err}addr, err := domain.ParseEmail(email)if err != nil {return err}u, err := s.users.ByID(ctx, id)if err != nil {return fmt.Errorf("request email change: %w", err)}if u.Email == addr.String() {return domain.ErrSameEmail}now := s.clock()code, hash := newCode()lc := store.LoginCode{Hash: hash, Purpose: store.PurposeEmailChange, Email: addr.String(), UserID: id, ExpiresAt: now.Add(LoginCodeTTL)}link := s.link(url.Values{"code": {code}}, "profile", "confirm-email")msg, err := outbox.NewMessage(mail.TopicEmailChange, mail.Link{To: addr.String(), URL: link.String(), ExpiresAt: lc.ExpiresAt}, now)if err != nil {return fmt.Errorf("request email change: %w", err)}err = s.uow.Do(ctx, func(tx uow.Tx) error {if err := s.codes.Add(ctx, tx, lc); err != nil {return err}return s.outbox.Add(ctx, tx, msg)})if err != nil {return fmt.Errorf("request email change: %w", err)}return nil}Test it in
app_test.go, with the memory stores and an injected clock. -
Expose it through an endpoint, its OpenAPI entry and an authorization-matrix row. See Add an endpoint. Add the event to the list on Aggregates and events.
-
Run the checks:
Terminal window go test -race -shuffle=on ./internal/user/... ./internal/archtest/...make checkWithout Docker, add
-short: the MySQL and MongoDB store tests then run only in CI.
Mistakes the checks won’t catch
Section titled “Mistakes the checks won’t catch”- Validating in
Apply. Old events would fail to load the day the rule changes. Check rules in the method, beforeRaise. - Renaming a stored event. Existing streams stop loading. Add a
.v2with an upcast instead; see Aggregates and events. - Doing I/O inside
Do. A backend may run the function twice. Send nothing from it; add an outbox message. - Retrying
es.ErrConcurrencyin the use case. Return it. The client gets a409and decides with fresh data.