Declaring APIs and Ports: The Full Picture¶
See also: API Contracts (one-struct-one-call convenience matrix) · Codec — declare once (
Param/MergedParam[T]/Template[T]mechanics) · Ports, Plugins, and Adapters
This page compares how you declare a route, channel, request/reply pair,
MCP resource, file, or directory across all six boundaries go-codex ships
today: api/rest, api/events, api/reqreply, api/mcp, ports.File, and
ports.Dir. API Contracts answers "once declared, how do
I call it with one struct?" — this page answers "what does the declaration
itself look like, what's the default way, what escape hatches exist, and
where/why do the six boundaries diverge?"
The two declaration workflows¶
Every boundary falls into one of two workflows, split by whether it produces a machine-readable spec (OpenAPI/AsyncAPI/MCP manifest) that needs to accumulate across many declarations into one document.
Spec-backed (api/rest, api/events, api/reqreply, api/mcp) — three steps:
NewRoute / NewChannel / NewResource / NewTool
│
└─ .Register(builder) ──→ Handle ──→ adapter (nethttp / mqtt5 / mcpgo)
└──→ builder.OpenAPISpec() / AsyncAPISpec() / MCPSpec()
A *Builder accumulates every declaration in a package/service so one call
produces the whole spec document. .ClientHandle() is available on
rest.Route, events.Channel, reqreply.Route, and mcp.Tool as a
builder-free shortcut when you only need the codec/merge helpers (e.g. an
HTTP client that never generates its own spec) — mcp.Resource/mcp.Prompt
do not offer ClientHandle, since resources/prompts are inherently
server-declared concepts with no independent client-side spec use case.
api/rest/api/events/api/reqreply add a middle step: capability
requirement/value declaration, between declaring the pattern and attaching
an adapter (api/mcp has no equivalent — MCP has no protocol-native
capability concept to declare):
NewRoute / NewChannel (step 1 — declare the pattern)
│
├─ RequireQoS(...) / HeaderParam{...} (step 2 — declare capability
│ / Capabilities: []pkg.Capability{} requirements and/or values,
│ via a RouteOpt/ChannelOpt or
│ an adapter's Options struct)
│
└─ .Register(builder) ──→ Handle ──→ adapter (step 3 — attach)
Capability is the interface an adapter implements against — the
PROGRAMMING CONTRACT between the api layer and the adapter layer for
protocol-native behavior, exactly like ServerTransport/ClientTransport
are the contract for the base pattern itself. A route/channel declares WHAT
it needs (RequireQoS, a HeaderParam, …) independent of which adapter
eventually attaches; the adapter declares WHAT it can supply (a sealed
Capability value, or simply existing as an HTTP transport that can always
extract headers); Serve/Attach time is where the two are checked against
each other — see D-0006 — Protocol-Native Capabilities
for the complete three-tier model (Explicit/Implicit/adapter-value) this
step covers.
Non-spec (ports.File, ports.Dir) — two steps:
There is no Builder, no Register, and no Handle type — NewFile/
NewDir return the directly usable value. File I/O has no OpenAPI/AsyncAPI
analogue to accumulate, so the extra indirection the spec-backed boundaries
need doesn't apply here.
Comparison table¶
| Boundary | Primary constructor (bare string) | Escape hatch (reuse a pre-built shape) | Vars/param type | Merge-capable into content type? | Builder/Register? |
|---|---|---|---|---|---|
REST (api/rest) |
rest.NewRoute[Req,Resp]("GET", "/users/{id}", ...) |
rest.NewPath("/users/{id}", params...) + rest.NewRouteFromPath(path, ...) |
rest.PathParam / rest.MergedPathParam[T] |
✅ via rest.NewPathParam[T] (merges into Req) |
✅ Route.Register(builder) |
Events (api/events) |
events.NewChannel[T]("sensors/{id}/readings", ...) |
events.NewTopic("sensors/{id}/readings", params...) + events.NewChannelFromTopic(topic, ...) |
events.TopicParam / events.MergedTopicParam[T] |
✅ via events.NewTopicParam[T] (merges into payload T) |
✅ Channel.WithSubscribe(...)/WithPublish(...).Handle(builder) |
Req/reply (api/reqreply) |
reqreply.NewRoute[Req,Resp]("device/{id}/cmd", ...) |
reqreply.NewTopic("device/{id}/cmd", params...) + reqreply.NewRouteFromTopic(topic, ...) |
reqreply.TopicParam / reqreply.MergedTopicParam[Req] |
✅ via reqreply.NewTopicParam[Req] (merges into Req only, never Resp) |
✅ Route.Register(builder) |
MCP (api/mcp) |
mcp.NewResource[V,T]("items://{id}", codec, mcp.URIParam(field), ...) |
mcp.NewResourceFromTemplate(template, codec, ...) |
codex.Template[V] (typed vars container, built from codex.FieldCodec[V]s via URIParam) |
❌ — V is independent of T by design (see below) |
✅ Resource.Register(builder) |
File I/O (ports.File) |
ports.NewFile[T]("data/{id}.json", format, ...) |
ports.FilePathTemplate{...} + ports.NewFileFromPathTemplate(t, format, ...) |
ports.FilePathParam / ports.MergedFilePathParam[T] |
✅ via ports.NewFilePathParam[T] (merges into decoded content T) |
❌ no builder — NewFile returns File[T] directly |
Dir I/O (ports.Dir) |
ports.NewDir("data/{id}/", ...) |
ports.DirPathTemplate{...} + ports.NewDirFromPathTemplate(t, ...) |
ports.DirPathParam (validate-only — no merge variant) |
❌ — Dir has no content type to merge into |
❌ no builder — NewDir returns Dir directly |
mcp.Tool's input/output are plain typed In/Out codecs with no path/URI
vars at all (tools are invoked with a single JSON args object, not a
templated address) — it isn't a row above because it has no vars-declaration
story to compare; see MCP tools for its
shape. mcp.Prompt sits a tier below even mcp.Resource: its arguments are
validated (ValidateArgs) but handed to the app as a raw
map[string]string, never a typed/merged container — prompts are the
simplest, least-typed of the four api/mcp concepts.
What's consistent¶
- Bare-string-first is the default everywhere.
NewRoute,NewChannel,NewRoute(reqreply),NewResource,NewFile, andNewDirall accept the template as a plain string first, with param/field declarations as trailing variadic opts. You never have to pre-build a shape object just to declare one route/channel/resource/file — that's reserved for the reuse case below. - A reusable shape +
NewXFromYescape hatch exists for all 6 boundaries (rest.Path+NewRouteFromPath,events.Topic+NewChannelFromTopic,reqreply.Topic+NewRouteFromTopic,mcpTemplate+NewResourceFromTemplate,ports.FilePathTemplate+NewFileFromPathTemplate,ports.DirPathTemplate+NewDirFromPathTemplate). Reach for it only when the same template and param declarations are shared by two or more routes/channels/routes/resources/files of different content types — see API Contracts — Reusing a Topic/Path/FilePathTemplate for the full recipe. Declaring a single route/channel/resource/file never needs it. - A validate-only param escape hatch exists everywhere. Every boundary
lets you declare a var that is validated against its codec but never
merged into any Go struct (
rest.PathParam{Name:"x"},events.TopicParam{Name:"x"},reqreply.TopicParam{Name:"x"},ports.FilePathParam{Name:"x"},ports.DirPathParam{Name:"x"}, or acodex.Template[V]field the app chooses not to read after extraction). Use it when a var must be well-formed (e.g. matches a UUID codec) but the handler has no use for its value. codex.IdentityField/RequiredField/OptionalField/DefaultFieldunderlie every merge-capable param'sget/setclosures across all boundaries — see Codec — Reusing Field declarations.
Where it differs, and why¶
api/reqreply now has a Topic+NewRouteFromTopic escape hatch, matching
the other five boundaries. This was the one genuine, no-workaround gap
found during the original audit — reqreply was the only spec-backed
boundary without a reusable-shape escape hatch, even though the use case
(e.g. one topic family used for several distinct command types with
different Req/Resp pairs) is just as real for req/reply as it is for
REST paths or event topics. It now mirrors events.Topic/
NewChannelFromTopic exactly: reqreply.Topic, reqreply.NewTopic,
reqreply.NewRouteFromTopic[Req,Resp]. reqreply.Builder also gained the
matching WithTopicCodec/WithTopicConstraints builder-level override
(mirroring events.WithTopicCodec/WithTopicConstraints), closing a
second, related gap in the same pass — see reqreply.InvalidTopicError.
api/mcp Resource uses codex.Template[V] instead of codex.Param/
MergedParam[T]. Every other boundary's vars can optionally merge into
an existing Req/content type, so Param/MergedParam[T] (validate-only
OR merge-into-T) is the right shared primitive. MCP resources have no
merge target at all — T (the resource content) is application-produced
when the handler runs, not wire-decoded from the URI the way a REST body or
event payload is — so there's nothing for the vars to merge into. V is
therefore always a real, independent, typed vars container built from
codex.FieldCodec[V] declarations (via mcp.URIParam), exactly the same
mechanism codex.Template[T] itself already provides. This was a deliberate
design conclusion, not an oversight: forcing Param/MergedParam[T] onto
MCP resources would add a merge target that's never usable.
ports.Dir has no merge-capable param variant. ports.FilePathParam
comes in both a validate-only form and a merge-capable
MergedFilePathParam[T]/NewFilePathParam[T] form, because File[T] has a
decoded content type T for vars to merge into. Dir has no content type
at all (it lists/matches entries, it doesn't decode one payload) — so
DirPathParam is validate-only only, the same reasoning that keeps MCP
Resource vars from merging into T.
ports.File/ports.Dir have no Builder/Register/Handle. They
don't generate a spec document, so the extra indirection the four api/*
boundaries need (accumulate declarations into a builder, then ask the
builder for a handle and a spec) has nothing to accumulate into. NewFile/
NewDir return the directly usable value; adapters (adapters/file) and
ports.Pattern bindings consume it as-is.
api/mcp.Tool.ClientHandle() returns (*ToolHandle, error); the other
three boundaries' ClientHandle() return just the handle. This is a
narrow, mechanical difference (JSON schema generation for tool args/results
can fail at ClientHandle time) rather than a design divergence — treat it
as a signature detail, not a workflow difference.
Builder-level global codec/constraint overrides
(WithPathCodec/WithPathConstraints, WithTopicCodec/
WithTopicConstraints) exist for api/rest, api/events, AND
api/reqreply. All three let you set a default var codec/constraint set
for every path/topic param in a Builder that doesn't specify its own —
reqreply.WithTopicCodec/WithTopicConstraints were added to close the
gap that existed here, mirroring events's shape exactly (same option
names, same InvalidTopicError semantics).
Error-response declarations use two different names for the same idea.
api/rest, api/reqreply, and api/mcp name it ErrorPattern[E,B];
api/events names the identical mechanism ErrorChannel[E,B] — matching
its own domain vocabulary (routes/tools reply with a pattern of status or
topic, a channel replies by being itself a channel). ports.File/
ports.Dir have no equivalent at all: file I/O errors are returned directly
to the caller, never "replied" to a remote peer, so there's no declaration
to make.
Codec-declared middleware — a cross-cutting attachment mechanism¶
The three spec-backed request/response-shaped boundaries — api/rest,
api/events, and api/reqreply — additionally share ONE
middleware.Declaration[In,Out]-based mechanism for attaching reusable,
codec-validated enrichment/enforcement concerns to an already-declared
route or channel (Transform/ClientTransform for a route/channel-bound
attachment, or .Use(mw) for a route/channel-agnostic one reused verbatim
across many declarations). It composes with, rather than replaces, the
declaration story above — you declare the route/channel/resource first,
the same way regardless, then optionally attach middleware afterward. See
Feature: Codec-Declared Middleware
for the full cross-API walkthrough.
See also¶
- API Contracts — the one-struct-one-call convenience matrix once a boundary is declared
- Codec — declare once —
the
codex.Param/MergedParam[T]/Template[T]/IdentityFieldmechanics underneath every param type in the table above - Feature: REST API · Feature: Event Channels & MQTT · Feature: MCP Server · Feature: Ports