runtime/contracts is the first runtime slice of the GOWDK Runtime
contract-driven backend model. It is usable from normal Go today. .gwdk
command and query references are discoverable in compiler IR, build reports,
and generated web adapters when they have a routable method/path.
Trust Boundary
frontend UI event -> command/query -> backend handler -> backend-owned event
frontend <- result or presentation event
- UI events are browser-local clicks, submits, inputs, and changes.
- Commands are backend intent and have one owner handler.
- Queries read state and should not change state.
- Domain events are backend facts emitted after command success.
- Integration events are backend facts intended for durable delivery later.
- Presentation events are browser-facing notifications; they are not trusted input.
- Contract scanning rejects first browser-UI and vague event-name anti-patterns
such as
ButtonClicked,FormSubmitted, andPatientChanged.
Runtime API
Enable future compiler integration with:
Addons: []gowdk.Addon{
contractsaddon.Addon(),
}
The implemented runtime registry is currently independent from compiler integration.
Go does not support generic methods, so the API uses generic functions over a registry. Keep this shape while the repository targets Go 1.26; revisit it when the project upgrades to Go 1.27 and the language supports generic methods:
r := contracts.NewRegistry()
contracts.RegisterQuery[GetPatientPage, PatientPageData](r, LoadPatientPage)
contracts.RegisterCommand[CreatePatient, CreatePatientResult](r, HandleCreatePatient)
contracts.RegisterDomainEvent[PatientCreated](r, SendWelcomeEmail)
contracts.RegisterDomainEvent[PatientCreated](r, WriteAuditLog)
contracts.RegisterJob[SyncPatients](r, RunPatientSync)
Run contracts:
page, err := contracts.ExecuteQuery[GetPatientPage, PatientPageData](ctx, r, query)
result, err := contracts.ExecuteCommand[CreatePatient, CreatePatientResult](ctx, r, command)
err := contracts.ExecuteJob(ctx, r, job)
Run a role-filtered runtime:
result, err := contracts.ExecuteCommandForRole[CreatePatient, CreatePatientResult](
ctx,
r,
contracts.RoleWeb,
command,
)
err := contracts.PublishDomainForRole(ctx, r, contracts.RoleWorker, PatientCreated{ID: id})
err := contracts.ExecuteJobForRole(ctx, r, contracts.RoleCron, SyncPatients{})
metadata := r.ContractsForRole(contracts.RoleWeb)
Default execution helpers run the whole in-process registry for small
single-binary apps. Role-specific helpers run handlers with no explicit roles
and handlers registered for the selected role. They skip handlers registered
only for another role and return role_not_allowed when the selected role
tries to execute a command, query, or job that is not available to that role.
Generated web adapters always execute command/query references with
contracts.RoleWeb. A .gwdkg:command or g:query reference to a
worker/cron/admin/API-only contract is a compiler diagnostic, not a generated
route that fails later.
Observability
runtime/contracts exposes stable operation names and labels for logs,
metrics, and traces. These names are API values, not CLI display text.
metadata := r.ContractsForRole(contracts.RoleWeb)
observation := metadata[0].ObservationForRole(
contracts.ObservationExecuteCommand,
contracts.RoleWeb,
)
The stable operation names include:
| Operation | Name |
|---|---|
| Register query | gowdk.contract.register.query |
| Register command | gowdk.contract.register.command |
| Register event | gowdk.contract.register.event |
| Register job | gowdk.contract.register.job |
| Execute query | gowdk.contract.execute.query |
| Execute command | gowdk.contract.execute.command |
| Capture command events | gowdk.contract.capture.command |
| Execute job | gowdk.contract.execute.job |
| Publish event | gowdk.contract.publish.event |
| Store command events in outbox | gowdk.contract.outbox.store |
| Publish broker events | gowdk.contract.broker.publish |
| Send presentation events | gowdk.contract.presentation.send |
| Worker receive batch | gowdk.contract.worker.receive |
| Worker ack batch | gowdk.contract.worker.ack |
| Worker nack batch | gowdk.contract.worker.nack |
Metadata.ObservationLabels() returns the stable contract labels: kind, event
category, contract type name, result type name, role, roles, and handler count
when known. EventEnvelope.ObservationLabels() returns the event kind,
category, and captured event contract type. ContractName[T]() returns the same
Go contract type name used by metadata and event envelopes.
ObservationForRole records the runtime role that performed the operation.
Inside a command handler, emit backend-owned events through the command context:
func HandleCreatePatient(ctx context.Context, cmd CreatePatient) (CreatePatientResult, error) {
id := "patient-1"
if err := contracts.EmitDomain(ctx, PatientCreated{ID: id}); err != nil {
return CreatePatientResult{}, err
}
return CreatePatientResult{ID: id}, nil
}
Emitted events are dispatched only after the command handler returns successfully. If the command returns an error, recorded events are discarded.
Capture events instead of dispatching subscribers when a command needs an outbox boundary:
result, events, err := contracts.CaptureCommandEvents[CreatePatient, CreatePatientResult](
ctx,
r,
command,
)
Each captured EventEnvelope contains the event category, Go type name, and
typed value. Capturing does not run event subscribers.
For dependency-free outbox integration, implement the small Outbox interface:
type PatientOutbox struct{}
func (PatientOutbox) StoreEvents(ctx context.Context, events []contracts.EventEnvelope) error {
return nil
}
result, err := contracts.ExecuteCommandToOutbox[CreatePatient, CreatePatientResult](
ctx,
r,
PatientOutbox{},
command,
)
ExecuteCommandToOutbox stores events only after the command handler succeeds.
It does not dispatch subscribers. Database transactions, outbox tables, retry
policy, idempotency, broker publication, and worker delivery remain adapter
responsibilities outside the core package.
For durable domain events, adapter code should preserve this order:
start transaction
apply state change
store domain event in outbox
commit transaction
publish from worker
Use plain ExecuteCommand for small single-binary apps where in-process
subscriber dispatch is enough. Use CaptureCommandEvents or
ExecuteCommandToOutbox when subscribers should run from a later worker or
broker delivery path.
GOWDK Runtime also includes a dependency-free file outbox adapter for local durable JSON Lines storage:
import "github.com/cssbruno/gowdk/runtime/contracts/fileoutbox"
outbox := fileoutbox.New(
"var/gowdk-outbox.jsonl",
fileoutbox.WithJSONTypeDecoder[PatientCreated](),
fileoutbox.WithDeadLetter("var/gowdk-outbox.dead.jsonl", 5),
)
_, err := contracts.ExecuteCommandToOutbox[CreatePatient, CreatePatientResult](
ctx,
r,
outbox,
command,
)
err = contracts.RunEventWorker(ctx, r, outbox)
The file outbox implements both contracts.Outbox and
contracts.EventSource. It appends captured envelopes as JSON Lines records,
decodes records through explicitly registered decoders, removes records only
after worker Ack, and keeps records after Nack for retry. Nack records the
attempt count, last attempt time, and last error in the durable record. It is
useful for local development, small single-host deployments, and tests.
When WithDeadLetter(path, maxAttempts) is configured, records move to the
dead-letter JSON Lines file after the configured failed delivery count.
Applications that need database transactions, cross-process locking, retry
backoff, broker delivery, or operational dead-letter processing should use a
database-backed or broker-backed adapter.
Subscriber handlers must be idempotent for any durable delivery adapter. A
worker can crash after a subscriber side effect but before Ack, or an adapter
can retry after Nack. Use a stable domain key, event id, outbox record id, or
application-level dedupe table to make repeated deliveries safe. GOWDK Runtime
does not hide retries behind generated JavaScript or browser state.
External broker adapters can implement the dependency-free Broker interface:
type PatientBroker struct{}
func (PatientBroker) PublishEvents(ctx context.Context, events []contracts.EventEnvelope) error {
return nil
}
result, err := contracts.ExecuteCommandToBroker[CreatePatient, CreatePatientResult](
ctx,
r,
PatientBroker{},
command,
)
ExecuteCommandToBroker publishes captured events only after the command
handler succeeds. It does not dispatch local subscribers. Broker adapters own
serialization, acknowledgements, retries, dead-letter behavior, and delivery
guarantees.
Realtime adapters can implement PresentationFanout for browser-facing output:
type PatientFanout struct{}
func (PatientFanout) SendPresentationEvents(ctx context.Context, events []contracts.EventEnvelope) error {
return nil
}
result, err := contracts.ExecuteCommandToPresentationFanout[CreatePatient, CreatePatientResult](
ctx,
r,
PatientFanout{},
command,
)
Only presentation events are sent to fanout. Domain and integration events are filtered out. Fanout adapters own SSE/WebSocket sessions, serialization, client targeting, buffering, and disconnect behavior.
Worker or broker adapter code can replay captured events through the same typed subscriber registry:
err := contracts.PublishEnvelopesForRole(ctx, r, contracts.RoleWorker, events)
Envelope replay keeps the original event category and type. Subscribers still
run through role filtering. If a subscriber returns an error, replay stops and
returns subscriber_failed; adapter retry and idempotency policy stays outside
the core runtime.
Queue or outbox adapters can drive worker subscribers with EventSource:
type PatientEventSource struct{}
func (PatientEventSource) ReceiveEventBatch(ctx context.Context) (contracts.EventBatch, error) {
return contracts.EventBatch{}, contracts.ErrEventSourceClosed
}
err := contracts.RunEventWorker(ctx, r, PatientEventSource{})
RunEventWorker dispatches batches with RoleWorker, calls Ack after
successful subscriber replay, calls Nack when subscriber replay fails, stops
cleanly when the source returns ErrEventSourceClosed, and returns the context
error when ctx is canceled. RunEventWorkerForRole can be used for another
runtime role.
Generated command routes use the same event-plumbing boundary through one configurable sink:
gowdkapp.RegisterContractEventSink(contracts.OutboxCommandEventSink(outbox))
The generated app API exists when routable command contract adapters are
generated. Passing nil restores the default in-process sink:
gowdkapp.RegisterContractEventSink(nil)
Available sink helpers:
InProcessCommandEventSink()dispatches captured events through the local registry with role filtering.OutboxCommandEventSink(outbox)stores captured events without local subscriber dispatch.BrokerCommandEventSink(broker)publishes captured events to a broker.PresentationFanoutCommandEventSink(fanout)sends only presentation events.CompositeCommandEventSink(...)sends the same captured event batch to multiple sinks in order.
Apps that need more than one destination can implement
contracts.CommandEventSink directly or use CompositeCommandEventSink.
Choose the sink based on where subscribers should run:
| Need | Sink |
|---|---|
| Small single-binary app | InProcessCommandEventSink() |
| Local durable queue or test fixture | OutboxCommandEventSink(fileoutbox.New(...)) |
| Local in-memory queue | BrokerCommandEventSink(membroker.New()) |
| Redis Streams queue | BrokerCommandEventSink(redisstream.New(...)) |
| Core NATS live pub/sub | BrokerCommandEventSink(natsbroker.New(...)) |
| Browser notifications over SSE or WebSocket | PresentationFanoutCommandEventSink(hub) |
| More than one destination | CompositeCommandEventSink(...) |
CompositeCommandEventSink sends the same captured batch to each sink in
order. A later sink is not called after an earlier sink returns an error.
Presentation fanout sinks filter non-presentation events themselves. Broker and
outbox sinks receive the full event batch.
Generated packages with executable contract registrations also expose:
registry := gowdkapp.NewContractRegistry()
err := gowdkapp.RunContractEventWorker(ctx, source)
NewContractRegistry creates a fresh registry using the scanned registration
functions. RunContractEventWorker replays an EventSource through the same
registrations with the worker role.
Dependency-free adapters:
runtime/contracts/fileoutboxstores JSON Lines records on disk and implements bothOutboxandEventSource.runtime/contracts/membrokerprovides an in-memoryBrokerandEventSourcefor tests, local development, and single-process apps.runtime/contracts/sseprovides anhttp.HandlerandPresentationFanoutfor server-sent browser presentation events.
Optional broker and realtime adapters:
runtime/contracts/redisstreamuses Redis Streams as aBrokerandEventSource.runtime/contracts/natsbrokeruses core NATS publish/subscribe as aBrokerandEventSource.runtime/contracts/websocketfanoutprovides anhttp.HandlerandPresentationFanoutfor browser WebSocket clients.
These concrete optional adapters are nested Go modules. Add only the adapter an application uses:
go get github.com/cssbruno/gowdk/runtime/contracts/redisstream
go get github.com/cssbruno/gowdk/runtime/contracts/natsbroker
go get github.com/cssbruno/gowdk/runtime/contracts/websocketfanout
Sink Recipes
Redis Streams
Use Redis Streams when command routes should append events to a queue and a worker should replay subscribers later:
import (
"time"
"github.com/cssbruno/gowdk/runtime/contracts"
"github.com/cssbruno/gowdk/runtime/contracts/redisstream"
redis "github.com/redis/go-redis/v9"
)
client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
events := redisstream.New(
client,
"gowdk:events",
"gowdk-workers",
"worker-1",
redisstream.WithBlock(5*time.Second),
redisstream.WithJSONDecoder[PatientCreated]("patients.PatientCreated"),
)
if err := events.EnsureGroup(ctx); err != nil {
return err
}
gowdkapp.RegisterContractEventSink(contracts.BrokerCommandEventSink(events))
A worker can use the same adapter as an EventSource:
if err := gowdkapp.RunContractEventWorker(ctx, events); err != nil {
return err
}
Ack calls XACK and then XDEL. Subscriber failures leave messages pending
for the Redis consumer group to handle according to app-owned retry policy.
Register JSON decoders for event types that need typed Go values when replayed
through subscribers.
NATS
Use the NATS adapter for live event distribution where subscribers are expected to be online:
import (
"time"
"github.com/cssbruno/gowdk/runtime/contracts"
"github.com/cssbruno/gowdk/runtime/contracts/natsbroker"
nats "github.com/nats-io/nats.go"
)
conn, err := nats.Connect(nats.DefaultURL)
if err != nil {
return err
}
events := natsbroker.New(
conn,
"gowdk.events",
natsbroker.WithQueue("gowdk-workers"),
natsbroker.WithTimeout(5*time.Second),
natsbroker.WithJSONDecoder[PatientCreated]("patients.PatientCreated"),
)
defer events.Close()
gowdkapp.RegisterContractEventSink(contracts.BrokerCommandEventSink(events))
Worker replay uses the same adapter:
if err := gowdkapp.RunContractEventWorker(ctx, events); err != nil {
return err
}
This adapter uses core NATS publish/subscribe. It does not provide durable replay for offline subscribers. Use Redis Streams, the file outbox, or a custom JetStream adapter when events must survive worker downtime.
SSE Presentation Fanout
Use SSE when the app needs one-way browser presentation events:
import (
"net/http"
"github.com/cssbruno/gowdk/runtime/contracts"
"github.com/cssbruno/gowdk/runtime/contracts/sse"
)
hub := sse.New()
http.Handle("/gowdk/events", hub)
gowdkapp.RegisterContractEventSink(
contracts.PresentationFanoutCommandEventSink(hub),
)
The browser receives event: gowdk-presentation messages whose data value is
the JSON contracts.EventEnvelope. Domain and integration events are ignored.
WebSocket Presentation Fanout
Use WebSocket fanout when clients need a persistent bidirectional transport for presentation events:
import (
"net/http"
"github.com/coder/websocket"
"github.com/cssbruno/gowdk/runtime/contracts"
"github.com/cssbruno/gowdk/runtime/contracts/websocketfanout"
)
hub := websocketfanout.New(websocketfanout.WithAcceptOptions(websocket.AcceptOptions{
OriginPatterns: []string{"https://example.com"},
}))
http.Handle("/gowdk/events/ws", hub)
gowdkapp.RegisterContractEventSink(
contracts.PresentationFanoutCommandEventSink(hub),
)
Each presentation event is written as one text JSON contracts.EventEnvelope.
Slow clients can drop queued messages when their buffer fills; tune
WithBufferSize for the app's realtime behavior.
Fanout Plus Queue
Apps can send browser presentation events immediately and still queue the full event batch for backend workers:
sink := contracts.CompositeCommandEventSink(
contracts.PresentationFanoutCommandEventSink(hub),
contracts.BrokerCommandEventSink(events),
)
gowdkapp.RegisterContractEventSink(sink)
The generated command route fails before writing JSON success if any sink returns an error.
.gwdk Command References
Use g:command on forms to declare backend command intent:
<form method="post" action="/patients" g:command="patients.CreatePatient">
<input name="name">
<button>Create patient</button>
</form>
Current behavior:
- Renders
data-gowdk-command="patients.CreatePatient". - Adds a command reference to
internal/gwdkir.Program.ContractRefs. - Records the reference alias, imported package path when declared with a
.gwdk import, local command type, bound result type, binding status, and handler/register function names and runtime roles in IR/build-report metadata when known. - Records literal form
methodandactionas command adapter IR method/path. gowdk buildlinks command references to scanned Go command registrations and addscontract_referenceevents with status and source line/column togowdk-build-report.json. Command events include method/path when present.- Generated apps register the scanned package registration function once in a
local
runtime/contracts.Registry, route the form method/action through the backend router, capture emitted backend events withCaptureCommandEventsForRole(..., contracts.RoleWeb, input), send captured events to the configured command event sink, and return the command result as no-store JSON. - When the scanner can see the exported command input struct fields, generated adapters parse submitted form values, allow only the scanned fields, decode supported scalar fields, and pass the typed command input to the registry.
- When generated CSRF is enabled, command contract forms receive the same hidden token injection as POST action forms, and generated command adapters validate the submitted token before dispatch.
- Command references on guarded pages inherit the page guards. When rate limiting is enabled, generated command adapters run rate limiting first, guards second, then form parsing, CSRF validation, typed input decoding, and command execution.
gowdk checkand CLIgowdk buildfail when a command reference is missing, linked to an invalid Go handler signature, or bound only to non-web runtime roles.- Requires a package-qualified Go reference such as
patients.CreatePatient. - Must not be combined with
g:post.
If the scanner cannot see the command input fields yet, generated command adapters construct a zero-value command input before dispatch.
.gwdk Query References
Use g:query on HTML elements to declare readonly backend query intent:
<section g:query="patients.GetPatientPage">
<h1>Patients</h1>
</section>
Current behavior:
- Renders
data-gowdk-query="patients.GetPatientPage". - Adds a query reference to
internal/gwdkir.Program.ContractRefs. - Records the reference alias, imported package path when declared with a
.gwdk import, local query type, bound result type, binding status, and handler/register function names and runtime roles in IR/build-report metadata when known. gowdk buildlinks query references to scanned Go query registrations and addscontract_referenceevents with status and source line/column togowdk-build-report.json.- Page-owned query references record
GETplus the page route as first request-time source metadata. - Generated apps register the scanned package registration function once in a
local
runtime/contracts.Registry, route page-owned query references through the backend router, execute the query withExecuteQueryForRole(..., contracts.RoleWeb, input), and return the query result as no-store JSON. - Page-owned query routes share the page path, so generated apps dispatch them
only for explicit query requests:
Accept: application/json, another+jsonmedia type, orX-GOWDK-Query: true. Normal document requests keep serving the page HTML at the same route. - When the scanner can see the exported query input struct fields, generated adapters decode supported URL query parameters into the typed query input.
- Query references on guarded pages inherit the page guards. When rate limiting is enabled, generated query adapters run rate limiting first, guards second, then typed URL query decoding and query execution.
gowdk checkand CLIgowdk buildfail when a query reference is missing, linked to an invalid Go handler signature, or bound only to non-web runtime roles.- Requires a package-qualified Go reference such as
patients.GetPatientPage. - Must not be combined with
g:postorg:commandon the same form.
If the scanner cannot see the query input fields yet, generated query adapters construct a zero-value query input before dispatch.
Templates must not declare backend facts:
<!-- rejected -->
<form g:event="PatientCreated">
Use g:on:* for local UI/component events and g:command for backend intent.
Current Limits
- Generated command/query adapters execute bound references through
runtime/contractswhen the.gwdkreference has a routable method/path, an import path, a local contract type, a result type, and a scanned package registration function. .gwdkcommand/query reference linking matches the full reference name, the captured local contract type, and the scanned Go contract type import path when the.gwdkimport alias differs from the Go package name.- Form-local
g:commandreferences and element-localg:queryreferences include exact source line and column in IR and build reports. - Missing, invalid, or non-web-only command/query references produce
contract_reference_*diagnostics ingowdk checkand stop CLI builds. - Generated fallback contract routes that remain in appgen for allowed non-bound modes return explicit HTTP 501 no-store responses.
- Other contract diagnostics do not all have exact source spans yet.
gowdk contracts,gowdk list commands|queries|events|jobs,gowdk graph, andgowdk trace <contract>can scan Go AST registration calls today.- Contract scan reports include
go/typesdiagnostics for command, query, event, and job handler signatures across local package files and imported handler symbols when the standard Go importer can resolve them. - Contract scanning caches package import/export inspection by package directory and import set inside each scan.
- Contract scanning rejects feature packages that import generated app output
such as
gowdk-generated-app/gowdkapp, because that dependency direction creates generated app import cycles. - Contract scan reports include the top-level package registration function
that accepts
*contracts.Registry, when the registration call is inside one. - Contract scan roles are propagated into linked IR, app adapter IR, and build-report metadata.
- Page guards are propagated into linked IR and app adapter IR for generated command/query routes.
- Contract scan reports include same-package exported command/query input struct fields for generated form/query decoders.
- Contract scan reports validate local and imported contract/result types
resolved by
go/typesas exported struct symbols where the scanner can resolve them. - Contract scan reports duplicate command owner registrations.
gowdk checkand CLIgowdk buildfail on contract scan diagnostics such as invalid handler signatures and duplicate command owners.gowdk graphdetects command-emitted events when command handlers callcontracts.EmitDomain,contracts.EmitIntegration, orcontracts.EmitPresentationwith a visible event type.- Contract scanning reports
contract_event_category_invalidwhen a command emits a visible event type under one category but the scanner only sees registrations for that event type under another category. gowdk trace <contract>reports a single command/query/event/job, command emitted events, event subscribers, source locations, handlers, and roles.runtime/contractscan capture command-emitted events asEventEnvelopevalues and pass them to a dependency-freeOutboxinterface without dispatching subscribers.- Captured event envelopes can be replayed later with
PublishEnvelope,PublishEnvelopes, and role-filtered variants. runtime/contracts/fileoutboxprovides a dependency-free JSON Lines adapter that implementscontracts.Outboxandcontracts.EventSource, including nack retry metadata and an opt-in dead-letter file.- External broker adapters can implement the dependency-free
Brokerinterface and receive captured envelopes throughExecuteCommandToBrokerorPublishEventsToBroker. - Realtime adapters can implement the dependency-free
PresentationFanoutinterface and receive only presentation envelopes throughExecuteCommandToPresentationFanoutorSendPresentationEventsToFanout. - Generated command adapters expose
RegisterContractEventSink; a registeredCommandEventSinkreceives captured command events before the generated adapter writes the JSON command result. - Generated contract packages expose
NewContractRegistryandRunContractEventWorkerwhen executable contract registrations are present. - Queue/outbox adapters can implement the dependency-free
EventSourceinterface and drive worker-role subscribers throughRunEventWorker. internal/appgenrecords command/query contract exposure metadata in backend adapter IR, including reference name, alias, import path, local contract type, result type, runtime roles, decoded input fields, binding status, handler, register function, owner, and source.- Generated app adapter source is assembled from Go AST nodes, printed with
go/printer, and normalized withgo/format; contract adapter emitters do not use string line writing. gowdk routesincludes routableg:commandandg:queryreferences as backend endpoint metadata with contract binding details.- Command contract adapter IR includes literal form method/path.
- Page-owned query contract adapter IR includes
GETplus the page route. - Page-owned generated query routes use JSON/query request negotiation so they do not replace normal static, SPA, or SSR page responses.
- Cross-package contract input field discovery remains planned.
- Retry backoff policy, split web/worker/cron binaries, and managed deployment recipes remain planned.