Documentation
¶
Overview ¶
Package usecaseop is the enforced use-case envelope for FlowCatalyst business operations.
Every state-changing business operation is modelled as an Operation — a value with four named phases that the Run driver executes in a fixed order:
Validate command shape (presence, format, length) — pure, no DB
Authorize resource-level access — REQUIRED, or [Public] to declare open
Execute invariant checks (loads, business rules) → returns a Plan
────────── Run takes over ──────────
apply persist aggregate + write domain event + write the plan's
dispatch jobs + write audit log, atomically in one transaction
What this guarantees, and how ¶
Unlike a bare function that "remembers" to validate, authorize, and commit, the envelope makes the four guarantees structural:
- Validation and authorization ALWAYS run, because Run runs them — an operation cannot skip a phase, only leave it empty.
- Authorization is never silently absent: Authorize is required. An operation that is intentionally open sets it to Public, which is a visible, greppable, reviewable decision; an omitted Authorize fails closed at runtime and is flagged by the uowseal analyzer.
- An operation cannot reach the database except by returning a Plan from Execute. Plan is a sealed interface (its only method is unexported), so the sole constructors are Save / Delete / Emit / SaveAll / Sync, and the sole consumer is Run. There is no path to a persisted aggregate that does not also write the domain event and audit log in the same transaction.
Dispatch jobs ¶
A plan may also carry dispatch jobs, work the platform delivers to a URL (framework write-path contract 7a):
return usecaseop.Save(inv, repo, event).Dispatch(exportJob, notifyJob), nil
Run validates them before the transaction opens and writes them as DISPATCH_JOB outbox rows after the event and before the audit row; see Plan for the rules. The outbox processor sends them to POST /api/dispatch-jobs/batch.
The Plan seal is the Go-idiomatic replacement for the older usecase.Result + internal/sealed token machinery: the unexported interface method does the same job as the token, without threading a witness value through every commit.
An operation ¶
func CreateConnection(repo *connection.Repository) usecaseop.Operation[CreateCommand, ConnectionCreated] {
return usecaseop.Operation[CreateCommand, ConnectionCreated]{
Name: "CreateConnection",
Validate: func(ctx context.Context, cmd CreateCommand) error { ... },
Authorize: func(ctx context.Context, cmd CreateCommand) error {
return auth.RequireAnchor(auth.FromContext(ctx))
},
Execute: func(ctx context.Context, cmd CreateCommand, ec usecase.ExecutionContext) (usecaseop.Plan[ConnectionCreated], error) {
// ... invariant checks, build aggregate + event ...
return usecaseop.Save(c, repo, event), nil
},
}
}
And the HTTP handler:
ec := usecase.NewExecutionContext(ac.PrincipalID)
event, err := usecaseop.Run(ctx, s.UoW, operations.CreateConnection(s.Repo), cmd, ec)
if err != nil { return nil, err }
// event.ConnectionID is the committed id
Index ¶
- func Public[C any](_ context.Context, _ C) error
- func Run[C any, E usecase.DomainEvent](ctx context.Context, uow *usecasepgx.UnitOfWork, op Operation[C, E], cmd C, ...) (E, error)
- func RunTx[C any, R any](ctx context.Context, uow *usecasepgx.UnitOfWork, op TxOperation[C, R], cmd C, ...) (R, error)
- type Operation
- type Plan
- func Delete[A usecase.HasID, E usecase.DomainEvent](agg *A, repo usecasepgx.Persist[A], event E) Plan[E]
- func Emit[E usecase.DomainEvent](event E) Plan[E]
- func Save[A usecase.HasID, E usecase.DomainEvent](agg *A, repo usecasepgx.Persist[A], event E) Plan[E]
- func SaveAll[A usecase.HasID, E usecase.DomainEvent](aggs []A, repo usecasepgx.Persist[A], event E) Plan[E]
- func Sync[A usecase.HasID, RE usecase.DomainEvent](repo usecasepgx.Persist[A], saves []usecasepgx.SyncSaveItem[A], ...) Plan[RE]
- type TxOperation
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Public ¶
Public is the explicit Authorize value for operations that are intentionally open — no resource-level authorization beyond whatever the transport layer already enforced. Prefer this over leaving Authorize nil: "deliberately open" should be a visible decision in the code, not an omission, and it keeps the operation passing the uowseal analyzer.
func Run ¶
func Run[C any, E usecase.DomainEvent]( ctx context.Context, uow *usecasepgx.UnitOfWork, op Operation[C, E], cmd C, ec usecase.ExecutionContext, ) (E, error)
Run executes op's phases in order — Validate, Authorize, Execute — short circuiting on the first error, then applies the returned Plan in one transaction (aggregate change + domain event + audit log, atomically) and returns the committed event.
Run is a free function because Go does not allow type parameters on methods. It is the only consumer of a Plan, so every successful event it returns went through validation, authorization, and an atomic commit.
func RunTx ¶
func RunTx[C any, R any]( ctx context.Context, uow *usecasepgx.UnitOfWork, op TxOperation[C, R], cmd C, ec usecase.ExecutionContext, ) (R, error)
RunTx executes op's phases — Validate, Authorize — then opens one transaction and runs Execute inside it, committing on success and rolling back on error or panic. Returns Execute's custom result.
Types ¶
type Operation ¶
type Operation[C any, E usecase.DomainEvent] struct { // Name identifies the operation in diagnostics. Optional but recommended. Name string // Validate checks command shape: presence, format, length, patterns — // anything that does not require loading data. Return a validation-kind // [usecase.Error] on failure. Optional: leave nil when the command has // nothing to check, though most operations validate at least one field. Validate func(ctx context.Context, cmd C) error // Authorize is the resource-level access check (ownership, scope, // state-based permission). REQUIRED: set it to a real check, or to // [Public] to declare the operation intentionally open. Authorization // data (the authenticated principal) is carried on ctx — platform code // reads it with auth.FromContext(ctx). Run refuses to proceed if this is // nil (fail closed), and the uowseal analyzer flags any Operation literal // that omits it. Authorize func(ctx context.Context, cmd C) error // Execute runs invariant checks (loads aggregates, applies business // rules), builds the domain event, and returns the [Plan] describing the // change to commit. Returning a Plan is the ONLY way an operation can // reach the database; Run applies the Plan after Validate and Authorize // have passed. REQUIRED. Return a non-nil error (typically a // [usecase.Error]) to fail without committing. Execute func(ctx context.Context, cmd C, ec usecase.ExecutionContext) (Plan[E], error) }
Operation is one business operation expressed as four named phases. Build it with a constructor that captures the operation's dependencies (repos, services) in the phase closures, and run it with Run. See the package doc for the contract and an example.
type Plan ¶
type Plan[E usecase.DomainEvent] interface { // Dispatch returns a copy of the plan that also writes these dispatch // jobs (framework write-path contract 7a): DISPATCH_JOB outbox rows in // the same transaction, after the plan's events and before its audit // row, in the order given (calls accumulate). The platform's outbox // processor delivers them to POST /api/dispatch-jobs/batch. The plan it // is called on is not changed. // // Run validates every job (outbox.DispatchJob.Validate: the 7a rules, // with their texts) before the transaction opens; an invalid job fails // the operation as INTERNAL DISPATCH_JOB_INVALID (Message = the rule's // text, Details["index"] = the job's position) and nothing is written. // Check values that come from the command in Validate, so the caller // gets a 400 instead. A job without a correlation id takes the plan's // event's (the returned event; a Sync plan's rollup). Nothing else is // inherited: the job's client is what it names (WithClientCode or // WithPlatformClientID; neither leaves it unscoped), and its eventId is // set only when the job sets it. The UnitOfWork's sink must implement // usecasepgx.DispatchJobSink (outboxpgx.Sink does); otherwise Run fails // with INTERNAL DISPATCH_JOB_UNSUPPORTED, again before any write. Dispatch(jobs ...outbox.DispatchJob) Plan[E] // contains filtered or unexported methods }
Plan describes a pending change and the domain event it produces, built during an operation's Execute phase but NOT yet committed. Run is the only thing that applies a Plan, and it does so inside one transaction — writing the aggregate change, the domain event, the plan's dispatch jobs and the audit log atomically.
Plan is a sealed interface: its apply method is unexported, so only this package can implement it. The only constructors are Save, Delete, Emit, SaveAll, and Sync. An operation therefore cannot reach the database except by returning a Plan and letting Run apply it after Validate and Authorize have run — which is what makes "aggregate written ⇒ event + audit written, atomically" hold by construction.
func Delete ¶
func Delete[A usecase.HasID, E usecase.DomainEvent]( agg *A, repo usecasepgx.Persist[A], event E, ) Plan[E]
Delete plans an aggregate deletion with its domain event.
func Emit ¶
func Emit[E usecase.DomainEvent](event E) Plan[E]
Emit plans a domain event with no aggregate change (e.g. UserLoggedIn).
func Save ¶
func Save[A usecase.HasID, E usecase.DomainEvent]( agg *A, repo usecasepgx.Persist[A], event E, ) Plan[E]
Save plans an aggregate upsert with its domain event. Use for create and update operations.
func SaveAll ¶
func SaveAll[A usecase.HasID, E usecase.DomainEvent]( aggs []A, repo usecasepgx.Persist[A], event E, ) Plan[E]
SaveAll plans an upsert of many aggregates of one type with a single summary event. For one logical command that touches many rows.
func Sync ¶
func Sync[A usecase.HasID, RE usecase.DomainEvent]( repo usecasepgx.Persist[A], saves []usecasepgx.SyncSaveItem[A], deletes []usecasepgx.SyncDeleteItem[A], rollup RE, ) Plan[RE]
Sync plans a batch of per-row saves and deletes plus a rollup event, all in one transaction. For sync / bulk-upsert endpoints whose consumers project the per-row events. The rollup is the returned event. Dispatch jobs are written after the rollup event, before its audit row.
type TxOperation ¶
type TxOperation[C any, R any] struct { // Name identifies the operation in diagnostics. Optional but recommended. Name string // Validate checks command shape. Pure, no DB. Optional. Validate func(ctx context.Context, cmd C) error // Authorize is the resource-level access check. REQUIRED — a real check, // or [Public]. RunTx fails closed if it is nil, and the uowseal analyzer // flags any TxOperation literal that omits it. Authorize func(ctx context.Context, cmd C) error // Execute performs the orchestrated writes inside the open transaction s // and returns the custom result on success, or an error to roll back. // Every aggregate change MUST go through a scoped commit helper // (usecasepgx.CommitScoped / EmitEventScoped / …) so it is written with // its domain event + audit log atomically. REQUIRED. Execute func(ctx context.Context, s *usecasepgx.TxScopedUnitOfWork, cmd C, ec usecase.ExecutionContext) (R, error) }
TxOperation is the multi-aggregate sibling of Operation, for business operations that must write several aggregates (and/or emit several domain events) atomically and return a custom result R that isn't a single domain event — e.g. provisioning a service account together with its principal and OAuth client.
It keeps the same enforced phase order as Operation — Validate → Authorize → Execute — but Execute receives a transaction-scoped unit of work and performs its writes directly via the scoped commit helpers (usecasepgx.CommitScoped / CommitDeleteScoped / EmitEventScoped, and s.WithTx for the rare raw write). RunTx runs the phases, opens ONE transaction, invokes Execute inside it, and commits on success / rolls back on error.
Prefer the single-event Operation + Plan for ordinary CRUD; reach for TxOperation only when one logical command genuinely spans multiple aggregates in one transaction.