Skip to content

Ports, Plugins, and Adapters

See also: ports package on pkg.go.dev · Ports feature page · Wiring Guide · Forge Pipelines concept

Runnable 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 straightforward func(In) (Out, error) transform, a one-off request, or an imperative loop. No forge/gstream import needed at all. See examples/ports-plain-go.
  • Forge pipelines (SetPipeline, Connect, gstream.Apply, Feed) — when the logic benefits from composition (Map/Filter/CombineLatest/ windowing), needs forge.Registry governance (named, versioned functions with observability), or fans multiple sources/sinks together via PipePort. See examples/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:

  1. A ClientTransport/ServerTransport (REST) or Transport (events) implementation, attached via rest.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.
  2. A ports.SourceAdapter/SinkAdapter/IOAdapter/etc. implementation, bound via Port.Bind — the pipeline path this page is about.
  3. A SANCTIONED, handle-based escape hatch — for callers who need a pre-built *rest.RouteHandle/*rest.SSERouteHandle/*events.ChannelHandle directly rather than a rest.Route/rest.SSERoute/events.Channel value, or who need capability the declarative path doesn't expose (e.g. per-call retry/backoff control). rest.CallWithTransport, nethttp.CallSSEAdapter, and mqtt5/mqtt/zeromq's NewPublishTransport/NewSubscribeTransport (paired with events.PublishHandle/SubscribeHandle) are the shipped examples. Every one of these STILL takes a handle built by api/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 in api/*, never in adapters/*. 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 Capability interface supplied at DECLARE time (via that adapter's own SubscribeOptions/PublishOptions.Capabilities field) — never baked into an api/* declaration. All declaration, validation, and dispatch logic lives in api/*/ports. A user's entire vocabulary is api/*/ports: declare, compose, attach — the adapter package is touched only to Attach/Bind and supply its Options/Capability values, 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 of internal/" — 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

  1. REST supplies no native Tier-3a Capability VALUE of its own (RequireQoS/RequireHWM are 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.
  2. events.Address/the full Channel[Addr,T] retrofit remains unshipped — a real gap, but deliberately deferred until a real Address-needing adapter (AMQP) exists; not actionable today.
  3. reqreply's server-side ErrorPattern WRITE step (after the already centralized ObserveErrorResponseFor match) has no ErrorResponseWriter-equivalent interface — mqtt5/zeromq realize 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

  1. 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 in docs/features/observer.md.
  2. Capability VALUES themselves (mqtt5.QoS, zeromq.HWM, etc.) — D-0006's original, never-reopened rejection of a shared value type: only the CapabilityName/LeveledCapability/Apply INTERFACES are shared, never the concrete sealed values.
  3. Adapter Options structs (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.
  4. reqreply/events' server-side ErrorPattern wire-write — see "Missing interfaces" item 3 above.
  5. 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 of events.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.