Help us shape the future of Go! Take the 2026 Go Developer Survey

usecaseop

package
v0.0.13 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 3, 2026 License: Apache-2.0 Imports: 5 Imported by: 0

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

Constants

This section is empty.

Variables

This section is empty.

Functions

func Public

func Public[C any](_ context.Context, _ C) error

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL