Ports, Plugins, and Adapters¶
See also:
portspackage on pkg.go.dev · Ports feature page · Wiring Guide · Forge Pipelines conceptRunnable demos:
examples/ports-plain-go(idiomatic Go, zero pipelines) ·examples/sensor-service(forge pipelines)
Every go-codex application answers the same question at its boundary: how do I
talk to the outside world without letting a transport leak into my business
logic? ports is the answer, and it works the same way whether your business
logic is a forge pipeline or a plain Go function.
Three roles, one mechanism¶
| Role | What it is | Where it lives |
|---|---|---|
| Port | A typed, protocol-agnostic declaration of an IO boundary (SourcePort[T], SinkPort[T], IOPort[Req,Resp], ToolPort[In,Out], LatestPort[T], DuplexPort[In,Out]) |
domain/pipeline package — zero adapter imports |
| Plugin | A Pattern (RESTPattern, EventPattern, ReqReplyPattern, MCPPattern, ...) registered on a port via PluginXxxPattern, returning the typed handle the adapter needs |
main.go — the only place that knows the wire shape |
| Adapter | A concrete transport implementation (mqtt5.SubscribeAdapter, nethttp.CallAdapter, sql.QueryEachAdapter, ...) bound to the port via Bind |
main.go — the only place that knows the concrete broker/client |
The sequence is always declare → plug in → bind:
// 1. Declare — domain/pipeline.go, no adapter imports
var Calibration = codex.Must(ports.NewIOPort[SensorReading, CalibratedReading](
"calibration", ReadingCodec, calibratedCodec, ports.PortOptions{}))
// 2. Plug in — main.go, describes the wire shape and returns a typed handle
handle, err := Calibration.PluginRESTPattern(ports.RESTPattern{
Method: "GET", Path: "/calibration/{sensorID}",
})
// 3. Bind — main.go, picks the concrete transport
Calibration.Bind(ctx, nethttp.CallAdapter(httpClient, baseURL, handle, callOpts))
Swap step 3 for sql.QueryEachAdapter, file.ReadEachAdapter, or
zeromq.CallAdapter and steps 1–2 never change. This is the same promise
codex.Codec[T] makes for data shape, extended to IO boundaries.
The question this page answers: pipelines or plain Go?¶
A common misreading of ports is that it exists for forge pipelines, and
that using it without pipelines means falling back to calling an adapter
constructor directly. That is not the design. declare → PluginXxxPattern
→ Bind is identical regardless of what happens after Bind — only the
consumption of the bound port differs, and every port type has both styles
built in:
| Port | Pipeline-composed consumption | Plain-Go consumption |
|---|---|---|
SourcePort[T] |
Stream(ctx) piped into gstream.Apply |
Stream(ctx) drained with stream.Drain and a plain callback |
SinkPort[T] |
Feed(ctx, stream) from an upstream gstream.Stream |
Start(ctx) / Push(ctx, v) / Close() from a plain goroutine or loop |
IOPort[Req,Resp] |
Connect(ctx, stream) — streams requests through the adapter |
Call(ctx, req) — one request in, one response out |
ToolPort[In,Out] |
SetPipeline(func(ctx, In) gstream.Stream[Out]) |
SetFunc(func(ctx, In) (Out, error)) |
LatestPort[T] |
Feed(ctx, stream) populates the cache |
Latest() reads the cache directly |
DuplexPort[In,Out] |
Feed(ctx, stream) drives outbound frames |
Inbound(ctx) + plain per-session handling |
Both columns call the same bound adapter, decode with the same codec,
and were built from the same Pattern-derived handle. SetFunc and
Call are not simplified re-implementations of SetPipeline/Connect — the
plain-Go path still goes through the adapter's real Transform/pipeline
function; SetFunc wraps your function once so it satisfies the same
internal contract as a pipeline function would, and Call runs a
single-item stream (stream.Single → stream.Collect) under the hood.
Which should a user choose?¶
Neither is "more advanced." Choose based on what the port's business logic
naturally looks like, not on whether the application uses forge elsewhere:
- Plain Go (
SetFunc,Call,stream.Drain,Start/Push/Close) — when the logic is a straightforwardfunc(In) (Out, error)transform, a one-off request, or an imperative loop. Noforge/gstreamimport needed at all. Seeexamples/ports-plain-go. - Forge pipelines (
SetPipeline,Connect,gstream.Apply,Feed) — when the logic benefits from composition (Map/Filter/CombineLatest/ windowing), needsforge.Registrygovernance (named, versioned functions with observability), or fans multiple sources/sinks together viaPipePort. Seeexamples/sensor-service.
An application can freely mix both — one ToolPort using SetFunc for a
simple unit conversion, another using SetPipeline because it composes three
pipeline stages — without the Pattern/Bind declarations looking any
different from one another.
Why this matters for migration¶
Because the declaration never changes, moving a port from plain Go to a forge
pipeline (or back) is a local, mechanical edit — replace SetFunc with
SetPipeline (or stream.Drain with gstream.Apply), leave the Pattern,
handle, and Bind call untouched. A user who starts with idiomatic Go and
later needs pipeline composition for one port never has to re-learn how
ports works, and never has to touch main.go's wiring code to make that
switch.
Relationship to adapters used directly¶
Every adapter package (adapters/nethttp, adapters/mqtt5,
adapters/sql, ...) also works WITHOUT any ports involvement at all — this
is unchanged and remains the right choice for one-off scripts or code that
has no reason to declare a reusable boundary. ports is for applications
that want the boundary declared once, independent of the transport choice,
and swappable in one place. Reaching for ports does not require adopting
forge — see the table above.
Used directly, an adapter is STILL never a raw bypass of api/rest/
api/events's own vocabulary — this is an architectural constraint, not a
style preference. Every adapter entry point is one of exactly three shapes:
- A
ClientTransport/ServerTransport(REST) orTransport(events) implementation, attached viarest.Client.Attach/rest.Server.Attach/events.Client.Attach— e.g.nethttp.NewClientTransport/nethttp.NewServerTransport,chi.NewServerTransport,mqtt5.NewTransport,mqtt.NewTransport,zeromq.NewTransport. This is the declarative, common-case path:client.Call(...)/client.Consume(...)for REST,client.Publish(...)/client.Subscribe(...)for events. - A
ports.SourceAdapter/SinkAdapter/IOAdapter/etc. implementation, bound viaPort.Bind— the pipeline path this page is about. - A SANCTIONED, handle-based escape hatch — for callers who need a
pre-built
*rest.RouteHandle/*rest.SSERouteHandle/*events.ChannelHandledirectly rather than arest.Route/rest.SSERoute/events.Channelvalue, or who need capability the declarative path doesn't expose (e.g. per-call retry/backoff control).rest.CallWithTransport,nethttp.CallSSEAdapter, andmqtt5/mqtt/zeromq'sNewPublishTransport/NewSubscribeTransport(paired withevents.PublishHandle/SubscribeHandle) are the shipped examples. Every one of these STILL takes a handle built byapi/rest/api/events— none of them decode/encode/dispatch independently of it.
There is no fourth shape: no adapter exposes a bare, non-handle-based
Publish/Subscribe/Call/Serve function that bypasses RouteHandle/
ChannelHandle entirely (an earlier design did — nethttp.Call[Req,Resp],
removed per docs/design/d-0002-pubsub-workflow-simplification.md's
Decision 6 — the unification onto Client/ClientTransport is what
replaced it). A NEW adapter must follow the same rule: see the
add-a-new-adapter skill's own architectural-boundary rule for the concrete
checklist.
Convenience helpers belong in api/*, not adapters¶
This section restates, in a different call-site, the SAME architectural
promise this whole page opens with: a user works ENTIRELY in the api/*/
ports abstraction — declaring routes, channels, patterns, ports — and
ATTACHES an adapter only to supply the concrete IO implementation for a
protocol already chosen. Client.Attach/Server.Attach, Port.Bind, and
PluginXxxPattern are all instances of the same idea: the adapter is a
plug-in, never a second vocabulary the user has to learn on top of api/*.
If a helper function's name/behavior changes depending on which adapter
package it happens to live in — nethttp.ErrorPatternAs vs
mqtt5.ErrorPatternAs vs zeromq.ErrorPatternAs, say — that is the
abstraction leaking: the user is no longer working with ONE api/*
vocabulary, they're working with N adapter-flavored variants of it, and
switching adapters silently changes which function names they must use.
That is precisely the failure mode this rule exists to prevent.
A DIFFERENT (but related) mistake from the bypass rule above: a helper function that a CALLER uses AFTER a decode/encode/dispatch has already happened — one that never itself decodes, encodes, or dispatches anything — can still end up misplaced in an adapter package by accident. The rule:
Adapters implement wire protocols only. Any user-facing convenience or ergonomic helper that touches ONLY core
api/*types — codecs, handles, declared patterns, or a core-layer interface — belongs inapi/*, never inadapters/*. This holds EVEN WHEN, at the time of writing, only one adapter happens to implement that boundary — "only one adapter exists today" is not a justification for placing transport-independent logic inside that one adapter.
The concrete test: could this function be written using ONLY
errors.As/a core-layer interface, with ZERO reference to any
adapter-specific type? If yes, it does not belong in adapters/*.
A real, fixed example — rest.ErrorPatternAs[B]/HandleErrorPattern/
Case and reqreply.ErrorPatternAs[B]/HandleErrorPattern/Case. These
extract a matched ErrorPattern's typed payload from a client-side call
error — they touch ONLY the core ErrorPatternValuer interface (the SAME
interface ErrorPatternOpt.Match already correctly used from api/rest/
api/reqreply). They were originally placed in adapters/nethttp,
adapters/mqtt5, and adapters/zeromq instead — for reqreply, this meant
adapters/mqtt5 and adapters/zeromq carried BYTE-FOR-BYTE IDENTICAL
copies of the same 3 functions, a duplication that immediately exposed the
misplacement once someone asked "why do I need the adapter package just to
match an error?" Fixed by moving all of them into api/rest/api/reqreply
— one implementation each, reused transparently by every adapter that
implements the boundary's ErrorPatternValuer interface. See
docs/design/d-0005-error-handling.md's Topic 6 "Design
guardrail" subsection for the full narrative and the corrected design
reasoning.
Why this is distinct from the "no adapter-invented bypass" rule above: that rule governs whether an adapter's OWN decode/encode/dispatch path can skip the handle (it never may). This rule governs where a POST-RESPONSE convenience helper — one that runs entirely AFTER decode/encode/dispatch already happened, using only the result — should live. Both rules point the same direction (adapters carry ONLY protocol-specific logic), but they guard different call sites and are easy to satisfy one of while missing the other.
The user-experience promise this protects: a caller should be able to
declare and consume a communication pattern using ONLY the api/*
abstraction — attaching a specific adapter is purely a protocol-selection
decision, never something that changes which helper functions/vocabulary
the caller reaches for.
Guardrail: adapters as pure protocol shims¶
Both rules above are about where LOGIC lives (never adapter-invented, never a bypass). This section states the standing principle those rules serve, precisely:
Adapters are thin protocol shims: wrap exactly one external SDK, contain zero declarative/business logic, and expose any adapter-specific extension (MQTT QoS, User Properties, ...) as a SEALED, adapter-owned
Capabilityinterface supplied at DECLARE time (via that adapter's ownSubscribeOptions/PublishOptions.Capabilitiesfield) — never baked into anapi/*declaration. All declaration, validation, and dispatch logic lives inapi/*/ports. A user's entire vocabulary isapi/*/ports: declare, compose, attach — the adapter package is touched only toAttach/Bindand supply itsOptions/Capabilityvalues, never as a second business-logic API.
Already shipped vs. genuinely new. Most of this guardrail is not
new — it restates the two rules already established above in this page
("Relationship to adapters used directly" and "Convenience helpers
belong in api/*, not adapters"), and their add-a-new-adapter/SKILL.md
mandatory counterparts (Step 5c, Step 5d). The sealed, per-adapter
Capability interface has SHIPPED (see
docs/design/d-0006-protocol-native-capabilities.md):
one unexported marker method PER adapter package (adapters/mqtt,
adapters/mqtt5, adapters/zeromq each define their OWN Capability
interface), mirroring ports.Pattern's own sealing technique. A
capability value is supplied via a Capabilities []<pkg>.Capability
field on that adapter's EXISTING SubscribeOptions/PublishOptions
struct (attached via events.Subscriber.WithOptions/
events.Publisher.WithOptions — NOT a new Attach-time parameter), so
a value from the wrong adapter package is a Go COMPILE error rather than
a silent no-op or a runtime check.
If a new adapter needs a protocol-specific toggle/option with no
cross-protocol meaning: define a sealed Capability type in that
adapter's OWN package (mirroring mqtt5.QoS/zeromq.HWM) and a
Capabilities []<pkg>.Capability field on its SubscribeOptions/
PublishOptions — do NOT add the option to the owning api/* package's
declaration type (events.Channel, rest.Route, etc.), even as a "just
this one field" convenience. The former api/events/mqtt_qos.go
(MQTTQoS/Subscribe.QoS) legacy field path — originally kept
ADDITIVE alongside the Capability mechanism — was later found to be a
genuine backdoor (it bypassed Capability/Apply entirely) and was
DELETED entirely, closing that gap; see
D-0006's "Design
goal: zero backdoor" section. There is no legacy path anymore — new
protocol-specific toggles use ONLY the Capability mechanism.
Guardrail: shared cross-pattern MECHANICS belong in internal/, not a public package¶
The two guardrails above are about keeping a USER's entire vocabulary
inside api/*/ports. This one is the mirror-image rule for go-codex's
OWN maintainers: a mechanism genuinely SHARED across 2+ of api/rest/
api/events/api/reqreply (or a future ports pattern) that is pure
MECHANICS — not a single adapter's protocol IO — belongs in a
repo-root internal/ package, never a public top-level one, with each
pattern package exposing its own thin, same-named public wrapper
(type aliases + forwarding constructors) around it.
Why
internal/, not just a docs convention: Go's own compiler enforces "importable only by code rooted at the parent ofinternal/" — here, that parent is the whole go-codex module, so every in-module consumer (api/*,adapters/*,ports,render/*) keeps importing it unchanged, while an EXTERNAL user who imports go-codex as a dependency cannot reach it at all. This is strictly stronger than "the docs say don't import this directly" — exactly the same reasoning this page's other two guardrails rely on docs/convention for, applied where the compiler can do the enforcement instead.
Shipped reference implementation (docs/design/d-0009-internalize-shared-mechanics.md):
internal/router (Router/Mount/Group/Walk mechanics), internal/route
(HTTP/pub-sub-agnostic route + security-scheme descriptors), and
internal/middleware (declarative, composable enrichment/enforcement
vocabulary) were all relocated from public top-level packages (router/
route/middleware) under internal/. Each pattern package now
exposes the identical vocabulary under its own name — e.g.
rest.BearerScheme/events.BearerScheme/reqreply.BearerScheme each
thinly wrap internal/route.BearerScheme; rest.SharedMiddleware/
events.SharedMiddleware/reqreply.SharedMiddleware are IDENTICAL type
aliases to internal/middleware.Middleware (a type alias is still ONE
type at the compiler level, so a value built via any one pattern's
wrapper remains attachable to any of the 3 patterns' .Use() — the
entire reason this type is shared rather than per-pattern).
The one documented exception: a pattern-AGNOSTIC concept with no
natural per-pattern home that is ALSO referenced by a PUBLIC,
user-implementable extensibility interface does NOT get the internal/
treatment — it is relocated to the package OWNING that public interface
instead. Disposition/ResolveDisposition moved from middleware into
stats (not into internal/middleware) for exactly this reason: an
external user implementing stats.DispositionObserver must be able to
even spell the Disposition type in their own method signature, which
an internal/ type would make impossible.
When adding a new cross-pattern mechanic, ask: "could an external
go-codex user ever construct/import this type directly, bypassing
api/*/ports?" If the honest answer is "they shouldn't need to, and
doing so would only let them bypass our own constructors," it belongs in
internal/, with a per-pattern wrapper — not a new public top-level
package.
Interface inventory: what adapters implement against¶
A durable reference for "does adapter-side function X implement a real,
declared interface, or is it an ad-hoc function with no interface contract
at all?" — a recurring, easy-to-miss distinction (adapters/mqtt5.
NewServerTransport(opts) reqreply.ServerTransport/adapters/nethttp.
NewClientTransport(opts) rest.ClientTransport satisfy REAL, declared,
api-layer-owned interfaces, but plenty of other adapter-exported functions
are exported, documented, real API surface that don't implement any
declared interface at all). This is a living section — update it
whenever a new adapter ships or a new interface is introduced.
Available interfaces adapters implement against¶
| Package | Interface | Implemented by |
|---|---|---|
api/rest |
ServerTransport/ServerAwareTransport |
nethttp.serverTransport, chi.serverTransport |
api/rest |
ClientTransport |
nethttp.clientTransport only (chi is server-only) |
api/rest |
HeaderCapableTransport/CookieCapableTransport/QueryCapableTransport |
nethttp.httpCarrier, chi.httpCarrier, websocket.wsCarrier |
api/rest |
ErrorResponseWriter |
nethttp/chi's *statusResponseWriter |
api/rest |
ErrorPatternValuer |
nethttp.ErrorPatternResponse |
api/events |
Transport/ClientAwareTransport |
mqtt5, mqtt (v3), zeromq |
api/events |
PublishTransport[T]/SubscribeTransport[T] |
mqtt5, mqtt, zeromq |
api/reqreply |
ServerTransport/ServerAwareTransport/ClientTransport |
mqtt5, zeromq |
api/reqreply |
ErrorPatternValuer |
mqtt5.ErrorPatternResponse, zeromq.ErrorPatternResponse |
| all 3 | CapabilityName/LeveledCapability |
mqtt5.QoS/Retained, zeromq.HWM/Conflate, mqtt.QoS/Retained |
reqreply.Topical and RouteOpt/ChannelOpt are NOT adapter-facing at
all — Topical is implemented by *RouteHandle itself (api-layer-internal),
and RouteOpt/ChannelOpt are implemented by USER-declared param types,
not adapters — both listed here only to confirm they were checked, not
omitted by oversight.
Missing interfaces / gaps, and why they're not (yet) closed¶
- REST supplies no native Tier-3a
CapabilityVALUE of its own (RequireQoS/RequireHWMare REST-side requirement declarations, but no adapter can satisfy them — HTTP genuinely has no QoS/HWM concept). This is correct-as-is by design: "adapter doesn't support this capability" is the intended outcome, not a gap. events.Address/the fullChannel[Addr,T]retrofit remains unshipped — a real gap, but deliberately deferred until a real Address-needing adapter (AMQP) exists; not actionable today.- reqreply's server-side
ErrorPatternWRITE step (after the already centralizedObserveErrorResponseFormatch) has noErrorResponseWriter-equivalent interface —mqtt5/zeromqrealize it via inline/free-function reply-publish logic instead. The two adapters' protocol shapes (declared-topic reply vs. broadcast) were evaluated and found too divergent to unify profitably for just 2 implementers — a narrower, per-pattern interface (e.g.ReplyPublisher interface { PublishReply(topic string, body []byte) error }) could be designed in a future session if this becomes a maintenance burden, but is not an obvious net win today.
Interactions deliberately NOT interface-implemented, with reasons¶
- Observer outcome-recording (
RecordRequest/RecordPublish/RecordSubscribe) — only the adapter's own transport dispatch has the real per-protocol status/duration data; documented adapter-owned-by-design indocs/features/observer.md. - Capability VALUES themselves (
mqtt5.QoS,zeromq.HWM, etc.) — D-0006's original, never-reopened rejection of a shared value type: only theCapabilityName/LeveledCapability/ApplyINTERFACES are shared, never the concrete sealed values. - Adapter
Optionsstructs (IngestSocketAdapterOptions,PublishOptions,ServerTransportOptions, etc.) — deliberately concrete, never interfaces: these are CONSTRUCTOR-TIME configuration, not a dispatch contract, and forcing a shared field set across adapters with zero configuration overlap would defeat the purpose of adapter-specific tuning. - reqreply/events' server-side
ErrorPatternwire-write — see "Missing interfaces" item 3 above. - A small set of genuinely free-standing exported helper functions per
adapter (confirmed via a full exported-function sweep across all 6
adapters, finding no additional gaps beyond the items above):
Connect/NewSecuredClient(connection establishment),MessageFromContext/RequestFromContext/ResponseCookiesFromContext/ResponseHeadersFromContext(ctx accessors),TopicVarsFromMessage(mqtt/mqtt5/zeromq— the inverse ofevents.ChannelHandle.BuildTopic, for a caller extracting topic vars outside the standard subscribe dispatch path),NewCachingCredentialFunc(credential-caching utility),NewHub/NewDialer/NewUpgrader(websocket connection-management constructors) — none of these are escape-hatch-style dispatch functions competing with an interface; each is connection setup, ctx plumbing, or declaration sugar, genuinely outside any interface's scope.