Skip to content

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/user/domain and internal/user/app.

  1. 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 in internal/user/domain/events.go, holding only what the fact needs. No secrets, and no personal data beyond that.

    internal/user/domain/events.go
    // EmailChanged means the user confirmed a new address.
    type EmailChanged struct {
    Old Email `json:"old"`
    New Email `json:"new"`
    }
  2. 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")
    }
  3. Add the aggregate method and its Apply case. The method checks the rule and raises the event. Apply only 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.Changes
    id uuid.UUID
    email Email
    googleSubject 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 errNotRegistered
    case 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.Email
    case EmailChanged:
    u.email = e.New
    case GoogleLinked:
    u.googleSubject = e.Subject
    default:
    return fmt.Errorf("user: can't apply %T", event)
    }
    return nil
    }
  4. 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 string
    history []any
    to domain.Email
    want 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 TestEventsKeepTheirStoredShape too, which pins each event’s stored JSON.

  5. Project it in every backend. Each store’s Project, in internal/user/store/memory, mysql and mongodb, 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 into internal/user/store/storetest first; a new store.User field also goes in its assertUser. In MongoDB the write is the bson.D in project; userDoc is only for reads. Add a projection covers new columns and indexes.

  6. Write the use case in internal/user/app/app.go. It gets the caller from Actor(ctx, scope), which also checks the scope, never from the request body. Load the user and call the aggregate method before s.uow.Do. Inside it, only write: s.repo.Save, with meta naming the actor, and s.outbox.Add for 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{}, errBadEmailChangeCode
    case 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.NewMessage and add it inside the same Do call:

    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.

  7. 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.

  8. Run the checks:

    Terminal window
    go test -race -shuffle=on ./internal/user/... ./internal/archtest/...
    make check

    Without Docker, add -short: the MySQL and MongoDB store tests then run only in CI.

  • Validating in Apply. Old events would fail to load the day the rule changes. Check rules in the method, before Raise.
  • Renaming a stored event. Existing streams stop loading. Add a .v2 with 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.ErrConcurrency in the use case. Return it. The client gets a 409 and decides with fresh data.