Skip to content
Event Sourcing: Decide and Evolve

Event Sourcing: Decide and Evolve

Event sourcing with an aggregate: a command decides which events happen, each event evolves the aggregate, and cfgevs.NewHandler ties both together.

Event Sourcing: Decide and Evolve

package main

import (
	"context"
	"errors"
	"slices"
	"time"

	"github.com/worldiety/option"
	"go.wdy.de/nago/application"
	"go.wdy.de/nago/application/evs"
	cfgevs "go.wdy.de/nago/application/evs/cfg"
	"go.wdy.de/nago/auth"
	"go.wdy.de/nago/pkg/data"
	"go.wdy.de/nago/pkg/xerrors"
	"go.wdy.de/nago/presentation/core"
	"go.wdy.de/nago/presentation/ui"
	"go.wdy.de/nago/presentation/ui/alert"
	"go.wdy.de/nago/web/vuejs"
)

type EID string

type EmployeeAggregate struct {
	ID       EID
	Name     string
	Meetings []MeetingID
}

func (e *EmployeeAggregate) Clone() *EmployeeAggregate {
	return &EmployeeAggregate{
		ID:       e.ID,
		Name:     e.Name,
		Meetings: slices.Clone(e.Meetings),
	}
}

func (e *EmployeeAggregate) IsDeleted() bool {
	return false
}

type EmployeeEvent interface {
	evs.Evt[*EmployeeAggregate]
	Employee() EID
}

type EmployeeIntroduced struct {
	EmployeeID EID
	Name       string
}

func (e EmployeeIntroduced) Employee() EID {
	return e.EmployeeID
}

func (e EmployeeIntroduced) Evolve(ctx context.Context, aggregate *EmployeeAggregate) error {
	aggregate.ID = e.EmployeeID
	aggregate.Name = e.Name
	return nil
}

func (e EmployeeIntroduced) Discriminator() evs.Discriminator {
	return "EmployeeIntroduced"
}

// MeetingID references another aggregate root. Replaying and folding/evolving
// a meeting event into different aggregate roots is considered an architectural
// smell
type MeetingID string

// MeetingPlanned just references an external aggregate root for Meetings.
type MeetingPlanned struct {
	Participant EID
	Meeting     MeetingID
}

func (e MeetingPlanned) Evolve(ctx context.Context, aggregate *EmployeeAggregate) error {
	aggregate.Meetings = append(aggregate.Meetings, e.Meeting)
	return nil
}

func (e MeetingPlanned) Discriminator() evs.Discriminator {
	return "MeetingPlanned"
}

func (e MeetingPlanned) Employee() EID {
	return e.Participant
}

func main() {
	application.Configure(func(cfg *application.Configurator) {
		cfg.SetApplicationID("de.worldiety.tutorial_85_2")
		cfg.Serve(vuejs.Dist())

		option.MustZero(cfg.StandardSystems())
		option.Must(option.Must(cfg.UserManagement()).UseCases.EnableBootstrapAdmin(time.Now().Add(time.Hour), "%6UbRsCuM8N$auy"))
		cfg.SetDecorator(cfg.NewScaffold().Decorator())

		handler := option.Must(cfgevs.NewHandler[*EmployeeAggregate, EmployeeEvent, EID](
			cfg,
			"test.employee",
			"Mitarbeiter",
			func(e EmployeeEvent) (EID, bool) {
				id := e.Employee()
				return id, id != ""
			},
			[]EmployeeEvent{
				EmployeeIntroduced{},
				MeetingPlanned{},
			},
		))

		ucIntroduce := NewIntroduceEmployee(handler)

		cfg.RootViewWithDecoration(".", func(wnd core.Window) core.View {
			name := core.AutoState[string](wnd)

			return ui.VStack(
				ui.TextField("Name", name.Get()).InputValue(name),
				ui.PrimaryButton(func() {
					if err := ucIntroduce(wnd.Subject(), IntroduceEmployeeCmd{Name: name.Get()}); err != nil {
						alert.ShowBannerError(wnd, err)
					}
				}).Title("Introduce Employee"),
			)
		})
	}).Run()
}

// Architecture style note:
// To align your code better with Event Storming keep your use case function declaration but summarize
// your arguments into a single struct called <UseCaseName>Cmd. The Result of that function is
// the Event which has been stored. See the example below

type IntroduceEmployeeCmd struct {
	ID   EID
	Name string
}

func (cmd IntroduceEmployeeCmd) Decide(subject auth.Subject, aggregate *EmployeeAggregate) ([]EmployeeEvent, error) {
	var errs xerrors.Errors

	if cmd.Name == "" {
		errs.Add("Name", "must not be empty")
	}

	if errs.Has() {
		return nil, errs.Error()
	}

	return []EmployeeEvent{EmployeeIntroduced{EmployeeID: cmd.ID, Name: cmd.Name}}, nil
}

type IntroduceEmployee func(subject auth.Subject, cmd IntroduceEmployeeCmd) error

func NewIntroduceEmployee(handler *evs.Handler[*EmployeeAggregate, EmployeeEvent, EID]) IntroduceEmployee {
	return func(subject auth.Subject, cmd IntroduceEmployeeCmd) error {
		if !subject.Valid() {
			// apply domain specific authorization here
			return errors.New("invalid subject")
		}

		if cmd.ID == "" {
			cmd.ID = data.RandIdent[EID]()
		}

		_, err := handler.Handle(subject, cmd.ID, cmd)
		return err
	}
}

View on GitHub ↗ · run it with go run go.wdy.de/nago/example/cmd/tutorial-85-eventsourcing-decide-evolve@latest