Event-sourced user service
Users are rebuilt from their own event stream. Event names are versioned, old shapes are upcast, and every append checks the stream’s version.
Two services in one module. A token service with OAuth2, PKCE and JWKS, and an event-sourced user service that sends mail through a transactional outbox. Memory, MySQL or MongoDB, and one command to run it all.
gh repo create my-api --template vardius/go-api-boilerplate --private --clone // 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}// LinkGoogle links the Google account with subject to the calling user, who// needs ScopeUserWrite, and returns the web app's profile page to go back// to. A Google account linked to someone else is store.ErrGoogleTaken.func (s *Service) LinkGoogle(ctx context.Context, subject string) (*url.URL, error) { id, err := Actor(ctx, ScopeUserWrite) if err != nil { return nil, err } user, err := s.repo.Load(ctx, id) if err != nil { return nil, fmt.Errorf("link google: %w", err) } if err := user.LinkGoogle(subject); err != nil { return nil, err } err = s.uow.Do(ctx, func(tx uow.Tx) error { _, err := s.repo.Save(ctx, tx, user, meta(ctx)) return err }) if err != nil { return nil, fmt.Errorf("link google: %w", err) } return s.link(url.Values{"google": {GoogleLinked}}, "profile"), nil}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) }}Features
Event-sourced user service
Users are rebuilt from their own event stream. Event names are versioned, old shapes are upcast, and every append checks the stream’s version.
Your own token service
OAuth2 with PKCE S256, client credentials and refresh-token rotation with reuse detection. ES256 access tokens, verified with the keys it publishes as a JWKS.
Transactional outbox
Emails are queued in the same transaction as the change that causes them, then delivered at least once with backoff.
Three stores, one contract
Memory, MySQL and MongoDB pass the same contract suite. A build tag picks one per binary.
One command to run
docker compose up --wait starts both services and the web app on localhost:8080, and Mailpit on
localhost:8025. No cluster, no hosts file.
OpenTelemetry
JSON logs with trace and request IDs. Traces and metrics go out over OTLP once you set an endpoint.
Ready for AI agents
An AGENTS.md in every area, written code standards, and checks that enforce the rules agents tend to
break.
Architecture
A browser talks to one origin. Behind it, auth owns tokens and user owns users, and neither calls the other on a normal request.
/api/auth/ and/api/user/, so cookies stay SameSite and nothing needs CORS.users projection and queues its emails in one transaction.Create your repository from the template, start it with Docker Compose, and sign in with a link from Mailpit.