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
Menuscuration + publication
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
Seven workspaces, live counts, and recent activity in the terminal shell.
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.
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.
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.
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.
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.
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.
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.
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
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
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
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
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
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.
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.
The submitted ID selects the resource. Its current fields are loaded inside the transaction before authorization; the returned entity is authorized again before dispatch.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 field
Meaning
Example
Touches
Attributed changed resources
rewritten Drink
Participants
Referenced resources
inspected, unchanged Menu
Effects
Domain-authored before/after explanation
recipe or stock disposition change
WorkflowID
Correlation across commands
domain 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
Situation
Audit behavior
Returned result
success recorder or commit fails
rollback, attempt failure record
operation failure
managed command fails
record after rollback
original error
failure recording also fails
durability cannot be promised
errors.Join preserves both
caller supplies transaction
record inside caller transaction
caller 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.
// 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.
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.
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.
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.
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
Difference
Required action
Recorded activity
add or change values
tag
one stable tag operation
remove keys
untag
mixed replacement
tag + 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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(¤t)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
// 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
JSONFlag, TemplateFlag, StdinFlag, and FileFlag keep command spelling consistent. ReadJSONInput[T] selects and decodes one source; WriteJSON emits an indented document and newline.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.