← Series Scroll view
Building Mixology

Building a modular monolith in Go

A cocktail bar application with seven business owners, three interfaces, and tests that keep their boundaries intact.

← / → chapter↑ / ↓ detailS speaker viewEsc map
Orientation

Why this application exists

Architecture is the set of changes the codebase makes easy, and the shortcuts it refuses.

00

Why a cocktail bar makes a useful teaching repo

Understandable business

Ingredients make drinks. Menus offer drinks. Orders reserve and consume ingredients.

Connected decisions

A stock correction can block an order. Retiring an ingredient can require recipe review and change menu availability.

Visible consequences

The CLI, TUI, and GUI must agree about state, permission, failure, and what committed.

I chose a small business model with enough interaction to make ownership and consistency problems unavoidable.

What “modular monolith” means here

Monolith

The domains run together in a Go process and collaborate through ordinary calls. Related writes can share one SQLite transaction.

+

Modular

Each domain owns its decisions and storage. Other domains use its public read contracts and events.

CLI, TUI, and GUI are separate executables built around the same application. They can run together against one database file on the same machine.

Turn ownership into checks

A package diagram

Shows the intended dependency direction.

It cannot stop the next convenient import.

An executable boundary

Makes ownership visible in APIs, types, transactions, generators, tests, and import rules.

The shortest wrong path fails.

The reference application is a cocktail bar because inventory, recipes, menus, and orders create real pressure between domains.

Seven business owners

Ingredientscatalog + retirement
Drinksrecipes
Inventorystock
Orderslifecycle
Auditappend-only activity
Taggingassociations
caller → public query owner event owner ⇢ reacting owner

Selected collaborations; Audit and Tagging connections omitted.

Find the owner of an operation

main/<surface>process composition
surfaces/<surface>domain presentation
domain rootfacade + pipeline
queries / internalbehavior + storage

CLI

One command, one fresh operation context.

Bubble Tea TUI

Persistent session, message-driven presentation.

Fyne GUI

Retained native widgets and managed lifecycle.

All three enter the same application behavior and local SQLite database. Views are adapters, not alternate applications.

Start by running and reading one list

# From go-modular-monolith, with Go 1.27.1+
go run ./main/seed
go run ./main/cli ingredients list --limit 5 --json
go run ./main/cli ingredients list --filter-help
go run ./main/cli --actor bartender menus list
go run ./main/tui
Start with sample data and one visible result. Then follow the same list from its CLI adapter to the module, query, DAO, and authorization step.

The application behind the examples

Mixology terminal dashboard with Drinks, Ingredients, Inventory, Menus, Orders, Audit, and Tags workspaces plus recent activity.
Seven workspaces, live counts, and recent activity in the terminal shell.

Headless capture · owner persona · go-modular-monolith 635c59b · select image for full size

The route through the series

ChaptersQuestion we will answer
1.1–1.8 · foundationsWho owns a change, and what happens on every operation?
2.1–2.4 · collaborationHow do events, tags, filters, and storage preserve those rules?
3.1a–d · business walkthroughsWhat happens when stock, recipes, and accepted orders disagree?
4.0–4.6 · interfacesHow do three runtimes expose the same application correctly?
3.2 · optional future workshopWhat would a Procurement workflow add?
This is a teaching order through today's application. Each chapter connects a business problem to code, a design choice, and a test.
Foundation 1.1

Shape modules around business ownership

A modular monolith begins with decisions, language, and collaboration contracts, not a folder template.

1.1

Give each business decision one owner

Public facadeapp/domains/drinks composes and exposes supported behavior.
Public contractsmodels, queries, and events are explicit collaboration contracts.
Event handlershandlers consume public facts and mutate only their owning domain.
Private workinternal/commands and internal/dao remain implementation details.
If a neighboring domain can import the command that “just does the thing,” ownership is only a suggestion.

Seven contexts, three module profiles

ProfileContextsWhy it exists
OperationalIngredients, Drinks, Inventory, Menus, Ordersbusiness state, commands, queries, events, persistence, policy
ActivityAuditappend-only evidence written by the operation pipeline
Cross-cutting domainTaggingowned associations over registered operational targets
Consistency does not require identical package trees. Audit and Tagging use smaller profiles because their responsibilities differ.

Public does not mean interchangeable

Application caller

A surface calls Orders.Place on the public module. The module enters the pipeline that authorizes, executes, and audits the operation.

Collaborating domain

Orders reads a published menu through public query contracts. Inventory reacts to OrderPlaced through its own handler.

A public query package supports internal collaboration. It does not itself promise the authorized application boundary supplied by the module facade.

Composition is ordinary, visible Go

FoundationAudit + Tagging schemas
Portstag repository + empty registry
Evidenceseparate audit writer
Pipelinedispatcher + writer callback
Operational modulesrows + tag targets
Public facadesTagging + Audit

No import side effects

Private SQLite rows register during construction. Invalid or missing registration fails at startup or in architecture tests.

No second manifest

TestEveryDomainIsComposed treats domain directories as the source of truth and verifies app.New.

The private Audit writer exists before the public Audit facade, breaking the construction cycle between pipeline activity and authorized audit reads.

Application state is not request state

App

Store and public modules whose private composition retains the configured pipeline. No actor identity.

+

Session

Binds a persistent TUI or GUI to an authenticated base context, then creates a fresh operation context every time.

base contextactor + logger + metrics
Session.Context()fresh mutable state
operationevents + activity + attributes
discardnothing leaks forward
The CLI starts fresh per invocation. Persistent clients reuse authentication, never accumulated operation state.

Construct the dependency graph in one place

tags := tagging.NewRepository(s)
targets := tagging.NewRegistry()
auditWriter := audit.NewWriter(s)
pipeline := middleware.NewPipeline(middleware.PipelineConfig{
    Store:          s,
    Dispatcher:     dispatcher.New(s, tags),
    Metrics:        telemetry.FromContext(ctx),
    RecordActivity: auditWriter.RecordActivity,
})
ingredientsModule := ingredients.NewModule(
    ctx, s, tags, targets, pipeline,
)
The pipeline receives an audit-writing capability. The public Audit module is constructed later with that same pipeline.

Code: app/app.go

Copy operation state, retain transaction identity

func (c *Context) forOperation() *Context {
    derived := *c
    derived.Context = c.Context
    derived.events = make([]any, 0, 4)
    derived.activity = nil
    return &derived
}
The principal and optional transaction survive the copy. The event slice and activity do not: two commands can share a commit without sharing an operation.

Code: pkg/middleware/context.go

Foundation 1.2

Use types to prevent easy mistakes

An invariant is a rule that must remain true. Types enforce some rules; validation and transactions enforce the rest.

1.2

Let types carry the distinctions they can

Generated entity IDs

DrinkID and IngredientID share the same generated Cedar method shape without becoming interchangeable parameters.

Closed amount variants

An unexported isAmount method limits Amount to volume and discrete quantities owned by the kernel.

Validated values

Currency, price, quantity, and tag parsers turn accepted external text into domain-shaped values.

Go still permits zero values and package-local construction. Constructors, decoding validation, and boundary checks carry the guarantees the type system cannot.

Adjacent article: Making Illegal States Unrepresentable in Go

Identity should reveal the entity

Raw string

func Load(id string)

Load(orderID) // compiles

Meaning survives only in names and review.

Generated ID

func Load(id DrinkID)

Load(orderID) // compiler error

Parsing, prefixes, Cedar identity, and JSON behavior stay consistent.

Six entity ID types share generated mechanics without becoming interchangeable values.

Model absence, variants, and concurrency explicitly

Closed variants

Amount accepts only kernel-owned volume or discrete quantity implementations.

Intentional absence

optional.Value[T] distinguishes absent from present, including a deliberately present zero value.

Opaque revision

Mutable public models round-trip the store token. Surfaces never compare or increment it.

Use types to preserve meaning, then validate values read from input or storage.

Capability types remove forbidden moves

middleware.Context

Transaction, principal, activity, AddEvent, and TouchEntity.

Commands may originate owned facts.

HandlerContext

Transaction, principal, and TouchEntity. No event accumulator.

Reactions cannot cascade.

The most reliable prohibition is an API that cannot express the forbidden operation.

The ID is a defined type, with generated behavior

type DrinkID cedar.EntityUID

func ParseDrinkID(id string) (DrinkID, error) {
    uid, err := parseID(TypeDrink, PrefixDrink, id)
    return DrinkID(uid), err
}
func (id DrinkID) EntityUID() cedar.EntityUID {
    return cedar.EntityUID(id)
}
func (id DrinkID) String() string {
    return string(cedar.EntityUID(id).ID)
}
Domain signatures retain DrinkID. Conversion to Cedar is explicit at the policy boundary; parsing validates external IDs at entry.

Code: app/kernel/entity/entities_gen.go

A closed variant still needs runtime validation

// Selected Amount methods.
type Amount interface {
    Unit() Unit
    Value() float64
    Add(Amount) (Amount, error)
    Convert(Unit) (Amount, error)
    isAmount()
}

// Executable example.
volume := measurement.MustAmount(30, measurement.UnitMl)
pieces := measurement.MustAmount(1, measurement.UnitPiece)
_, err := volume.Add(pieces)
// Invalid: "unit mismatch: ml vs piece"
The unexported marker controls direct implementations. Add and Convert enforce dimensional compatibility between valid variants.

Code: app/kernel/measurement/amount.go

Represent physical quantity separately from display units

type Volume struct { ml float64 }
type Quantity struct {
    Volume Volume
    Unit   Unit // preferred display unit
}

// Convert retains the same Volume; it changes the display unit.
q := measurement.MustQuantity(1, measurement.UnitOz)
ml, _ := q.Convert(measurement.UnitMl)
q.Value()  // 1
ml.Value() // 29.5735

// Pieces, dashes, and splashes cannot convert to liquid volume.
Amounts prevent dimensional mistakes. They do not make floating-point arithmetic exact or enforce every business quantity constraint.

Code: app/kernel/measurement/quantity.go

Foundation 1.3

Make errors part of the application protocol

Domains choose meaning once. Every present and future edge chooses only how to render it.

1.3

Failure kind is not presentation

Domain / storetyped failure
pkg/errorssemantic kind
CLI / TUI / GUInative feedback
HTTP / gRPCfuture mapping
Lower layers never choose exit codes, colors, dialogs, HTTP status, or gRPC codes.

Six meanings cover the application boundary

KindMeaningTypical response
Invalidthe request is malformedfix input
NotFoundthe resource does not existchoose another
Permissionthe actor is not allowedhide or deny
Conflictstate collides or revision is stalereload or rename
FailedPreconditionvalid request, invalid current stateresolve prerequisite
Internalinvariant or dependency failedsafe message + diagnostics

Diagnostics and safe text are different data

For operators

errors.Internalf(
  "load inventory: %w", err,
)

Error() retains the cause for logs and wrapping.

For people

err.WithUserMessage(
  "Inventory is unavailable",
)

Internal detail is generic unless explicitly made safe.

Unknown errors do not inherit this safety guarantee. Classify unexpected failures before a presentation boundary.

Generate the repetitive family, enforce one vocabulary

Generated

Typed constructors, classifiers, metadata, and matching test assertions.

Wrapped

%w, Is, and As preserve semantic inspection through context.

Enforced

arch-lint rejects direct standard-library errors imports outside pkg/errors.

Contextual wrappers preserve discoverable error meaning. A new typed wrapper deliberately changes the outer classification.

Two types separate classification from payload

// Shared payload (error.go).
type Error struct {
    kind        Kind
    detail      string
    userMessage string
    cause       error
}

// Generated wrapper (errors_gen.go).
type InternalError struct { err *Error }

func Internalf(format string, args ...any) *InternalError {
    return &InternalError{err: newErrorf(KindInternal, format, args...)}
}
InternalError identifies the failure family. Error carries the immutable kind, diagnostic detail, safe override, and one wrapped cause.

Code: pkg/errors/errors_gen.go

Build one failure and let the real evaluator wrap it

// Injected failure; the evaluator and wrapping are real.
cause := errors.New("database is locked")
failure := errors.Internalf("load readiness: %w", cause).
    WithUserMessage("Readiness is temporarily unavailable")

_, err := actions.Evaluate(ctx, actions.Group{
    Permission: actions.Public(),
    Controls: []actions.Control{{ID: "publish",
        Conditions: []actions.Condition{
            func(context.Context) (bool, string, error) {
                return false, "", failure
            },
        },
    }},
})
The condition returns a typed error. evaluateControl adds the condition index; evaluateGroup adds the control ID. Neither layer needs a CLI or GUI type.

Code: pkg/presentation/actions/actions.go

Read the complete error chain

Evaluate → evaluateGroup → evaluateControl → condition
                                               ↓
*fmt.wrapError
  actions: control "publish": condition 0:
    load readiness: database is locked
  └─ *fmt.wrapError
       condition 0: load readiness: database is locked
       └─ *errors.InternalError
            load readiness: database is locked
            └─ *errors.errorString
                 database is locked
This is a causal wrapping chain, not a captured Go runtime stack. Error() includes every added prefix; Unwrap preserves the path to the original cause.

Code: pkg/presentation/actions/actions.go

As reaches both the typed wrapper and shared payload

var typed *errors.InternalError
var payload *errors.Error

errors.As(err, &typed)   // true
errors.As(err, &payload) // true: generated As method
errors.Is(err, cause)    // true: same original cause
payload.Kind()          // KindInternal
payload.UserMessage()   // "Readiness is temporarily unavailable"

// The generated bridge, abbreviated:
if p, ok := target.(**Error); ok {
    *p = e.err
    return true
}
A domain caller can classify the concrete failure. A generic adapter can extract the common payload without switching over six wrapper types.

Code: pkg/errors/errors_gen.go

The same failure has different diagnostic and UI output

Diagnostic Error():
  actions: control "publish": condition 0:
  load readiness: database is locked

CLI: exit code 50
  Readiness is temporarily unavailable

TUI: style "error", original cause retained
  Readiness is temporarily unavailable

GUI: severity Error, original cause retained
  Readiness is temporarily unavailable

Without WithUserMessage:
  internal error
The safe message comes from the classified payload, so outer diagnostic prefixes do not leak into CLI, TUI, or GUI adapter output.

Code: pkg/errors/cli.go

The CLI makes error kinds observable

$ echo '{"name":"","category":"spirit","unit":"oz"}' |
    ./mixology ingredients create --stdin
name is required
$ echo $?
10

$ ./mixology ingredients create --category spirit --unit oz "London Dry Gin"
insert ingredient "London Dry Gin"
$ echo $?
40

$ ./deck-error-probe # injected Internal from the preceding example
Readiness is temporarily unavailable
$ echo $?
50
Invalid → 10, Conflict → 40, Internal → 50. The message goes to stderr; stdout stays empty.

Captured process output · real CLI for Invalid / Conflict · injected Internal probe

Invalid: the terminal identifies bad input

Terminal Ingredients workspace with the red root status message: name is required.
The root status bar renders name is required with error styling; the CLI exits 10.

TUI root status-bar adapter · owner persona · 635c59b · select image for full size

Invalid: keep the correction in the form

Desktop New ingredient form with an empty name, spirit category, ounce unit, description Keep this correction, and inline Error: name is required.
The GUI keeps name is required inline and preserves the other entered fields.

Fyne desktop · owner persona · 635c59b · select image for full size

Conflict: the terminal changes severity

Terminal Ingredients workspace with the yellow root status message: insert ingredient London Dry Gin.
The root status bar uses warning styling for Conflict; the CLI exits 40.

TUI root status-bar adapter · owner persona · 635c59b · select image for full size

Conflict: preserve the attempted change

Desktop New ingredient form for London Dry Gin with an Unable to complete operation dialog saying insert ingredient London Dry Gin; the entered description remains in the form.
A warning dialog reports the collision while the duplicate-name form keeps its input.

Fyne desktop · owner persona · 635c59b · select image for full size

Internal: show the safe message in the terminal

Terminal Menus workspace with a red root status message: Readiness is temporarily unavailable. No database or evaluator details are displayed.
Injected dependency failure: the status bar shows Readiness is temporarily unavailable; exit code 50.

TUI root status-bar adapter · owner persona · 635c59b · select image for full size

Internal: show the safe message in a dialog

Desktop Menus workspace dimmed behind an Error dialog saying Readiness is temporarily unavailable.
Injected dependency failure: the GUI uses error severity and the same safe message as CLI and TUI.

Fyne desktop · owner persona · 635c59b · select image for full size

Choose where to preserve, translate, or classify

// Current store mapping, selected branches.
if errors.IsConflict(err) || isUniqueConstraint(err) {
    return errors.Conflictf(format, args...)
}
return errors.Internalf(format+": %w", append(args, err)...)

// Adding context without changing a known kind:
return fmt.Errorf("publish menu: %w", err)

// Unknown errors have no automatic safe-message guarantee.
errors.ToCLIExit(errors.New("raw dependency detail"))
// exit 1, message "raw dependency detail"
Expected store failures become domain-facing kinds. Unexpected failures retain their cause under Internal. A new typed wrapper is a semantic decision, not routine decoration.

Code: pkg/store/errors.go

Foundation 1.4

Give every operation one trustworthy path

Commands and queries should state intent while the pipeline owns the guarantees around them.

1.4

One path for application commands

SerializeTransaction
Logging + Metrics
TrackActivity
UnitOfWork begins
load → authorize → handle → authorize result
dispatch events → record success
commit everything, or nothing
A unit of work is the transaction containing one command and its reactions. Queries use a smaller chain: serialization, logging, metrics, and authorization.

Six typed entries, one middleware model

Query

Load a Cedar entity, then authorize the result.

QueryResource

Authorize a known resource around a non-entity result.

PageQuery

Fill a page with authorized rows without leaking denied ones.

Command

Authorize caller input and resulting state.

LoadCommand

Load trusted state inside the transaction.

LoadCommandActions

Derive transition-specific action requirements from loaded state.

Choose the method by the operation's input and authorization needs. The shared pipeline still owns transaction and failure behavior.

Code guide: pkg/middleware/README.md

The chain runs inward, then proves the result outward

Enter

Serialize, enrich logs, start metrics and activity, open the unit of work, load trusted state, authorize, handle.

Unwind

Authorize result, dispatch facts, record successful activity, commit, observe final duration and error.

Declaration order and completion order differ. Moving one middleware can move work outside the transaction or hide an unwind failure from telemetry.

Commit the command and its reactions together

domain mutation + prepared event reactions + touched entities + successful audit

Success

Every write and the durable activity record commit together.

One business operation.

or

Any failure

Result authorization, handler, audit, or storage error rolls the complete write graph back.

No partial truth.

A caller-supplied transaction retains commit and rollback ownership. Ordinary domain calls use the middleware-managed SQLite transaction.

Load trusted state inside the transaction

ID + intentsmall request
LoadCommandcurrent persisted value
authorizebefore + after
commitrevision still current

Trusted input state

Authority over the existing resource comes from persisted state, not attributes supplied by the caller.

Authorized result state

The actual result must also satisfy policy. A denial rolls back the mutation before event dispatch or commit.

The actual command chain is nested function calls

command: NewChain(
    SerializeTransaction(),
    Logging(),
    Metrics(config.Metrics),
    TrackActivity(config.Store, config.RecordActivity),
    UnitOfWork(config.Store),
    recordSuccessfulActivity(config.RecordActivity),
    DispatchEvents(config.Dispatcher),
)
Enter top to bottom; unwind bottom to top. Dispatch and successful audit complete before UnitOfWork returns and commits.

Code: pkg/middleware/chains.go

Publish authorizes persisted state, then the result

func (m *Module) Publish(
    ctx *middleware.Context, menu *models.Menu,
) (*models.Menu, error) {
    return m.pipeline.LoadCommand(ctx, authz.ActionPublish,
        func(ctx *middleware.Context) (*models.Menu, error) {
            return m.queries.Get(ctx, menu.ID)
        },
        m.commands.Publish,
    )
}
The submitted ID selects the resource. Its current fields are loaded inside the transaction before authorization; the returned entity is authorized again before dispatch.

Code: app/domains/menus/publish.go

Inject a late failure and inspect what persisted

// Selected recorder branches from the rollback test.
recordCalls++
if !activity.Success {
    return insertTransactionProbe(ctx, "failure-audit")
}
if err := insertTransactionProbe(ctx, "success-audit"); err != nil {
    return err
}
return errors.Internalf("audit unavailable")

// After the pipeline call that also inserted "business-write":
testutil.Equals(t, recordCalls, 2)
testutil.Equals(t, transactionProbeKinds(t, ctx, s),
    []string{"failure-audit"})
Business and success-audit rows roll back. Only the separately recorded failed attempt survives.

Code: pkg/middleware/command_tx_test.go

Store.Write and store.Write have different ownership

// Managed boundary: opens, commits, or rolls back a transaction.
err := s.Write(ctx, func(tx *store.Tx) error {
    // Return an error to reject the entire unit of work.
    return nil
})

// DAO helper: joins an existing transaction; never opens one.
func Write(ctx Context, f func(*Tx) error) error {
    tx, ok := ctx.Transaction()
    if !ok || tx == nil {
        return errors.Internalf("missing transaction")
    }
    return f(tx)
}
The lowercase package helper requires a transaction. Domain DAOs cannot accidentally turn one business operation into several commits.

Code: pkg/store/access.go

A caller-owned transaction owns every nested outcome

application composition opens transaction T
  command A joins T
    business write + reactions + success activity
  command B joins T
    business write + reactions + success activity
  caller returns nil  → commit all of T
  caller returns err  → roll back all of T

If B fails:
  propagate the error; do not commit A's tentative success
  joined middleware does not open a separate failure-audit write
A successful inner call is provisional until the caller commits. Transaction ownership includes error handling and audit policy.

Code: pkg/middleware/track_activity.go

Serialize shared transactions, not whole application instances

func SerializeTransaction() Middleware {
    return func(ctx *Context, _ Operation, next Next) error {
        if tx, ok := ctx.Transaction(); ok && tx != nil {
            defer store.LockTransaction(tx)()
        }
        return next(ctx)
    }
}

// LockTransaction keys a sync.Mutex by *store.Tx.
// Separate transactions are coordinated by SQLite.
The lock protects concurrent operations sharing one caller-owned transaction. It is not a process-wide writer lock or a reentrant transaction primitive.

Code: pkg/middleware/transaction.go

Foundation 1.5

Make the architecture executable

The compiler provides privacy. Generators, analyzers, and adversarial tests defend the dependency graph.

1.5

Compiler, generator, analyzer, test

RuleBest carrierExample
Coarse package privacyGo compilerinternal blocks callers outside the owning tree
Fine package allowlistarch-lintonly facades, queries, handlers, and internals may consume domain internals
Repetitive wiringGeneratorEvent and Cedar registration
Repository topologyarch-lintSurfaces stay bespoke; handlers cannot import commands
Composition completenessArchitecture testEvery domain is initialized by app.New
Business behaviorIntegration testRetirement rolls back as one operation

Capture ownership instead of naming every module

Capture the imported target

# imported target
forbid:
  - app/domains/{module}/internal/**

# permitted importers reuse {module}
except:
  - app/domains/{module}
  - app/domains/{module}/queries/**
  - app/domains/{module}/handlers/**
  - app/domains/{module}/internal/**

Importer must share ownership

The forbidden import captures {module}. Importer exceptions reuse that value, so Drinks cannot claim Ingredients’ private implementation.

One rule covers every current and future domain.

Future adapters inherit the rule

capture{module} + {surface}
forbidconcrete surface imports
exceptsame module + surface
fixtureCLI, TUI, GUI, and Web

Allowed fixture

drinks/surfaces/web/valid imports its own drinks/surfaces/web implementation.

Rejected fixture

drinks/surfaces/web/invalid-gui imports the GUI surface and domain-internal storage.

The same adversarial fixture separately rejects cross-domain surfaces, mismatched toolkits, and main imports across the current adapters. It proves the configuration, not merely a clean tree.

Adjacent project: arch-lint · architecture/arch_lint_test.go

Spend complexity when the pressure appears

Foldersmake ownership legible
Public contractsseparate questions from decisions
Eventsreverse reactive dependencies
Two phasespreserve pre-mutation facts
Projectionshare action meaning
SQLiteenable concurrent local clients
Rulesfreeze lessons into checks
The sequence matters. Each mechanism pays rent by solving a problem the working application has already made concrete.

An import can compile and still violate ownership

Importer: app/domains/drinks/surfaces/gui

Import drinks/internal/dao
  Go internal rule: allowed, importer is inside drinks/
  arch-lint: rejected, a surface is not an allowed consumer

Import ingredients/internal/dao
  Go internal rule: rejected, importer is outside ingredients/

Import drinks/surfaces/tui
  Go compiler: allowed
  arch-lint: rejected, GUI must not depend on TUI

Run: go tool arch-lint -config=.arch-lint.yaml
Go protects the owning subtree. Architecture rules narrow the permitted layers within it and prevent coupling between presentation runtimes.

Code: .arch-lint.yaml

Generated registration depends on concrete source conventions

Event:   struct under an events directory
Handler: method under app/.../handlers

func (h *StockAdjusted) Handle(
    ctx *middleware.HandlerContext,
    e inventoryevents.StockAdjusted,
) error

func NewStockAdjusted(
    s *store.Store, tags tag.Repository,
) *StockAdjusted

go generate ./pkg/dispatcher
The generator converts discoverable source structure into ordinary, reviewable calls. A package's presence alone does not register a reaction.

Code: pkg/dispatcher/README.md

A fixture must exercise the real commit boundary

f := testutil.NewFixture(t)
_, hasTx := f.OwnerContext().Transaction()
testutil.IsFalse(t, hasTx)

// Complete an order using 2 oz from 10 oz.
f.Orders.Complete(ctx, &ordersmodels.Order{ID: order.ID})
// stock == 8 oz

// Reuse ctx for an unrelated command.
f.Ingredients.Create(ctx, &ingredient)
// stock must still be 8 oz; old events must not replay.
Tests should preserve the production unit of work. A fixture-wide transaction can hide commit, rollback, and operation-state bugs.

Code: pkg/testutil/transaction_test.go

Authorization 1.6a

Model fine-grained access with Cedar

Policies combine identity, action, resource attributes, and tags. Domain-owned contracts make those decisions consistent across every interface.

1.6a

Fine-grained access is a resource decision

Identity

Which principal is making this request?

Capability

Which domain action is requested?

Scope

Which resource attributes and tags make that action permissible?

A sommelier can manage wine, read a tagged cocktail, and still be denied permission to update that cocktail.

Code: app/domains/drinks/authz/policies.cedar

Identity enters through the operation context

principal := authn.Sommelier()
// Mixology::Actor::"sommelier"

ctx := authn.ToContext(context.Background(), principal)
// The application's session/fixture constructs middleware.Context.
// The pipeline reads middleware.Context.Principal().

// Current demo actors:
owner, manager, sommelier, bartender, anonymous
Actor selection is a demo identity mechanism. Cedar determines that actor's access; it does not authenticate the caller.

Code: pkg/authn/authn.go

Each domain owns its policy vocabulary

Domain modelcomplete business state
Authorization modelresource attributes for Cedar
Principal + actionoperation intent
Cedar evaluatorpermit or deny
The shared evaluator knows Cedar. Ingredients, Menus, and Orders decide what their resources and actions mean.

Inspect the request and the entity separately

// Selected fields from AuthorizeWithEntity.
req := cedar.Request{
    Principal: principal,
    Action:    action,
    Resource:  resource.UID,
    Context:   cedar.NewRecord(nil),
}
entities := cedar.EntityMap{
    principal:    {/* UID; empty attributes, parents, tags */},
    resource.UID: resource,
}
decision, diagnostic := cedar.Authorize(ps, entities, req)
The request names the resource. The entity map supplies its attributes and tags. Cedar does not fetch missing domain state.

Code: pkg/authz/authorize.go

The schema defines what a policy can inspect

namespace Mixology {
    entity Actor enum [
        "owner", "manager", "sommelier", "bartender", "anonymous"
    ];
    entity Drink {
        Name: String,
        Category: String,
        Glass: String,
        Description: String
    } tags String;
}
namespace Mixology::Drink {
    action list, get, create, update, delete, tag, untag appliesTo {
        principal: Mixology::Actor,
        resource: Mixology::Drink,
        context: {}
    };
}
Drink has four declared attributes and string-valued Cedar tags. An attribute is not available to policy merely because it exists on the Go model.

Code: app/domains/drinks/authz/schema.cedarschema

CedarEntity is security-sensitive mapping code

func (d Drink) CedarEntity() cedar.Entity {
    return drinkauthz.Drink{
        UID: d.ID.EntityUID(), Name: d.Name,
        Category: string(d.Category),
        Glass: string(d.Glass), Description: d.Description,
        Tags: d.Tags.Map(),
    }.CedarEntity()
}
Hydrate authoritative state before this conversion. A valid schema proves shape, not that an attribute or tag came from a trusted source.

Code: app/domains/drinks/models/drink.go

Read the actual sommelier management policy

permit(
    principal == Mixology::Actor::"sommelier",
    action in [
        Mixology::Drink::Action::"create",
        Mixology::Drink::Action::"update",
        Mixology::Drink::Action::"delete",
        Mixology::Drink::Action::"tag",
        Mixology::Drink::Action::"untag"
    ],
    resource is Mixology::Drink
) when {
    resource.Category == "wine"
};
The grant is the intersection of principal, action, resource type, and category. Changing category changes the decision.

Code: app/domains/drinks/authz/policies.cedar

A separate permit grants narrower, tag-based access

permit(
    principal == Mixology::Actor::"sommelier",
    action in [
        Mixology::Drink::Action::"list",
        Mixology::Drink::Action::"get"
    ],
    resource is Mixology::Drink
) when {
    resource.hasTag("audience") &&
    resource.getTag("audience") == "sommelier"
};
This extends reading to an individual resource. It does not grant update, tag, or untag.

Code: app/domains/drinks/authz/policies.cedar

Evaluate a policy matrix, not a persona label

Sommelier resourceGetUpdateTag
Wine, no audience tagAllowAllowAllow
Cocktail, no audience tagDenyDenyDeny
Cocktail, audience=sommelierAllowDenyDeny
Being able to read a resource never implies permission to mutate it or change who can read it.

Code: pkg/authz/authorize.go

Permits compose; an applicable forbid overrides them

// Application-wide base.cedar:
permit(
    principal == Mixology::Actor::"owner", action, resource
);

// Audit domain, one of its explicit non-owner forbids:
forbid(
    principal == Mixology::Actor::"manager",
    action in [
        Mixology::AuditEntry::Action::"list",
        Mixology::AuditEntry::Action::"get"
    ],
    resource
);
Cedar denies without a matching permit. A matching forbid wins over a matching permit, regardless of document order.

Code: app/domains/audit/authz/policies.cedar

Mixology treats evaluator diagnostics as failure

decision, diagnostic := cedar.Authorize(ps, entities, req)
if len(diagnostic.Errors) > 0 {
    return errors.Internalf(
        "authz evaluation error: %s",
        diagnostic.Errors[0].Message,
    )
}
if decision == cedar.Deny {
    return errors.Permissionf(/* principal, action, resource */)
}
return nil
Cedar skips an erroneous policy when combining decisions. Mixology additionally rejects any diagnostic error, even if Cedar reports Allow.

Cedar reference

Generate and validate the authorization contract

Authoreddomain schema, policies, and Go model conversion
Generatedaction IDs, Cedar resource model, validator, tests, policy registry
At runtimevalidate resource shape, evaluate the assembled policies, classify the result
Changing policy is a behavior change. Changing the schema also changes the data contract that every authorization call must supply.

Code: pkg/authz/README.md

Authorization 1.6b

Authorize the state a command may produce

Trusted input and actual result are separate authorization boundaries. Both must permit the action before the transaction can commit.

1.6b

Authority must cover both sides of the change

Input authorization

May this principal perform this action on the resource we are about to change?

Reject before business mutation.

Result authorization

May this principal perform this action on the resource state the handler actually produced?

Reject before effects can commit.

Permission to start a mutation is not permission to produce every possible resulting state.

Code: pkg/middleware/run.go

The wine boundary constrains an update

Sommelier updateLoaded stateResult stateOutcome
Wine → wineAllowAllowMay commit
Cocktail → wineDenyNot reachedHandler blocked
Wine → beerAllowDenyRollback
An input-only check permits escape from the authorized category. A result-only check permits taking control of an unauthorized resource.

Code: pkg/middleware/command_tx_test.go

The pipeline checks every action at both gates

// Inside authorizeCommandActions; declarations omitted.
for _, action := range actions {
    err := authz.AuthorizeWithEntity(
        ctx.Principal(), action, in.CedarEntity())
    if err != nil { return zero, err }
}

out, err := next(ctx, in)
if err != nil { return zero, err }

for _, action := range actions {
    err := authz.AuthorizeWithEntity(
        ctx.Principal(), action, out.CedarEntity())
    if err != nil { return zero, err }
}
return out, nil
Result authorization uses the same required actions as input authorization. One denial or evaluation error aborts the operation.

Code: pkg/middleware/run.go

Drinks.Update checks three distinct resource states

LoadCommand
  load persisted drink in the transaction
  authorize update on PERSISTED state
  │
  └─ AuthorizeCommand around commands.Update
       authorize update on SUBMITTED proposal
       run commands.Update
       authorize update on ACTUAL result
  │
  authorize update on ACTUAL result
  dispatch events → record success → commit
The current composition makes four authorization calls over three state roles. The outer pipeline protects trusted state; the inner wrapper also constrains the proposal.

Code: app/domains/drinks/update.go

Read the complete update facade

func (m *Module) Update(
    ctx *middleware.Context, drink *models.Drink,
) (*models.Drink, error) {
    authorizedUpdate := middleware.AuthorizeCommand(
        authz.ActionUpdate, m.commands.Update)
    return m.pipeline.LoadCommand(ctx, authz.ActionUpdate,
        func(ctx *middleware.Context) (*models.Drink, error) {
            return m.queries.Get(ctx, drink.ID)
        },
        func(
            ctx *middleware.Context, _ *models.Drink,
        ) (*models.Drink, error) {
            return authorizedUpdate(ctx, drink)
        },
    )
}
The persisted object establishes authority over the target. The caller's object supplies the requested replacement, not proof of authority.

Code: app/domains/drinks/update.go

A forged category cannot authorize an existing target

Persisted target:  ID=drk-X, Category=cocktail
Submitted update: ID=drk-X, Category=wine
Principal:        sommelier
Action:           Drink::Action::"update"

If we authorize only the proposal:
  wine satisfies the sommelier policy

Current LoadCommand path:
  load drk-X → cocktail → Permission
  commands.Update never runs
An authorized-looking proposal does not grant authority over the record it names.

Code: app/domains/drinks/update.go

Isolate the result gate with a real pipeline test

// TestLoadCommand_AuthorizesResultAfterHandle, excerpt.
handled := false
_, err := pipeline.LoadCommand(
    fix.ActorContext("sommelier"), drinksauthz.ActionUpdate,
    func(*middleware.Context) (testEntity, error) {
        return wine, nil
    },
    func(_ *middleware.Context, out testEntity) (testEntity, error) {
        handled = true
        out.Attributes["Category"] = cedar.String("beer")
        return out, nil
    },
)
testutil.ErrorIsPermission(t, err)
testutil.IsTrue(t, handled)
The handler ran, but its successful Go return did not make the command authorized.

Code: pkg/middleware/command_tx_test.go

Result denial must undo work, not just hide output

// Probe handler inside LoadCommand, using the real store:
func(
    ctx *middleware.Context, out drinkauthz.Drink,
) (drinkauthz.Drink, error) {
    if err := store.Write(ctx, func(tx *store.Tx) error {
        return tx.Insert(&probe{Kind: "tentative-write"})
    }); err != nil {
        return out, err
    }
    ctx.AddEvent("tentative-event")
    out.Category = "beer"
    return out, nil
}
The write is tentative. Output denial must propagate to UnitOfWork; returning an error only after commit would be too late.

Code: pkg/middleware/uow.go

Follow result denial all the way out

Observed probe:
  handler ran                     true
  returned error kind             Permission
  persisted tentative-write rows  0
  dispatched tentative events     0
  successful activity callbacks   0
  failed activity callbacks       1

Result gate denies
  → DispatchEvents returns the error without dispatch
  → success activity is skipped
  → managed transaction rolls back
  → failure activity is attempted separately
Both authorization gates live inside the transaction. Denial of the produced state prevents that state from becoming durable.

Code: pkg/middleware/chains.go

Tag replacement can require several permissions

current: audience=sommelier, featured
desired: audience=bartender

replaceActions(current, desired):
  changed audience value → tag
  removed featured key   → untag

Before mutation: allow(tag, current) AND allow(untag, current)
After mutation:  allow(tag, result)  AND allow(untag, result)

No derived actions → Internal, handler does not run
No-op replacement → tag is still required
The submitted replacement is one intent, but its authority is the complete set of actions implied by the change.

Code: app/domains/tagging/module.go

Endpoint checks constrain transitions, not arbitrary pairs

Same request intent

The principal and action set stay fixed while the resource snapshot changes.

Both states must qualify

A policy requiring Category=wine restricts both the source and destination.

Business transition rules

Readiness and legal lifecycle changes still require command validation.

Cedar is called twice with one resource at a time. The current adapter does not supply an old/new pair or a phase flag.

Code: pkg/authz/authorize.go

The result gate has a precise enforcement boundary

Authorized resultthe resource returned by the command, before event dispatch
Consumer-owned effectstrusted handlers perform bounded reactions in the same transaction
Domain obligationreturn accurate policy state and validate every owned business effect
The pipeline does not independently authorize every row written by every event handler.

Code: pkg/middleware/dispatch_events.go

Authorization 1.6c

Expose only what the operation permits

Reads, counts, discovery, and action projections each disclose information. Their authority must be as explicit as a command's.

1.6c

One decision appears at four scales

Workspace

Can this actor discover and enter the domain?

Collection

Which rows and counts may become visible?

Entity

May this exact resource be read or selected?

Action

May this exact resource transition now?

Navigation, summaries, and lists disclose information; commands change it. Each needs authorization at its own application boundary.

Walk an individual grant through the application

// Existing tag ABAC test; fixture setup omitted.
grant := tag.Tag{Key: "audience", Value: "sommelier"}

_, err := f.App.Tags.Upsert(sommelier, cocktail.EntityUID(), grant)
testutil.ErrorIsPermission(t, err) // cannot self-grant

_, err = f.App.Tags.Upsert(manager, cocktail.EntityUID(), grant)
testutil.Ok(t, err)
_, err = f.Drinks.Get(sommelier, cocktail.ID)
testutil.Ok(t, err)

_, err = f.App.Tags.Remove(manager, cocktail.EntityUID(), "audience")
testutil.Ok(t, err)
_, err = f.Drinks.Get(sommelier, cocktail.ID)
testutil.ErrorIsPermission(t, err)
An authorized manager can grant and revoke read access by changing policy-relevant data. No policy reload is needed for that resource change.

Code: app/tag_abac_test.go

Fill the visible page, not the storage page

stable candidatesfilter + hydrate
authorize eachdeny disappears
continue scanninguntil N visible
look aheadsafe cursor

Permission denial

Expected list behavior. Omit the entity and keep scanning.

Evaluation or storage failure

Not a denial. Fail the query instead of returning a believable partial result.

Permission denial is a branch in the paging loop

err = authz.AuthorizeWithEntity(
    c.Principal(), action, item.CedarEntity(),
)
switch {
case err == nil:
    if len(page.Items) == pageRequest.Limit {
        page.Next = cursor(page.Items[len(page.Items)-1])
        return nil
    }
    page.Items = append(page.Items, item)
case errors.IsPermission(err):
    continue
default:
    return err
}
The extra authorized item proves there is a next page. A denied item neither consumes a page slot nor becomes the returned cursor.

Code: pkg/middleware/run.go

Counts reuse the authorized list, not raw SQL

func (m *Module) Count(
    ctx *middleware.Context, req ListRequest,
) (int, error) {
    return paging.Count(func(
        cursor paging.Cursor,
    ) (paging.Page[*models.Drink], error) {
        req.Cursor = cursor
        req.Limit = paging.DefaultLimit
        return m.List(ctx, req)
    })
}
The displayed total describes the actor's visible collection, with the same filters and policy decisions as the list.

Code: app/domains/drinks/list.go

Discovery has an explicit disclosure contract

OperationAuthorityDisclosure
Drinks.Get / ListDrink action on each resourceallowed domain models
Tags.List(target)target domain's Get actionthat target's tags
Tags.Show / SummaryTagDiscovery action, owner-only todaymatching references or aggregates
Tag discovery deliberately does not replay every target's Get permission. Its own grant authorizes that broader disclosure.

Code: app/domains/tagging/module.go

A real policy separates reading from management

permit(
    principal,
    action in [
        Mixology::Menu::Action::"list",
        Mixology::Menu::Action::"get"
    ],
    resource
);

// The manager permit includes these actions, among others:
principal == Mixology::Actor::"manager"
action in [Mixology::Menu::Action::"publish",
           Mixology::Menu::Action::"readiness"]
resource is Mixology::Menu
Permission answers whether the actor may publish. The command's readiness check answers whether this menu can be published now.

Code: app/domains/menus/authz/policies.cedar

Projection guides. Commands enforce.

Presentation projection

Combines permission with durable prerequisites so a view can hide denied actions and explain unavailable ones.

Authoritative command

Reloads current state, repeats authorization, and checks invariants inside the write transaction.

A stale screen may offer an action that just became invalid. That is a normal race, not authority granted by the UI.

A Publish control carries its own permission

// Selected declaration from Menus' action projector.
Permission: permission(menusauthz.ActionUpdate, resource),
Controls: []actions.Control{
    {
        ID:         ControlPublish,
        Permission: permission(menusauthz.ActionPublish, resource),
        Conditions: []actions.Condition{publishCondition(selected)},
    },
}

// Control permission replaces the inherited default.
// Permission runs first; conditions run only when authorized.
Being allowed to edit does not imply being allowed to publish. A control with a distinct action must project that action's permission.

Code: app/domains/menus/actions.go

Test the authorization contract at every boundary

EvidenceFailure it catches
Policy matrix + schema testswrong grants, malformed resource data
Loaded-state denialunauthorized target reaches the handler
Handler runs, result deniedinput-only authorization
Stored rows, events, activitydenial occurs after effects escape
Get, List, Count, discovery, controlsinconsistent disclosure or advertised authority
Test permits and denials through the public facade, and isolate both pipeline gates so an earlier denial cannot hide a missing result check.

Code: pkg/middleware/command_tx_test.go

Foundation 1.7

Observe the operation, not random functions

Logs diagnose one execution. Metrics describe the population. Neither replaces durable business activity.

1.7

Three lenses answer three questions

Structured logs

What happened during this execution, with actor, action, resource, duration, and diagnostic error?

Bounded metrics

How often, how slowly, and how unsuccessfully do operation classes behave?

Audit activity

Who attempted which business action, against what, and what else changed?

One middleware boundary provides consistent meaning without scattering instrumentation through domain code.

Context accumulates useful log meaning

Entrypointlogger + actor
PipelineCedar action
Commandprimary resource
Unwindduration + final error

Deliberate levels

Permission denial is informational; query failures warn; command failures error.

Fresh scope

Enriched attributes live only for one operation and cannot bleed into the next session call.

Keep metric label values bounded

Current instruments

Command/query totals use action + result. Error counters and durations use action. Store read/write durations have no labels.

Small, stable cardinality.

Never labels

Entity IDs, user filter text, error messages, tag values, or arbitrary resource names.

Unbounded and operationally expensive.

Authorization and event metric names are reserved but not currently emitted. The deck distinguishes available vocabulary from actual instrumentation.

Libraries expose a contract; executables own lifecycle

Domain + storerecord through a tiny Metrics interface
pkg/telemetryno-op, memory, OTEL, and Prometheus-backed implementations
main/<surface>address, HTTP server, startup, and shutdown
Runtime constraintconcurrent local surfaces need distinct metrics ports

One denied command, two observability decisions

// Logging: expected denial is informational.
case errors.IsPermission(err):
    logger.Info(string(op.Kind)+" denied",
        slog.Duration("duration", duration), log.Err(err))

// Metrics: every non-nil result is counted as an error.
if err != nil {
    mc.commandTotal.Inc(actionLabel, "error")
    mc.commandErrors.Inc(actionLabel)
}
A denial is informational in logs but still increments command error metrics. Interpret the counters according to this implementation.

Code: pkg/middleware/logging.go

Measure the operation's final outcome, including unwind

command body returns nil
  → result authorization permits
  → event reaction succeeds
  → successful activity recorder fails
  → transaction rolls back
  → Metrics records error, not success
  → Logging emits "command failed"

Duration includes everything inside those wrappers.
Caller transaction lock wait happens outside both wrappers.
Where instrumentation sits defines what its numbers mean. Handler success is not operation success.

Code: pkg/middleware/chains.go

Test metric meaning without a running exporter

memory := telemetry.Memory()
// Supply memory as PipelineConfig.Metrics.

// After a denied or otherwise failed Drink update:
memory.CounterValue(
    telemetry.MetricCommandTotal, "Drink.update", "error",
) // 1
memory.CounterValue(
    telemetry.MetricCommandErrors, "Drink.update",
) // 1

// The equivalent successful call uses result="success".
The application owns names and label meaning. Backend choice must not change the operation's observable outcome.

Code: pkg/middleware/metrics.go

Foundation 1.8

Treat auditing as a domain

An audit trail is durable business evidence with transaction semantics, policy, filtering, and its own read model.

1.8

An activity is more than a log line

Who + what

Principal, Cedar action, and primary resource identify the attempted operation.

When + outcome

Start, completion, success, and diagnostic error preserve what happened.

Affected entities

Changed-resource IDs, referenced participants, and domain-authored effects distinguish what changed from what was inspected.

Audit is append-only evidence. It is not diagnostic logging and it is not a replayable domain event stream.

Success and failure take different transaction paths

Successful command

mutation + handlers + success audit

If recording fails, the business operation rolls back.

Failed managed command

rollback first

Then persist the failed attempt in a separate managed write.

recordSuccessfulActivity runs inside the unit of work. TrackActivity records a managed failure only after rollback. Caller-owned transactions keep failure activity under the caller's decision.

Separate changes, references, and explanations

Activity fieldMeaningExample
TouchesAttributed changed resourcesrewritten Drink
ParticipantsReferenced resourcesinspected, unchanged Menu
EffectsDomain-authored before/after explanationrecipe or stock disposition change
WorkflowIDCorrelation across commandsdomain edit + tag replacement
A reference is not a mutation. An effect explains selected facts; it is not an automatic database diff.

Failure to record has an explicit policy

SituationAudit behaviorReturned result
success recorder or commit failsrollback, attempt failure recordoperation failure
managed command failsrecord after rollbackoriginal error
failure recording also failsdurability cannot be promisederrors.Join preserves both
caller supplies transactionrecord inside caller transactioncaller owns failure policy

The read side is still an application boundary

Audit modulelist, count, entity history, and actor activity
Query contractaction, principal, entity, time window, typed expression, cursor
PipelineCedar authorization and permission-safe paging
SurfacesCLI, TUI, and GUI adapt the same append-only evidence
The system that records activity automatically does not grant everyone permission to inspect it.

The activity stores explanations, not a replay log

// Selected fields; actor, action, resource and times omitted.
type Activity struct {
    WorkflowID   string
    Touches      []cedar.EntityUID
    Participants []cedar.EntityUID
    Effects      []Effect
    Success      bool
    Error        string
}
type Effect struct {
    Kind     string
    Resource cedar.EntityUID
    Changes  []Change // Field, Before, After are strings.
}
On a failed activity, effects describe attempts that rolled back, not committed state.

Code: pkg/middleware/events/activity.go

A composed failure has one outer owner

// RunWorkflow control flow, success/failure construction omitted.
state := &workflowState{id: ksuid.New().String()}
derived := *ctx
derived.workflow = state
err := s.Write(ctx, func(tx *store.Tx) error {
    return run(derived.WithTransaction(tx))
})
// On failure, aggregate child touches, participants and effects.
// Complete a failed Mixology::Workflow::Action activity.
// Record it in a new write after the business rollback.
return errors.Join(err, auditErr)
Successful child activities share WorkflowID and commit with business writes. Outer failure replaces them with one correlated failed attempt.

Code: pkg/middleware/workflow.go

Foundation 2.1

Coordinate domains with bounded event fan-out

Ingredient retirement changes four domains without giving Ingredients four collaborators.

2.1

Why Ingredients does not call every consumer

func (m *Ingredients) Retire(ctx Context, id ID) error {
    m.inventory.Remove(ctx, id)
    m.drinks.ReplaceOrReview(ctx, id)
    m.orders.BlockSnapshots(ctx, id)
    m.menus.Recalculate(ctx, id)
    return m.ingredients.Retire(ctx, id)
}
×Ingredients decides what retirement means everywhere.
×The source domain imports every consumer.
×Adding a reaction edits the initiating command.
×The “simple” path becomes the system map.

Separate questions from decisions

Public query

“Is this ingredient referenced?”

Safe when the caller owns the decision that follows.

vs

Public event

“This ingredient was retired with this explicit replacement intent.”

Each consumer owns its reaction.

Queries move information. Events move facts. Neither exposes another domain’s command implementation.

One fact, bounded fan-out

IngredientsIngredientDeleted
Drinksrewrite future recipes or require review
Inventoryretain stock; discontinue or quarantine
Ordersblock withdrawn stock; preserve acceptance
Menusrecompute availability, preserve curation
command mutation + four leaf reactions + touched entities + successful audit = one SQLite transaction

The event dispatcher is generated glue

AddEventowned fact
command returnsstill in UoW
Handlingall snapshots
Handleall reactions
audit + commitatomic result
func (h *IngredientDeleted) Handling(
    ctx *middleware.HandlerContext,
    event ingredientsevents.IngredientDeleted,
) error // capture state before any reaction

func (h *IngredientDeleted) Handle(
    ctx *middleware.HandlerContext,
    event ingredientsevents.IngredientDeleted,
) error // apply the owned reaction
HandlerContext has transaction, principal, and TouchEntity. It deliberately has no AddEvent.

Capture dependencies before any handler changes them

fresh handlersone event-local receiver each
optional Handlingall snapshots finish
every Handleapply owned reactions
commitone outcome
The command has already mutated state. For each event, all Handling calls finish before any Handle call begins.

Why Menus needs a preparation step

Before reactionsA recipe still references the retired ingredient. Menus finds the affected drinks and menus.
Drinks reactsAn explicit replacement may rewrite that recipe, removing the old reference.
Menus reactsIt persists availability fully calculated during preparation, without reading a sibling's writes.
Querying only after recipe rewrite could return no matches for the retired ingredient and leave affected menus untouched.

No cascades is defended twice

Capability boundary

HandlerContext omits AddEvent, so ordinary handlers cannot enqueue another fact.

+

Runtime boundary

DispatchEvents clones the original event slice before delivery, so accidental later additions are not dispatched.

Every event has a bounded, reviewable leaf fan-out. Multi-step time-spanning work deserves an explicit workflow.

Package rules preserve the dependency direction

×commands-emit-own-domain-events
×handlers-no-commands
×handlers-no-modules
×queries-no-commands
The event changes the dependency direction. The analyzer keeps it changed.

Generated dispatch constructs event-local receivers

IngredientDeleted case (error branches omitted here):

construct drinksHandler, inventoryHandler,
          menusHandler, ordersHandler

drinksHandler.Handling(hctx, e)
inventoryHandler.Handling(hctx, e)
menusHandler.Handling(hctx, e)
────────────────────────────────── preparation barrier
drinksHandler.Handle(hctx, e)
inventoryHandler.Handle(hctx, e)
menusHandler.Handle(hctx, e)
ordersHandler.Handle(hctx, e)
The same receiver holds preparation data and later applies its reaction. Generated order is visible, but must not become an undocumented dependency.

Code: pkg/dispatcher/dispatcher_gen.go

Prepare the resulting state, not just a list of IDs

OrderPlaced.Handling:
  capture stock; project the added reservations
  calculate resulting availability for active menus
  retain only changed Menu values

IngredientDeleted.Handling:
  override retired stock as unavailable for future service
  project recipes with Drink.RetireIngredient (pure rule)
  calculate resulting Menu values

Handle:
  persist prepared values; do not re-read sibling state
All preparations precede all reactions. Independence comes from what is prepared, not from the barrier alone.

Code: app/domains/menus/handlers/prepared.go

Dispatch snapshots the event list and wraps failures

if err := next(ctx); err != nil {
    return err
}
if d == nil {
    return nil
}
events := slices.Clone(ctx.Events())
for _, event := range events {
    if err := d.Dispatch(ctx, event); err != nil {
        return errors.Internalf("dispatch event %T: %w", event, err)
    }
}
return nil
A command failure prevents delivery. A delivery failure aborts the transaction. New events appended during delivery are outside this snapshot.

Code: pkg/middleware/dispatch_events.go

The preparation barrier is per event, not per command

command queues E1, then E2
  E1: construct fresh receivers
      all Handling calls
      all Handle calls
  E2: construct fresh receivers
      all Handling calls
      all Handle calls
  record successful activity
  commit

A failure stops remaining delivery and rolls back the operation.
Preparing E2 can observe effects of E1. Preparing handlers for E1 cannot assume another E1 handler has already reacted.

Code: pkg/middleware/dispatch_events.go

Unhandled events and durable delivery are different contracts

Known event, no matching reaction → debug log; success
Unknown event type               → debug log; success
Handler error                    → remaining delivery stops

Current delivery:
  synchronous Go calls
  one local transaction
  no durable event log, retry queue, or replay cursor
An event may be a valid extension point without subscribers. A successful dispatch does not prove that an intended handler was generated.

Code: pkg/dispatcher/dispatcher_test.go

Foundation 2.2

Give cross-cutting tags an owner

Shared vocabulary does not require ownerless persistence, global meaning, or private-domain reach-through.

2.2

Tagging is a bounded context

Kernel value

tag.Tag owns canonical key/value parsing, validation, ordering, and formatting.

Tagging domain

Owns polymorphic associations, authorized mutations, discovery, summary, and target registration.

A tag may influence filtering, presentation, or Cedar ABAC. Its business meaning remains with the policy and domain that interpret it.

The registry reverses the dependency

Taggingtarget registry
Operational domain providesload complete Cedar state
Operational domain providesbulk active-target check
Operational domain providesget, tag, and untag action IDs
Tagging providesone narrow association repository port
Tagging never imports Ingredients, Drinks, Inventory, Menus, or Orders models and private DAOs.

Hydration stays with the entity owner

Domain DAOload owned rows
tag repositorybatch associations
Domain modelcomplete tags
Cedar + filterevaluate full state
The association store is shared infrastructure. The complete Ingredient or Drink is still assembled by its owner before authorization and exact filtering.

Replace is one intent with dynamic authority

DifferenceRequired actionRecorded activity
add or change valuestagone stable tag operation
remove keysuntag
mixed replacementtag + untag
LoadCommandActions derives the complete Cedar action set from current tags and the desired complete set.

Compose domain change and tag change atomically

non-nil tag intent: RunTaggedMutation owns or joins one shared transaction
validate tagsbefore write
domain commandnormal pipeline
+
Tags.Replacenormal pipeline
commitboth or neither

nil desired set

Preserve existing tags and run only the domain mutation.

Non-nil empty set

Explicitly clear every tag as part of the same application operation.

The domain command and Tags.Replace remain two normal pipeline commands with correlated audit activities, committed atomically together. Managed failure records one failed workflow after rollback.

Discovery is its own authorized workflow

Show

Find active entity references for an exact tag or every value of a key.

Summary

Aggregate canonical tags across active registered entity types.

Policy

Tagging-owned Cedar actions govern discovery; referenced entity authorization is not silently replayed.

Inactive targets are excluded from discovery through each owner's registered bulk check. Stale association rows are not silently deleted.

The target registry contains capabilities, not domain models

type Target struct {
    Type        cedar.EntityType
    GetAction   cedar.EntityUID
    TagAction   cedar.EntityUID
    UntagAction cedar.EntityUID
    Load        LoadTarget
    Active      ActiveTargets
}
type TargetState struct {
    Entity      cedar.Entity
    DisplayName string
    Tags        tag.Tags
}
Ingredients supplies Load and Active. Tagging can authorize and discover targets without importing Ingredient or its DAO.

Code: app/domains/tagging/registry.go

Replace derives authority from the tag delta

Current:  featured, region=west
Desired:  region=east, seasonal

region changes west → east  : requires tag
seasonal is added           : requires tag
featured is removed         : requires untag
──────────────────────────────────────────
authorize {tag, untag} against loaded state
replace associations
load complete resulting state
authorize {tag, untag} against result
record one tag activity
The stable audit action is tag; the authorization action set can include both tag and untag.

Code: app/domains/tagging/module.go

Two pipeline commands share one transaction

compose := func(txCtx *middleware.Context) error {
    var err error
    result, err = mutate(txCtx)
    if err != nil { return err }

    replaced, err := application.Tags.Replace(
        txCtx, result.EntityUID(), *desired, expected...,
    )
    if err != nil { return err }
    result.SetTags(replaced.Tags)
    return nil
}
err := middleware.RunWorkflow(ctx, application.Store,
    "tagged_mutation", audit.NewWriter(application.Store).RecordActivity,
    compose)
The domain command and tag command each authorize and audit. Returning an error from either prevents the outer transaction from committing.

Code: app/tagged_mutation.go

Protect the editor's intent, not only the latest row

Editor loads: entity revision=7; tags={region=west}
Another actor adds: seasonal
Editor submits: revision=7; tags={region=east}

The entity revision alone cannot detect the tag change.

RunTaggedMutation(..., expectedTags)
  domain command checks its captured revision
  Tags.Replace compares the expected complete tag set
  mismatch → Conflict → both changes roll back
SQLite serializes writes. It does not know that a stale form would erase someone else's intent.

Code: app/tagged_mutation.go

Association identity belongs in a database invariant

target = (EntityType, EntityID)
association = (EntityType, EntityID, Key)

audience=sommelier → audience=bartender
  same association; replace Value

Repeat identical Upsert / Replace
  successful no-op; Changed=false

Repeat the same business command
  not automatically an idempotent request
The tag service's idempotent set semantics do not create system-wide command deduplication.

Code: app/domains/tagging/repository.go

Foundation 2.3a

Give people and programs one filter language

Own exact expression semantics above storage, authorization, and every presentation surface.

2.3a

The schema is a public domain contract

typed filter viewstable field names
Expr parser + checkeraccepted syntax
owned treestable semantics
surface helpfields + examples
The filter view need not mirror a SQLite row or returned model. It can expose nested and hydrated values without leaking persistence.

Borrow a compiler, keep ownership

Source

The trimmed expression a person supplied.

String

Canonical syntax for display and reparsing.

Tree

Mixology's stable node model for integrations and SQL planning.

Expr optimization is deliberately disabled. Expr parses, checks, and executes; Mixology owns the restricted language and pushdown plan.

One expression, two execution stages

checked expressionexact contract
safe SQL pushdowncandidate reduction
hydratetags + derived values
Matchauthoritative result
ApplySQLPushdowns returns candidates, never proof. Every operational DAO evaluates the complete hydrated view afterward.

Filtering and authorization compose in order

parse oncetyped invalid on error
filter + hydratedomain semantics
authorize eachomit denied rows
pagefill visible count
Audit can use direct ApplySQL because its filter view comes from one row. Operational domains use staged hydration.

Adjacent article: Typed Filtering over SQLite

The schema makes the query contract inspectable

type ListFilterView struct {
    Name string `expr:"name" filter:"Drink name" filter-column:"Name"`
    Tags []string `expr:"tags" filter:"Tags (key or key=value)"`
    Recipe RecipeFilterView `expr:"recipe"`
}
type RecipeFilterView struct {
    Garnish string `expr:"garnish" filter:"Recipe garnish"`
}

expr, err := filter.Parse(models.ListFilterSchema(),
    `name == "Daiquiri" && tags contains "featured"`)
Name has a persisted column mapping. Tags and recipe.garnish are part of the public language without claiming that same storage representation.

Code: app/domains/drinks/models/filter.go

The DAO hydrates before the final predicate

// Inside the row loop, after tags were loaded in one batch:
drink, err := toModel(row)
if err != nil { return err }
drink.Tags = tagsByTarget[drink.EntityUID()]
matched, err := filter.Expression.Match(
    listFilterView(row, drink.Tags.Strings()),
)
if err != nil { return err }
if !matched { continue }
if !yield(&drink, nil) { return nil }
SQL only reduces candidates. Match evaluates the complete value; PageQuery then decides which matched entities the actor may see.

Code: app/domains/drinks/internal/dao/list.go

Deep dive 2.3b

Prove filter optimization preserves meaning

A checked expression is a language contract. SQL pushdowns are conservative candidate selectors, not a second definition of matching.

2.3b

A pushdown must be necessary, not merely useful

E = the complete expression on a hydrated domain view
P = the predicates applied to persisted rows

Required safety property:
  E(record) = true  ⇒  P(row) = true

Candidate rows may include false positives.
A false negative cannot be recovered by later hydration.

Final result = candidates that satisfy E
The optimizer may do less work. It may not change which records the expression means.

Code: pkg/filter/sql.go

Walk the OR counterexample with two rows

Expression:
  category == "spirit" || name == "Beer"

Rows:
  Gin   / spirit → true from the left branch
  Beer  / mixer  → true from the right branch

Unsafe SQL: WHERE Category = 'spirit'
  drops Beer, which satisfies the full expression

Current behavior: retain both candidates; evaluate the OR
Neither branch alone is a requirement of the whole OR.

Code: pkg/filter/sql_test.go

Widen alternatives only when both constrain the same field

(category == "spirit" && tags contains "featured")
||
(category == "mixer" && tags contains "seasonal")

Safe candidate constraint:
  Category IN ("spirit", "mixer")

Still requires exact matching:
  a spirit tagged only seasonal must be rejected
  a mixer tagged only featured must be rejected
The widened candidate set deliberately forgets which tag belongs to which branch. The residual expression restores that relationship.

Code: pkg/filter/sql.go

Negation changes the connective before extracting constraints

if node.Kind == KindUnary && node.Operator == "!" {
    return e.impliedPushdowns(node.Children[0], !negated)
}
// For a binary boolean node under negation:
// && becomes ||; || becomes &&
// comparison leaves use negateComparison

!(category == "spirit" || category == "mixer")
  ⇒ Category != "spirit" AND Category != "mixer"

!(category == "spirit" && tags contains "featured")
  ⇒ no category-only constraint is necessary
Negating a conjunction does not let us push down the negation of whichever field happens to be stored.

Code: pkg/filter/sql.go

Own the supported language even when borrowing its compiler

program, err := expr.Compile(source,
    expr.Env(zero),
    expr.AsBool(),
    expr.Optimize(false),
    expr.Patch(dotPredicatePatcher{}),
    expr.Patch(collectionContainsPatcher{fields: collectionFields(schema)}),
)
// Compile errors become Invalid.
tree, err := buildTree(program.Node())
// Unsupported constructs also become Invalid.

// Source: user's trimmed text
// String: canonical syntax
// Tree:   application-owned nodes, not Expr AST
Successful library compilation is only the first gate. The owned tree restricts the language the application promises to support.

Code: pkg/filter/filter.go

Test candidate safety and final truth separately

TestApplySQLPushdownsDeferHydratedFields:
  three stored rows → two spirit candidates
  attach tags → Match the complete view

TestApplySQLDoesNotPushUnsafeOR:
  Gin/spirit and Beer/mixer must both survive

TestApplySQLPushdownsExtractNecessaryORConstraints:
  alternative categories produce a safe widened set

TestApplySQLPushdownBooleanSemantics:
  negation and boolean columns keep their meaning
An optimization test should prove both that no true match is lost and that residual false positives are rejected.

Code: pkg/filter/sql_test.go

Foundation 2.4a

Keep storage mechanics behind domain contracts

Domain DAOs own persistence mapping. The shared SQLite store supplies transactions, concurrency checks, and change signals for all three interfaces.

2.4a

SQLite stays below domain persistence

Domain DAOowned queries, row conversion, hydration
Typed store APIRegister, Get, Insert, Update, Query
Unit of workshared transaction carried by operation context
modernc SQLiteWAL, constraints, revisions, migration ledger, data version

Several processes can share one local truth

CLIshort write
SQLite WALone writer, many readers
TUIpersistent reader
GUIpersistent reader

Process coordination

Busy timeout and immediate transactions make writer contention explicit.

Application coordination

Keep commands short; never hold a transaction while waiting for user input.

Revisions turn stale writes into typed conflicts

read rev 7
other client writes rev 8
update WHERE rev = 7
Conflict
Public mutable models carry an opaque revision. The store performs the atomic comparison and increment.

A change notification means “query again”

data_versiondetect another connection's commit
Signalswake the client
+
Epochremember a change occurred
ordinary queryreload authorized state
Several changes can share one notification. The increasing epoch lets a client notice missed changes and re-query through normal authorization and filtering.

Treat the file format honestly

Migration ledger

Ordered migrations advance deliberately; a database from a newer schema is rejected.

Registration

Explicit model schemas fail early; imports do not mutate global persistence state.

Errors

Constraints and stale revisions become application kinds, not leaked driver strings.

Code guide: pkg/store/README.md

The SQLite storage shape is more specific than “tables”

-- Selected SQL actually issued by the store:
INSERT INTO records(model, id, data, revision)
VALUES (?, ?, ?, 1);

SELECT data, revision
FROM records
WHERE model = ? AND id = ?;

UPDATE records
SET data = ?, revision = revision + 1
WHERE model = ? AND id = ? AND revision = ?;
The generic store keeps typed row data as JSON in records, partitioned by model identity. Domain DAOs still own conversion and query meaning.

Code: pkg/store/query.go

A stale revision becomes a typed conflict

n, _ := r.RowsAffected()
if n == 0 {
    var current uint64
    err := t.tx.QueryRowContext(t.ctx,
        "SELECT revision FROM records WHERE model=? AND id=?",
        modelName(typ), idString(id),
    ).Scan(&current)
    if errors.Is(err, sql.ErrNoRows) {
        return errors.NotFoundf("record absent")
    }
    if err != nil { return err }
    return errors.Conflictf(
        "record changed: expected revision %d, current revision %d",
        revision, current)
}
Zero updated rows can mean absence or a stale edit. The store distinguishes those meanings before the DAO maps them to its own operation context.

Code: pkg/store/query.go

Three counters protect three different races

Database change epoch:
  commit observed → epoch increases → cached screen becomes stale

UI request generation:
  load A starts → load B starts → A finishes late → A is ignored

Entity revision:
  read revision 7 → another writer saves revision 8
  submit revision 7 → conditional UPDATE changes 0 rows → Conflict
An epoch is a refresh hint, a generation selects a result, and a revision guards a write. None substitutes for the others.

Code: pkg/store/changes.go

Deep dive 2.4b

Coordinate persistence across processes

Write acquisition, schema evolution, row identity, and invalidation each have a distinct consistency contract.

2.4b

Acquire write intent before reading mutation state

Store.Begin(ctx, writable=true):
  pin a connection
  BEGIN IMMEDIATE
  return a Tx using that connection

Command:
  load current state
  authorize → mutate → authorize result
  dispatch → audit → COMMIT

Competing writer waits or returns an error at acquisition.
The loaded authorization state and the mutation share one write transaction. WAL does not make SQLite a multi-writer database.

Code: pkg/store/store.go

Persistent identity includes the Go row type

func modelName(t reflect.Type) string {
    return t.PkgPath() + "." + t.Name()
}

records primary key:
  (model, id)

Example model discriminator:
  .../app/domains/drinks/internal/dao.DrinkRow

Domain values ↔ row conversion ↔ JSON-backed record
A package or row-type rename can be a data migration, not just a refactor.

Code: pkg/store/store.go

Migration startup is itself a coordinated write

BEGIN IMMEDIATE
  CREATE TABLE IF NOT EXISTS schema_migrations
  read highest version and applied count
  reject a version newer than this binary understands
  reject a non-contiguous ledger
  apply each outstanding statement
  record each version in the same transaction
COMMIT

On error: rollback using a non-cancelled cleanup context.
Two starting processes must agree on the same schema transition. Recording a version separately from its schema change would break that guarantee.

Code: pkg/store/store.go

The monitor pins the connection whose counter it compares

conn, version := openChangeConnection(...)
repeat:
  current := dataVersion(conn)
  if current != version:
    version = current
    publish invalidation

If the connection fails:
  reconnect with backoff
  establish a new baseline
  publish anyway: changes may have occurred in the gap
A data_version value is meaningful across observations on the same connection. It is not a global commit sequence.

Code: pkg/store/changes.go

Coalescing drops edges but retains an invalidation level

func (m *ChangeMonitor) publish() {
    m.epoch.Add(1)
    select {
    case m.signals <- struct{}{}:
    default:
    }
}

// signals has capacity one.
// Epoch counts monitor publications, not database commits.
// A signal carries no entity ID or business payload.
A full notification channel must not block the writer-observation loop. Consumers reload state rather than reconstructing changes.

Code: pkg/store/changes.go

Prove commit visibility using two store instances

reader := Open(path)
writer := Open(path)
monitor := reader.MonitorChanges(...)

writer.Write(insert "committed")
  → monitor signals
  → Epoch > 0

tx := writer.Begin(writable=true)
tx.Insert("rolled back")
writer.Rollback(tx)
  → no invalidation for that rollback
Use separate connections to test freshness. Reading your own uncommitted write proves a different property.

Code: pkg/store/changes_test.go

Domain workshop 3.1a

Follow an order as reality changes

Reservations, stock corrections, and ingredient retirement connect the architecture to business behavior.

3.1a

Placing an order creates a commitment

Orders decidesValidate the published menu and requested drinks, choose ingredient usage, and save the accepted snapshot.
Inventory reactsReserve every included quantity, including optionals, when it receives OrderPlaced.
Menus reactsRecalculate published availability as stock becomes committed.
Pipeline finishesRecord touched entities and the successful activity, then commit all writes together.
If any reservation fails, the order and earlier reservation writes roll back together.

Reserved stock is different from consumed stock

Example, in one unitOn handReservedAvailable
Before an order10010
Place an order requiring 41046
Complete that order606
Or cancel it instead10010
Placement protects a future commitment. Completion consumes stock. Cancellation releases the commitment.

Stock corrections flow back to Orders

pending
stock below reservations
blocked

Replenish

Inventory emits another adjustment. An order returns to pending when none of its committed ingredients remain blocked.

Cancel

A blocked order can be cancelled to release reservations and reconcile peers. It cannot be completed.

Orders → Inventory and Inventory → Orders are separate business operations with leaf reactions, not an event cascade.

A substitute must satisfy the whole recipe

Local choice

Requirement A can use X or Y. Requirement B can use only X. There is enough X for one requirement.

Choosing X for A first leaves B unfulfilled.

Complete plan

Try Y for A and reserve X for B. Compare quantities in compatible units and account for conversion ratios.

Accept a plan only when every requirement fits.

The planner backtracks when a preferred substitute prevents a complete solution. The accepted order keeps the resulting usage snapshot.

Retirement is a business decision

ReferenceNo replacementPermanent replacement
Required recipe componentkeep visible, review requiredrewrite compatible future recipe
Optional componentremove from future reciperewrite when compatible
Pending order using retired ingredientdiscontinue: honor reservations; withdraw: block; preserve acceptance
Published menu itempreserve curation and recalculate current availability

Three substitutions, three meanings

Recipe substitute

A modeled alternative inside the drink. It is not permission to rewrite the canonical recipe.

Operational substitution

A temporary way to fulfill a drink. It affects readiness and availability.

Permanent replacement

Explicit retirement intent carried by the source event, including conversion.

IngredientDeleted{Replacement, ReplacementRatio} carries explicit permanent intent.

Similarity is not intent. Consumers must not infer permanent replacement from whatever substitute happens to be available.

Rewrite plans. Preserve records.

Future recipe

An approved replacement changes what future orders will use. A recipe without a valid replacement may require review.

Recipe edits express future intent.

Accepted order

The usage snapshot keeps the ingredients selected when the order was placed.

An outstanding order can be blocked; its snapshot stays intact.

Staying published and becoming published differ

published
degraded but honest
draft
known-bad publish

Existing published menu

May remain published while item availability reflects new operational truth.

Draft promotion

Readiness blockers prevent publishing state already known to be unsuitable.

Readiness belongs to Menus

Load menuauthorized state
Evaluaterecipes + stock
Reportblockers + warnings
Publishre-check in command

Blockers

Invalid canonical state, unavailable items, or temporary substitution.

Warnings

Operational concerns such as low stock that deserve visibility but not a false invariant.

Readiness becomes visible in the desktop

Classic Cocktails menu scrolled to Readiness: ready and six available drinks: Margarita, Daiquiri, Gin & Tonic, Old Fashioned, Negroni, and Mojito.
The published menu reports readiness and availability beside its curated drinks.

Headless capture · owner persona · go-modular-monolith 635c59b · select image for full size

Place saves the concrete fulfillment plan

usage, err := c.fulfillmentSnapshot(ctx, created)
if err != nil { return nil, err }
created.IngredientUsage = usage

if err := created.Validate(); err != nil {
    return nil, err
}
if err := c.dao.Insert(ctx, &created); err != nil {
    return nil, err
}
ctx.RecordEffect("order_placed", created.ID.EntityUID(),
    middleware.Change("acceptance", "", created.Acceptance))
ctx.AddEvent(events.OrderPlaced{Order: created})
return &created, nil
The event carries the accepted usage snapshot. Inventory reserves those exact quantities; it does not plan the recipe again.

Code: app/domains/orders/internal/commands/place.go

Readiness is both a report and a command precondition

if err := menu.RequirePublishable(); err != nil {
    return nil, err
}
report, err := c.availability.Readiness(ctx, menu)
if err != nil { return nil, err }
if err := report.RequireReady(); err != nil {
    return nil, err
}

// Only after those checks:
updated := *menu
updated.Status = models.MenuStatusPublished
updated.PublishedAt = optional.Some(now)
Report findings have severity, code, entity IDs, and a message. RequireReady turns blocker messages into a typed FailedPrecondition.

Code: app/domains/menus/internal/commands/publish.go

Domain deep dive 3.1b

Allocate shared stock without breaking commitments

Fulfillment combines deterministic search, unit conversion, accepted snapshots, and transactional reservations.

3.1b

Plan the whole order, not each drink independently

for each order item:
  load its recipe
  retain optional ingredients as optional requirements
  multiply each amount by item quantity
  append to one requirements slice

menus.FulfillIngredients(ctx, requirements)
  → PlanIngredients across the complete slice

Aggregate picks by ingredient ID
  → sort the resulting IngredientUsage snapshot
Two independently feasible recipes can compete for the same stock. The planning scope must match the accepted commitment.

Code: app/domains/orders/internal/commands/complete.go

Candidate preference is deterministic, not arbitrary

Build candidates:
  original ingredient
  explicit recipe substitutes, with catalog ratios when present
  remaining catalog rules, deduplicated by ingredient ID

Keep candidates with enough individually available stock.

Sort:
  original before substitute
  higher quality rank
  greater available amount
  ingredient ID as final tie-break
A preference ranks choices. It does not prove the preferred choice permits a complete allocation.

Code: app/domains/menus/internal/availability/calculator.go

Force a choice that a greedy planner cannot repair

Requirements, each 1 oz:
  A can use Shared or Fallback
  B can use Shared only

Available: Shared=1.5 oz, Fallback=1 oz
Both substitutes have the same quality.

Try A=Shared:
  B would need total Shared=2 oz → impossible

Undo A's reservation.
Try A=Fallback, then B=Shared → complete plan
Rejecting the order at the first dead end would report a shortage even though a valid plan exists.

Code: app/domains/orders/fulfillment_test.go

Backtracking restores the tentative reservation map

// Inside assign(index), after checking available stock.
selected[index] = pick
reserved[key] = total
if assign(index + 1) {
    return true
}
if hadPrior {
    reserved[key] = prior
} else {
    delete(reserved, key)
}

// No candidate works at this index.
return false
The map is search-local bookkeeping, not persisted Inventory reservations. Undoing a branch must restore the exact prior amount.

Code: app/domains/menus/internal/availability/calculator.go

Follow the selected plan into persisted inventory

Adversarial fixture:
  A candidates: Shared, Fallback
  B candidates: Shared
  Shared=1.5 oz; Fallback=1 oz

After Place:
  IngredientUsage: Fallback=1 oz, Shared=1 oz
  two Inventory reservations
  on-hand amounts unchanged

After Complete:
  Shared on hand=0.5 oz; Fallback on hand=0 oz
  order reservations removed
A solver result matters only if the accepted snapshot and later inventory effects preserve that choice.

Code: app/domains/orders/fulfillment_test.go

Optional means omittable, not unaccounted for

// After all candidate branches at this index fail:
if requirements[index].Optional {
    selected[index] = PickResult{Omitted: true}
    if assign(index + 1) {
        return true
    }
}
return false

// Included optional: snapshot → reserve → consume → cost.
// Omitted optional: explicit selection with Omitted=true.
The planner can undo an optional choice to make a required ingredient fit. It must never consume an omitted ingredient.

Code: app/domains/menus/internal/availability/calculator.go

Cost a feasible plan in its price unit

Whole recipe → PlanIngredients once
For each included pick:
  load the chosen stock
  convert required quantity into stock.CostUnit
  multiply by CostPerUnit
  accumulate matching-currency prices

Omitted optional → no cost
Missing price    → unknown, not zero
No complete plan → FailedPrecondition
A cheap per-line choice is meaningless if two lines spend the same scarce substitute.

Code: app/domains/menus/queries/cost.go

Inventory rechecks availability when reserving

// Reserve, inside the originating command transaction.
requested, err := reservation.Amount.Convert(stockUnit)
// Conversion errors propagate.
reserved := sum(existingReservationRows)
if stock.Quantity-reserved < requested.Value() {
    return errors.FailedPreconditionf(/* shortage details */)
}
return tx.Insert(&ReservationRow{
    ID: reservationID(orderID, ingredientID),
    // OrderID, IngredientID, Quantity, Unit
})
Planning chooses a feasible usage snapshot. Inventory remains the owner of the durable reservation invariant.

Code: app/domains/inventory/internal/dao/reservations.go

Blocked is a set of unresolved causes, not a toggle

Pending order uses A and B.

A shortage → BlockedIngredients={A};   status=blocked
B shortage → BlockedIngredients={A,B}; status=blocked
A restock  → BlockedIngredients={B};   status=blocked
B restock  → BlockedIngredients={};    status=pending

StockAdjusted changes only its ingredient's membership.
The stored list is sorted by ingredient ID.
One recovery event must not erase another unresolved shortage.

Code: app/domains/orders/handlers/stock-adjusted.go

Authoritative availability preserves dependency errors

// PlanIngredients:
(picks, true, nil) // complete feasible plan
(nil, false, nil)  // no feasible plan
(nil, false, err)  // dependency or conversion failure

// Strict paths propagate err:
CalculateDetail → PlanIngredients
CalculateStrict → CalculateDetail
Readiness → CalculateDetail
FulfillIngredients → PlanIngredients

// Presentation fallback remains separate:
Calculate → unavailable on error
PickIngredients → (nil, false) on error
Persisted projections, readiness, costing, and order planning must not turn infrastructure failure into an ordinary shortage.

Code: app/domains/menus/internal/availability/calculator.go

A bounded search still has a real cost model

N requirements, candidate counts C1 ... CN
Worst-case branches grow like the product of candidate counts,
with an extra omission branch for each optional requirement.

The implementation:
  builds candidate sets before recursion
  prunes when cumulative stock exceeds availability
  returns the first complete feasible assignment
  has no memoization or explicit search budget

It does not split one requirement across several sources.
Determinism makes choices explainable. It does not make the search globally optimal or constant-time.

Code: app/domains/menus/internal/availability/calculator.go

Domain workshop 3.1c

Preserve acceptance while changing fulfillment

Explicit amendments separate what was accepted from what is now approved to be prepared.

3.1c

One order carries three different records

AcceptanceSnapshot
  menu identity/name; ordered items and notes
  agreed prices; recipe steps and garnish
  selected and omitted ingredients, quantities and ratios

Plan
  current approved line-by-line preparation

Amendments[]
  actor, time, reason, before/after Plan

IngredientUsage
  aggregated quantities backing current reservations
Catalog edits affect future service. They must not reinterpret a customer's accepted order.

Code: app/domains/orders/models/snapshot.go

An accepted order has a concrete preparation

Desktop order detail scrolled to two Margaritas for bar seat four and approved preparation: tequila, lime juice, triple sec, recipe steps, and lime wheel garnish.
Two Margaritas retain their quantities, preparation steps, and garnish in the approved plan.

Headless capture · owner persona · go-modular-monolith 635c59b · select image for full size

Placement owns lifecycle initialization

created := models.Order{
    MenuID: order.MenuID,
    Items:  order.Items,
    Notes:  order.Notes,
}
created.ID = entity.NewOrderID()
created.Status = models.OrderStatusPending
created.CreatedAt = now
created.CompletedAt = optional.None[time.Time]()

// fulfillmentSnapshot populates Plan and Acceptance.
// DAO insert owns the initial revision.
Do not copy an input model wholesale. A caller cannot smuggle terminal state, acceptance, or reservations through placement.

Code: app/domains/orders/internal/commands/place.go

Amendment is explicit, revisioned intent

type Amendment struct {
    OrderID      entity.OrderID
    Revision     uint64
    Reason       string
    Replacements []Replacement
    Preparation  []PreparationAmendment
}

// Replacement: current selected ID → replacement ID + ratio.
// Preparation: approved steps/garnish for selected drink lines.
// Unspecified preparation retains the previous approved values.
Only pending or blocked orders can be amended. Original acceptance and agreed prices stay unchanged.

Code: app/domains/orders/models/snapshot.go

Replan without spending your own reservation twice

Load current order and validate expected revision
Copy Plan; apply approved replacements and quantity ratios
Project stock with this order's reservations released
Plan every included requirement as required
Preserve unchanged preparation unless explicitly amended
Save current Plan + aggregate IngredientUsage + amendment
Emit OrderAmended{Before, Order, Reason}

Inventory: validate old reservations, release, reserve new
Menus: prepare availability from net reservation change
Orders: reconcile peers helped by the released commitment
All steps share one transaction. A late reservation failure restores the original plan and commitments.

Code: app/domains/orders/internal/commands/amend.go

Validate a batch before its own effects change revisions

App.AmendOrders(requests):
  reject duplicate order IDs
  load every selected order
  require every submitted revision to match

  then, for each request:
    reload the current order in this transaction
    use that revision for Orders.Amend

App.RetireIngredient(..., requests):
  amend the explicit selection
  retire the ingredient in the same workflow
Reconciliation may legitimately advance a peer's revision within the batch. Validate stale user intent before that begins.

Code: app/amend_orders.go

Prove that the second failure undoes the first success

Two selected orders; replacement stock can fulfill only one.

Attempt App.RetireIngredient with both amendments:
  first amendment provisionally succeeds
  second amendment fails
  outer transaction rolls back

Assert:
  first order equals its original value
  replacement reserved amount = 0
  original ingredient remains active
  audit count = before + 1; failed workflow has effects
The surviving activity describes the attempted changes. It must not look like committed amendment history.

Code: app/cross_domain_regression_test.go

Historical references can veto deletion

Delete Drink:
  Menus checks active menu references
  Orders checks all historical order usage

Delete Menu:
  Orders checks all historical order usage

A veto during Handling rolls back the source deletion.
Errors identify dependencies and the corrective action.
Redrafting preserves the previous PublishedAt value.
A retained snapshot does not automatically authorize removal of its referenced catalog identity.

Code: app/domains/orders/internal/dao/usage.go

Domain workshop 3.1d

Separate physical stock from service eligibility

Discontinuation, quarantine, release, and disposal are different business decisions.

3.1d

Canonical quantity, display unit, price basis

Stock row:
  Quantity + Unit       canonical physical amount (ml for volume)
  DisplayUnit           operator-facing quantity unit
  CostPerUnit + CostUnit explicit price basis

Example:
  10 oz on hand → stored as 295.735 ml
  change display to ml → same stock and reservations
  $2 per oz remains $2 per oz, not $2 per ml

Discrete units retain their own canonical unit.
A compatible catalog unit change must not change physical stock, accepted usage, or the meaning of its price.

Code: app/domains/inventory/internal/dao/convert.go

Retirement does not mean throwing stock away

Discontinue:
  exclude ingredient from new service
  retain stock, tags, identity and usable reservations

Withdraw (Retirement.Withdraw=true):
  quarantine retained stock
  block affected open orders

Release quarantine:
  active catalog item → active stock
  retired catalog item → discontinued stock

Neither replacement nor release transfers physical inventory.
Future product intent and existing physical commitments have different owners and lifecycle rules.

Code: app/domains/inventory/handlers/ingredient-deleted.go

Disposal preserves evidence of physical loss

Inventory.Dispose:
  require expected stock revision
  require discontinued/quarantined eligibility
  require positive amount and reason
  subtract from physical stock; reject negative remainder
  at zero, mark disposed
  save movement history and retain the stock row
  emit StockAdjusted to reconcile commitments

Inventory.History reads retained movements.
The absence of a row cannot explain what was discarded, why, or which commitments it affected.

Code: app/domains/inventory/internal/commands/dispose.go

Recovery must be as complete as blocking

On hand=3; two orders each reserve 2
Aggregate reserved=4 → both orders blocked

Cancel one order:
  release its reservation
  project remaining reserved=2
  clear that ingredient's blocker on the other order
  return to pending only if no blockers remain

Amendment release and quarantine release also reconcile.
The policy blocks all affected orders during an aggregate deficit. It does not allocate winners by FIFO or priority.

Code: app/domains/orders/handlers/order-cancelled.go

Persist substitution rules by identity

type SubstitutionRule struct {
    Revision      uint64
    Disabled      bool
    IngredientID  entity.IngredientID
    SubstituteID  entity.IngredientID
    Ratio         float64
    QualityImpact Quality
    Notes         string
}

// SetSubstitution authorizes update on the original ingredient.
// SubstitutionRules includes disabled rules for administration.
Renaming an ingredient must not change which substitute it means. A rule edit also refreshes dependent availability.

Code: app/domains/ingredients/models/substitution.go

Walk the lifecycle through the CLI

mixology ingredients substitution --id ing-A \
  --substitute-id ing-B --ratio 0.75 --quality similar
mixology ingredients substitutions --id ing-A
mixology orders amend --id ord-A --ingredient-id ing-A \
  --replacement-id ing-B --revision 3 --reason 'approved'
mixology orders amend-batch --file amendments.json

mixology inventory quarantine --ingredient-id ing-A --reason 'inspect'
mixology inventory release --ingredient-id ing-A --reason 'cleared'
mixology inventory dispose --ingredient-id ing-A \
  --quantity 5 --reason 'discard remainder'
mixology inventory history --ingredient-id ing-A
Replace illustrative IDs with seeded IDs. Capture revisions when intent must refer to the state you reviewed.

Code: main/cli/order_amend.go

Optional extension 3.2

Extend the model with Procurement

planned, not implemented
Orders and Inventory already demonstrate reciprocal reactions. Procurement would add a workflow that spans time and commits.

3.2

The next build: stock creates demand

Inventorystock becomes low
Procurementrecord demand
Explicit operationdraft purchase order
Supplierships goods
Inventoryrecords receipt
This chapter is a workshop plan, not current shipped behavior. The deck keeps that status explicit.

Establish ownership before packages

Inventory owns

On-hand amount, reservation, thresholds, and stock facts.

Procurement owns

Suppliers, offerings, purchase orders, receiving workflow, and commercial intent.

Later: Analytics observes

Queryable facts beside the completed slice. It does not become a dependency of the write path.

Build the slice in visible increments

Suppliersidentity and offerings
Purchase ordersstateful lifecycle
Low-stock factsdraft demand, idempotently
Receipt factsinventory reacts
Consistency viewshow what converged
Workflow limitfind hidden coordination
Outboxonly if commit boundary moves

When a handler becomes a workflow

Leaf reaction

One fact, bounded local mutation, same transaction, no new event.

Keep it in a handler.

Process manager

Waits over time, coordinates retries or compensations, tracks intermediate state, spans commits.

Name the workflow.

Add an outbox when delivery must survive a transaction boundary. Do not use it to decorate a transaction that is already atomic.

Proposed receipt processing needs durable identity

Design exercise, not implemented:

ReceivePurchaseOrder(purchaseOrderID, receiptID, lines)
  load purchase order; authorize receiving
  reject invalid quantities
  detect an already accepted receiptID
  persist receipt and receiving state
  emit ReceiptAccepted{receiptID, ingredient quantities}
  Inventory applies the receipt in the same transaction

External delivery, if later introduced:
  store an outbox record with the receipt commit
  deliver after commit; deduplicate by receiptID
A supplier callback can be retried. The exercise is to place idempotency and transaction ownership before adding asynchronous delivery.

Code: docs/architecture.md

Surfaces 4.0 · CLI toolkit

Start with the command line

Trace one invocation, then inspect the small toolkit shared by its commands: JSON input, structured output, and text rendering.

4.0

CLI walkthrough: discover, change, inspect

go run ./main/cli ingredients list --limit 5
go run ./main/cli ingredients list --limit 5 --json
go run ./main/cli drinks create --template
go run ./main/cli ingredients create "Demo lime" \
  --category juice --unit oz --tags featured
go run ./main/cli ingredients list \
  --filter 'name == "Demo lime" && tags contains "featured"'
go run ./main/cli audit list --limit 5
Show the result first, then trace the create command into the domain facade and the atomic tagged mutation.

A command is an adapter around an operation

flags / JSONparse user intent
domain moduleexecute with fresh context
CLI viewshape the result
output / exithuman or script feedback

Domain adapter

Owns input conversion, domain-specific output, and filter help.

Shared toolkit

Owns reusable JSON decoding, encoders, and table rendering.

Business validation and authorization still run in the application, so a script and an interactive user receive the same decision.

Scripts must preserve concurrency too

ReadObtain the entity and its revision in JSON.
EditChange intended fields while preserving the revision value.
SubmitThe store rejects a stale revision with a typed Conflict.
ResolveReload, compare the intervening change, then decide what to submit.
An opaque revision is a token to return unchanged, not a counter for the client to increment.

CLI rows are projections of application models

type DrinkRow struct {
    ID          string `table:"ID" json:"id"`
    Name        string `table:"NAME" json:"name"`
    Status      string `table:"STATUS" json:"status"`
    Ingredients int    `table:"INGREDIENTS" json:"ingredients"`
    // Category, Glass, Tags omitted.
}

// Selected conversion fields:
ID:          d.ID.String(),
Status:      string(d.Status),
Ingredients: len(d.Recipe.Ingredients),
The table/JSON view can summarize a recipe without changing the domain model or exposing its persistence row.

Code: app/domains/drinks/surfaces/cli/views.go

The command chooses the output contract

// ingredients list, after the authorized application query:
if cmd.Bool("json") {
    return clitoolkit.WriteJSON(cmd.Writer,
        paging.Page[ingredientscli.IngredientRow]{
            Items: ingredientscli.ToIngredientRows(res.Items), Next: res.Next,
        })
}
if err := clitable.PrintTable(cmd.Writer,
    ingredientscli.ToIngredientRows(res.Items)); err != nil {
    return err
}
return printNextCursor(cmd.Writer, res.Next)
The toolkit renders values. The command owns the page envelope, cursor reporting, and choice of representation.

Code: main/cli/ingredients.go

Table and detail rendering have different field rules

// Selected IngredientRow fields:
ID       string `table:"ID" json:"id,omitempty"`
Revision uint64 `table:"-" json:"revision,omitempty"`
Name     string `table:"NAME" json:"name"`
// Revision is absent from the table, present in detail when nonzero.
TestPrintTable fixture        TestPrintDetail fixture
ID     NAME   COUNT            ID:          ord-1
ing-1  Vodka  2                Menu ID:     mnu-1
                              Created At:  2025-02-03T04:05:06Z
                              Status:      pending
Tables opt fields in with table tags. Details use json tags and omit zero values marked omitempty. Neither rule grants or denies access.

Code: table/table.go · Tests: table/table_test.go · IngredientRow

The process boundary consumes the typed error

cmd := cliApp.Command()
if err := cmd.Run(context.Background(), os.Args); err != nil {
    cli.HandleExitCoder(errors.ToCLIExit(err))
    os.Exit(errors.ExitGeneral)
}
The application returns an error. The executable chooses process behavior through the shared adapter: Conflict → 40, Permission → 30, Internal → 50.

Code: main/cli/main.go

Trace JSON input through the validation boundaries

--template              → print example; no mutation
--file OR --stdin        → choose one input source
ReadJSONInput[DTO]       → decode the transport shape
domain CLI conversion   → parse IDs, units, revision, tags
public facade           → authorization + business validation
DAO                     → constraints + optimistic revision

JSON decoding is not business validation.
The toolkit handles transport mechanics. Domain-specific interpretation and authoritative rules remain separate.

Code: pkg/toolkits/cli/json.go

The JSON toolkit standardizes mechanics, not models

func WriteJSON(w io.Writer, v any) error {
    b, err := json.MarshalIndent(v, "", "  ")
    if err != nil {
        return err
    }
    b = append(b, '\n')
    _, err = w.Write(b)
    return err
}
JSONFlag, TemplateFlag, StdinFlag, and FileFlag keep command spelling consistent. ReadJSONInput[T] selects and decodes one source; WriteJSON emits an indented document and newline.

Code: pkg/toolkits/cli/json.go

Reuse the surface mechanics as interaction grows

ToolkitReusable responsibilityDomain adapter still owns
CLI · this chapterDecode a document; render a result.Flags, input interpretation, view conversion.
TUI · 4.1Route messages, keys, forms, and navigation.Screen state and application actions.
GUI · 4.2a–bCompose widgets; execute and publish work safely.Presenter state and application actions.
Add a CLI operation through the domain facade, its CLI adapter, and main/cli composition. Extract mechanics into the toolkit only when they are independent of the business model.

CLI toolkit extension guide · Output contract tests

Surfaces 4.1

Build a reusable MVVM toolkit for the terminal

Model–View–ViewModel separates application data, screen state and actions, and rendering. Bubble Tea supplies the update loop.

4.1

TUI walkthrough: keep context between actions

BrowseRun go run ./main/tui, open Ingredients, filter the list, and select the demo ingredient.
EditUse the displayed key help to edit, inspect tags, and return to the list.
RefreshChange the record from the CLI and observe the TUI query again.
CompareRun with --actor bartender and inspect which workspaces and actions are available.
A persistent session needs navigation, selection, input ownership, and an editor that survives background refresh.

Browse without losing the selected record

Terminal Ingredients workspace with London Dry Gin selected, its spirit category, ounce unit, base-spirit and botanical tags, and description in the adjacent detail pane.
The list and selected ingredient stay together; the shell preserves navigation context.

Headless capture · owner persona · go-modular-monolith 635c59b · select image for full size

Editing changes who owns the keys

Terminal ingredient editor for London Dry Gin with name, category, unit, description, and tags beside the ingredient list.
The editor takes keyboard input while the ingredient list remains in view.

Headless capture · owner persona · go-modular-monolith 635c59b · select image for full size

Adapt the pattern to the runtime

View-model responsibility

A screen owns presentation state and commands behind a testable view-model contract.

+

Bubble Tea runtime

Messages drive explicit updates, commands carry effects, and a string view renders each frame.

The framework is an implementation detail of the toolkit, not the architecture of every screen.

The shell owns application concerns

Domain surface

  • typed presentation state
  • queries and commands
  • domain workflows
  • action projection

Root application shell

  • route and back stack
  • cached view models
  • title, status, help
  • global keys and sizing
  • deferred invalidation

TUI toolkit

  • view-model contract
  • list/detail and viewports
  • forms and dialogs
  • layout, keys, styles

Only the root is a tea.Model

Bubble Tea contract

Update(tea.Msg) (
    tea.Model, tea.Cmd,
)

The executable shell satisfies this runtime boundary.

Mixology contract

Update(tea.Msg) (
    ViewModel, tea.Cmd,
)

Every domain screen stays inside the richer repository-owned abstraction.

Returning ViewModel preserves help and interaction contracts after every update. Domain screens neither embed nor pretend to be tea.Model.

A deliberately small contract

type ViewModel interface {
    Init() tea.Cmd
    Update(tea.Msg) (ViewModel, tea.Cmd)
    View() string
    ShortHelp() []key.Binding
    FullHelp() [][]key.Binding
    Interaction() Interaction
}

type Interaction struct {
    CapturesText bool
    HandlesBack  bool
}
The toolkit knows Bubble Tea. It does not know Drinks, Menus, Cedar actors, or the application composition root.

The toolkit is a kit, not a base screen

Browse

ListDetail, typed ListItem[T], summaries, loading, filtering, paging, and selection.

Compose

DetailViewport, FormViewport, layout arithmetic, reusable components.

Interact

Forms, dialogs, keys, styles, help bindings, and explicit input ownership.

Refresh

A change notification starts an ordinary query; request tokens reject stale results.

pkg/testutil/tuitest is the deterministic program driver. It tests the toolkit and completed application without becoming part of either.

Typed messages keep ownership visible

tea.Msginput arrives
Root shellconsults Interaction
ViewModelupdates state
tea.Cmdreturns typed result

Reusable mechanics

Generic list items retain their typed domain values while satisfying Bubbles interfaces.

Domain choices

Publish, complete, cancel, adjust, and retire remain bindings owned by their domain adapters.

An external change marks inactive screens stale. Active input finishes first, then the screen queries again with a fresh request token.

Tests follow the ownership

pure presentation model tests
component update and rendering tests
domain surface tests
real Bubble Tea program driver
root navigation and input ownership
cross-surface persisted behavior

Capture a request before returning a Bubble Tea effect

m.loadToken++
token := m.loadToken
req := m.request
req.Cursor = cursor

return func() tea.Msg {
    page, err := m.app.Ingredients.List(m.context(), req)
    if err != nil {
        return IngredientsLoadedMsg{Err: err, Token: token}
    }
    // Convert page.Items to the message's []models.Ingredient.
    return IngredientsLoadedMsg{
        Ingredients: items, Next: page.Next, Token: token,
    }
}
The effect captures request values and returns a typed message. It does not mutate the visible list from a background operation.

Code: app/domains/ingredients/surfaces/tui/list_vm.go

Reject a stale message before changing list state

case IngredientsLoadedMsg:
    if msg.Token != m.loadToken {
        return m, nil
    }
    if msg.Err != nil {
        m.shell.SetResult(m.items, msg.Err)
        return m, nil
    }
    m.next = msg.Next
    selected := selectedIngredientID(m.selectedIngredient())
    // Rebuild list items, then restore selection by entity ID.
    m.selectIngredient(selected)
Selection is an entity identity, not a row index. A late result must not replace the current page, error, cursor, or selected record.

Code: app/domains/ingredients/surfaces/tui/list_vm.go

Typing and navigation require explicit input ownership

type Interaction struct {
    HandlesBack  bool
    CapturesText bool
}

// Root shell, simplified routing:
rune key + CapturesText → child gets text, not global q / ?
Escape + help open     → close help
Escape + HandlesBack   → child cancels local interaction
Escape otherwise       → navigate application history
A global keybinding is not global while an editor owns that input. Back must unwind the nearest interaction first.

Code: main/tui/app.go

Defer invalidation without losing the need to refresh

databaseChangedMsg arrives:
  mark inactive views stale
  active view owns Back or text?
    yes → mark active stale; keep interaction
    no  → send DataInvalidatedMsg now

acceptViewUpdate:
  still editing/detail-owned → keep stale flag
  returned to browse         → clear flag; issue normal reload
A refresh request must survive the editor, but must not replace the editor. Staleness and interaction ownership are separate state.

Code: main/tui/app.go

Surfaces 4.2a

Adapt the MVVM toolkit to retained widgets

Fyne changes the interaction model, so the reusable mechanics change with it.

4.2a

GUI walkthrough: the same record in a desktop window

OpenRun go run ./main/gui against the same local database and find the demo ingredient.
InteractBrowse, filter, edit tags, and compare pointer actions with keyboard shortcuts.
Change externallyUpdate from the CLI; observe refresh without replacing an active form.
Inspect a decisionOpen Menus readiness and compare a permitted but blocked Publish action with a denied action.
Widgets persist, queries complete asynchronously, and the window can close while application work is running.

The desktop starts with a searchable catalog

Fyne Ingredients workspace with category and expression filters, create and refresh controls, and a table of seeded ingredients.
Filters and a table expose the same ingredient catalog through retained widgets.

Headless capture · owner persona · go-modular-monolith 635c59b · select image for full size

The same ingredient, a retained form

Desktop editor for London Dry Gin with spirit category, ounce unit, juniper-forward description, and base-spirit and botanical tag tokens.
London Dry Gin carries the same values into native fields and tag controls.

Headless capture · owner persona · go-modular-monolith 635c59b · select image for full size

Start from the application boundary

main/guidatabase, actor, logs, application, session, native lifecycle
domain GUI surfacespresenters and views shaped for each bounded context
pkg/toolkits/guishell, forms, tables, semantic controls, dialogs, executors
Fyne runtimeretained widgets, callbacks, windows, platform event loop

Publish state for the view to render

State

  • plain typed snapshot
  • items and selection
  • mode, form, errors
  • busy and action state

Presenter

  • calls the application
  • owns latest loads
  • admits one submission
  • publishes clones via OnChange

View

  • subscribes to state
  • updates Fyne controls
  • binds pointer and keys
  • owns widget-local state
Synchronous UI actions can publish directly. Async completions and external invalidation cross the injected Dispatcher before touching presenter or widget state.

The GUI toolkit owns retained-mode mechanics

Shell + Routecache and activate views
Standard pageslayout and hierarchy
Semantic controlsstable test identities
Domain viewrenders state

Navigation

UnsavedChanges guards route changes. Commander gives menus, shortcuts, and controls the same intents.

Presentation

List, form, filter, paging, table, tag, validation, dialog, and error mechanics stay domain-free.

Testing

Semantic controls preserve visible guards so tests trigger the same behavior as a person.

Async work has two boundaries

Presenterrequests work
Executorruns application call
Dispatcherpublishes on UI thread
Viewupdates widgets

LatestRequest[T]

Cancels superseded loads and rejects stale queued publications.

Submission

Admits one mutation, then releases on dispatched completion before presenting its result.

GatedDispatcher

Drops widget publications after desktop shutdown closes the publication gate.

Closing a window must not close the store too early

stop producerschange monitor + dashboard
close executorstop admission + drain work
close UI gatereject queued callbacks
close storerelease persistence
A closed window does not mean an accepted database operation has finished.

Check freshness when the UI callback executes

func (r *LatestRequest[T]) dispatch(
    generation uint64, fn func(),
) {
    r.dispatcher.Dispatch(func() {
        r.mu.Lock()
        current := generation == r.generation
        r.mu.Unlock()
        if current {
            fn()
        }
    })
}
A result can become stale after background work finishes but before the UI queue runs. Checking only before Dispatch leaves that race open.

Code: pkg/toolkits/gui/async.go

Publication copies the mutable containers it exposes

func cloneState(state State) State {
    state.Items = append([]models.Ingredient(nil), state.Items...)
    state.History = append([]paging.Cursor(nil), state.History...)
    actionsCopy := make(map[actions.ID]actions.State, len(state.Actions))
    maps.Copy(actionsCopy, state.Actions)
    state.Actions = actionsCopy
    if state.Selected != nil {
        selected := *state.Selected
        state.Selected = &selected
    }
    return state
}
Snapshot and OnChange publish this copy. The view gets presentation state without sharing the presenter's top-level slice or map storage.

Code: app/domains/ingredients/surfaces/gui/presenter.go

Surface deep dive 4.2b

Own work until shutdown is actually complete

Request freshness, mutation admission, UI publication, and store lifetime are separate concurrency boundaries.

4.2b

Reads and mutations have different admission semantics

LatestRequest:
  a newer load supersedes the older load
  cancellation reduces obsolete work
  generation checks reject stale publication

Submission:
  first submit marks active=true
  another submit while active returns false
  completion is dispatched to the UI
  release active, then publish the result
An obsolete read may be discarded. An accepted mutation still needs an accountable completion.

Code: pkg/toolkits/gui/async.go

Stop admission atomically with work accounting

e.mu.Lock()
if e.closed {
    e.mu.Unlock()
    return false
}
e.work.Add(1)
e.mu.Unlock()

go func() {
    defer e.work.Done()
    fn()
}()
return true
The accepted-work count must be incremented before Close can stop admission and begin waiting.

Code: pkg/toolkits/gui/executor.go

Closing the window is an ordered protocol

desktop.Close, guarded by sync.Once:
  stop change monitor; wait for its delivery goroutine
  close dashboard's separately owned lifecycle
  close executor: reject new work, cancel reads, drain
  close UI dispatcher gate
  close application/store
  close log and telemetry resources

Dashboard stops before executor admission closes.
The ordering prevents a producer from counting work the executor will reject, or a worker from reaching a closed database.

Code: main/gui/desktop.go

The publication gate checks queued callbacks again

d.dispatcher.Dispatch(func() {
    d.mu.Lock()
    if d.closed {
        d.mu.Unlock()
        return
    }
    d.active++
    d.mu.Unlock()
    defer finishAndSignalDrained()
    fn()
})

// Close sets closed and waits for active callbacks only.
Queued is not active. Shutdown drops queued callbacks while allowing already-active callbacks to finish.

Code: pkg/toolkits/gui/dispatcher.go

Hold real domain work open while closing the desktop

start two accepted tasks:
  Ingredients.Count
  Ingredients.Create
both wait on a controlled release channel

start desktop.Close in another goroutine
assert Close has not returned
assert the store is still queryable

release both tasks
assert both domain calls succeed
assert Close then completes
A toolkit-only test cannot prove that process shutdown keeps the real application alive long enough.

Code: main/gui/desktop_test.go

Surfaces 4.3

Use the third surface as an architecture test

Difference creates pressure. Pressure reveals misplaced ownership.

4.3

Preserve application behavior in each interface

What every surface must preserve

Application capabilities, authorization, invariants, error meaning, atomicity, and persisted results.

What each runtime teaches

CLI composability, TUI keyboard flow, GUI retained state, async lifecycle, dialogs, and focus.

Hidden ownership fails under a different runtime

PressureRevealed mistakeDurable correction
Persistent dashboardviews assembled business aggregatesshared application read model
Tag editingtwo commands could partially commitatomic RunTaggedMutation
GUI action stateviews duplicated policy-shaped logicdomain action projectors
Native shutdownstore could close under accepted workmanaged executor and gated dispatcher

Cross-surface evidence crosses a boundary

CLI processmutate
SQLitecommit
data_versioninvalidate
GUI / TUIre-query
Asserting that two presenters format similar fixtures is not cross-surface proof. Observe the same persisted application state through both adapters.

A repeatable audit

1

Choose difference

Add a runtime with a different interaction model.

2

Define parity

List durable behavior, not screen shapes.

3

Route findings

Name the owning boundary and add an executable rule.

4

Update all callers

Fix the shared behavior, then adapt each interface.

The parity test crosses a real process boundary

// CLI runs as a built executable in a temporary directory.
run("tags", "add", ingredientID, "origin=cli")

// A composed desktop opens the same database.
driver.Type(tagginggui.ControlValue, "origin=fyne")
driver.Tap(tagginggui.ControlSubmit)
// Assert the GUI result, then close desktop and GUI app.

output := run("tags", "list", ingredientID)
testutil.ErrorIf(t, !strings.Contains(output, "origin=fyne"),
    "CLI did not observe Fyne tag after a fresh lifecycle:\n%s", output)
The assertion observes persisted state through another adapter after shutdown. It cannot pass merely because two presenters share a fixture.

Code: main/gui/cross_surface_test.go

Surfaces 4.4

Share behavior, keep views bespoke

Consistency lives in contracts and outcomes, not identical presentation internals.

4.4

Each runtime has a different unit of interaction

CLI

Invocation, flags, stdin, stdout, exit status. State ends when the process ends.

TUI

Messages, commands, focus, terminal cells, key chords, and a continuous update loop.

GUI

Retained controls, pointer and keyboard events, background work, dispatch, and native lifecycle.

The least common denominator is not neutral

A universal view model either leaks one runtime into the others, or erases the affordances that made each runtime useful.

Shared contracts

  • public models
  • queries and events
  • action meaning
  • typed errors
  • observable outcomes

Application boundary

  • public operations
  • business rules
  • authorization
  • transaction ownership
  • atomic composition

Bespoke

  • focus and selection
  • widget or terminal state
  • async request state
  • navigation mechanics
  • rendering

Reuse mechanics within a surface

CLI toolkitJSON input and output, reflection-based tables
TUI toolkitview contracts, list/detail, forms, dialogs, layout
GUI toolkitshell, tables, semantic controls, executors, dispatchers
Domain surfacescompose only the matching toolkit with domain workflows

Reusable does not mean symmetrical

ToolkitReusable shapeWhy it differs
CLIencoders, decoders, tablesone invocation, then exit
TUIautonomous forms, dialogs, and view modelsmessages repeatedly advance explicit state
GUIshell, page objects, controls, async coordinatorswidgets persist and callbacks publish state
Package shape follows the runtime’s interaction model. Shared application meaning sits below all three.

Cross-cutting does not mean ownerless

surfacedesired tags
RunTaggedMutationvalidate + compose
domain mutationowned behavior
+
Tags.Replaceowned association
domain result + complete tag set = one transaction

Application composition

Participates in a caller transaction or opens one shared unit of work.

Narrow contract

TaggableEntity exposes only EntityUID and SetTags.

Bespoke interaction

Each surface keeps parsing, confirmation, form state, and feedback native.

Invalid tags never start the mutation. A replacement failure rolls the domain change back with it.

Adjacent project commentary: atomic tagged mutations

Use all three interfaces to locate shared behavior

If all three need it

It may belong in the application: dashboard aggregation, atomic tagged mutation, action projection.

If only one runtime needs it

It probably belongs in that toolkit or surface: cursor history, dialog ownership, terminal layout.

Share meaning. Specialize interaction. Test equality at the application boundary.

Test atomic composition by forcing the second step to fail

TestRunTaggedMutationRollsBackDomainMutationWhenTagReplacementFails

Fixture:
  ingredient name = "Before"
  audit count = N

Mutation callback:
  update the real ingredient to "After"
  return a syntactically valid, nonexistent tag target

Tags.Replace:
  fails to load that target

Assertions after the outer call:
  error != nil; ingredient name == "Before"; audit count == N
Even the first command's success activity rolls back with the failed composition. The UI must not report a partially saved form.

Code: app/tagged_mutation_test.go

A dashboard can be partial without inventing zeros

data := UnknownDashboard() // each count starts at -1
load := func(target *int, fn func() (int, error)) {
    value, err := fn()
    if err != nil {
        if first == nil && !errors.IsPermission(err) {
            first = err
        }
        return
    }
    *target = value
}
// Return data plus the first non-permission error.
The aggregate distinguishes unknown from zero and keeps successful values when another query fails.

Code: app/dashboard.go

Surfaces 4.5

Test native desktop behavior headlessly

Confidence comes from a ladder of distinct evidence, not one giant simulated UI test.

4.5

The evidence ladder

pure state + presenter with fake executor and dispatcher
real Fyne controls in the in-memory driver
dialogs, composed shell, and close ordering
fresh-process and cross-surface behavior
race detector and target compilation
pixels and manual accessibility evidence

Inject execution and publication separately

Presenterrequests work
Executorruns off UI thread
Dispatcherreturns to UI thread
Viewrenders state

Deterministic test

Immediate executor and dispatcher expose state transitions without timing guesses.

Production runtime

Managed executor and Fyne dispatcher preserve thread and shutdown ownership.

Semantic controls carry behavior

Pointer

Button activation reaches the guarded command.

Keyboard

Shortcuts invoke the same enabled control, not a parallel code path.

Test

The control exposes state and behavior without pixel coordinates.

A disabled button, its shortcut, and its command must agree. Semantic controls make that one assertion.

Pixels are different evidence

QuestionEvidence
Did the presenter compute the right state?pure model and presenter tests
Did the widget wire that state correctly?virtual window and semantic control tests
Does the composed process close safely?lifecycle and fresh-process tests
Does it look right?targeted screenshots and human review
Does assistive technology work?manual platform protocol, not inferred from pixels

Make an old result wait in the UI queue

executor := &fynetest.ManualExecutor{}
dispatcher := &fynetest.ManualDispatcher{}
request := gui.NewLatestRequest[int](executor, dispatcher)
// publish appends only Loaded values to values.

request.Load(func() (int, error) { return 1, nil }, publish)
executor.RunNext() // Result 1 is queued for publication.
request.Load(func() (int, error) { return 2, nil }, publish)
executor.RunNext()
dispatcher.Drain()

testutil.ErrorIf(t, len(values) != 1 || values[0] != 2,
    "published values = %v, want [2]", values)
Both computations finish before UI publication. Only result 2 may become visible; no timing assumptions or sleeps are needed.

Code: pkg/toolkits/gui/async_test.go

Exercise actual controls by semantic identity

app := test.NewApp()
t.Cleanup(app.Quit)
entry := gui.NewEntry("drink-name")
tapped := false
button := gui.NewButton("save-drink", "Save",
    func() { tapped = true })
driver := fynetest.NewDriver(t, container.NewVBox(entry, button))

driver.Type("drink-name", "Gimlet")
driver.Tap("save-drink")
testutil.ErrorIf(t, entry.Text != "Gimlet" || !tapped,
    "entry=%q tapped=%v", entry.Text, tapped)
This verifies real widget wiring in Fyne's test app. Presenter tests alone would not catch a field or button bound to the wrong behavior.

Code: pkg/toolkits/gui/semantic_test.go

Surfaces 4.6

Project actions, not widgets

Share durable action meaning across interfaces without sharing runtime state.

4.6

Give each state one meaning

Hidden

Authorization denied. Do not advertise an operation the actor cannot perform.

Disabled

Authorized, but a durable domain prerequisite is unmet. Keep the reason.

Enabled

Authorized and currently eligible. The command still remains authoritative.

Declare permission at the right scope

declaration := actions.Group{
    Permission: actions.Require(canEdit),
    Controls: []actions.Control{
        {ID: "name"},
        {ID: "publish",
         Permission: actions.Require(canPublish),
         Conditions: []actions.Condition{saved, publishable}},
    },
}
A group permission is an inherited default, not a permanent decision. Distinct operations override it explicitly.

Permission runs before conditions, so denial never leaks disabled reasons.

Project once, adapt natively

Domain projectorauthorization + durable prerequisites
TUIbindings and help
GUIbuttons, menus, shortcuts
Future webcontrols and explanations
Evaluation failureoperational error, never action state

Projection guides. Commands enforce.

load
authorize + project
state changes
command re-checks
A polished control state is not a lock. Current authorization, revision, and invariants are checked inside the write transaction.

Permission runs before prerequisites

state := State{ID: control.ID, Visible: true, Enabled: true}
if authorize != nil {
    if err := authorize(ctx); err != nil {
        if errors.IsPermission(err) {
            state.Visible = false
            state.Enabled = false
            return state, nil
        }
        return State{}, err
    }
}
// Only now evaluate Conditions in declaration order.
A denial returns hidden state immediately. An evaluation failure returns an error, and prerequisites are not evaluated in either case.

Code: pkg/presentation/actions/actions.go

Inspect the values a surface actually receives

// Same illustrative control, three successful evaluations:
{"id":"publish","visible":false,"enabled":false}

{"id":"publish","visible":true,"enabled":false,
 "disabled_reason":"Recipe review required"}

{"id":"publish","visible":true,"enabled":true}

// A condition returning a dependency error instead produces:
// states == nil, err != nil
Permission denial, an unmet prerequisite, and evaluation failure are three different results. Only the first two become ordinary control state.

Code: pkg/presentation/actions/actions.go

Build path

Walk the pressure, not the package tree

Each chapter starts with a decision the business forces, then follows the mechanism that makes it durable.

The recording arc

Ownershipcontexts + module contracts
Protocoltypes + errors + policy
Executionpipeline + transactions
Evidencelogs + metrics + audit
Coordinationevents + tags + filters
Pressuredegradation + workflows
SurfacesCLI + TUI + GUI

Keep the real command trace in frame

main/cli → domain CLI adapter → Orders.Place
  pipeline.Command
    begin or join transaction
      authorize input → commands.Place → authorize result
      dispatch OrderPlaced → Inventory + Menus handlers
      record successful activity
    commit (or let the outer transaction owner commit)
  render result
The pipeline surrounds the command. Its transaction also contains event reactions and successful audit recording.

Make the next change in the right place

Requested changeStart hereProve it with
A new publication prerequisiteMenus readiness + Publishreport and command agree; failure preserves state
A reaction to a business eventconsumer's handlersowned effects, audit touches, rollback, regenerated wiring
A searchable domain fieldpublic filter view + DAO hydrationexact matches, safe pushdown, authorized paging
A keyboard or layout improvementmatching surface or toolkitinteraction behavior with application rules preserved

Close each recording with observable evidence

Show the behavior

Perform the operation, inspect affected state, and explain the result a person sees.

Trace the decision

Open the owner, its collaborators, and the transaction boundary. Explain the failure this design prevents.

Prove the guarantee

Run the focused behavior test and relevant architecture or generation checks.

Start application tests with testutil.NewFixture(t). It supplies an isolated database and the real authorization, event, transaction, and audit paths.
The destination

One application.
Many honest boundaries.

The monolith is the deployment choice. Modularity is the behavior we keep proving.

github.com/TheFellow/go-modular-monolith · thefellow.github.io/series/mixology/