Skip to content

Reactive Stream Pipelines — stream

See also: stream on pkg.go.dev · Stream Guide · Ports Guide · Ports Feature · Observer Pattern · Forge Pipelines

Runnable demos: - examples/stream-pipeline — all operators showcased: From, Apply, Tap, Filter, CombineLatest2, Tee, Merge, FlatMapSlice, Debounce, Throttle, Buffer, Window, MapErr, Switch, GroupBy, Topology + YAML render - examples/stream-oee — forge + stream integration: governed OEE from machine events (Window → governed forge chain → alert); governance YAML with SHA-256 hashes per function - examples/sensor-serviceflagship port showcase: one coherent use case (MQTT ingest → SQL persist → threshold alert → REST time series → REST-triggered file export) where every IO hop is a port — mqtt.SubscribeAdapter/PublishAdapter, sql.QueryEachAdapter, file.DrainWriteFileAdapter, nethttp.PipelineAdapter, nethttp.HandlerLatest — with a single shared observer

The stream package provides a declarative reactive pipeline over typed Go channels, bridging push-based transport adapters (MQTT, ZeroMQ) with governed forge.Function computations.


forge vs stream — complementary, not competing

forge/ and stream/ solve different problems:

forge/ stream/
Execution Synchronous Apply(In) → (Out, error) — pull/batch Asynchronous goroutine loops — push/reactive
Composition unit []T slices <-chan T channels (per-item, continuous)
Governance SHA-256 hash + Author/ApprovedBy — KPI audit trail None
Spec output PipelineSpec → YAML via render/pipeline TopologySpec → YAML via render/stream

They compose: stream.Apply(ctx, mqttStream, forgeFunction, opts) — the forge function's validated computation runs per-item inside the reactive pipeline.

stream/ is a top-level package (not an adapter) because it has no external library dependency — only go-codex packages and Go stdlib.


Stream[T] — explicit error channels

type Stream[T any] struct {
    Values <-chan T     // successful items
    Errors <-chan error // per-item errors — stream continues after each
}

Values and errors travel on separate channels — idiomatic Go, no interface{} boxing, no type switches. A failing item (bad sensor payload, forge validation failure) does not terminate the pipeline: it goes to Errors and the pipeline continues.

Consumers MUST drain both channels concurrently. Use Drain as the safe default sink — it handles both in a single select loop.


Operators

Sources

Operator What it does
From[T](ctx, <-chan T) Stream[T] Wraps a typed channel. Errors channel is always empty.
FromCodec[T](ctx, <-chan []byte, format.Format[T], opts) Stream[T] Decodes raw bytes using any format (JSON, YAML, TOML, custom). Decode/validation failures → StreamDecodeError on Errors.
Single[T](ctx, v T) Stream[T] Emits v once and closes. Never writes to Errors. Used as per-request pipeline entry point in PipelineHandlerFunc and AsPipelineFunc.
BroadcastHub[T] N-subscriber fan-out. NewBroadcastHub(ctx, src, bufPerSubscriber) starts the hub. Each Subscribe() returns a new Stream[T] with a private buffered channel; Unsubscribe(s) removes the subscriber. Non-blocking fan-out: slow subscribers drop items. Hub closes all subscriber channels when ctx is cancelled or src closes. Used internally by SSEFromHub and any fan-out scenario.

Transforms

Operator What it does
Apply[In,Out](ctx, Stream[In], *forge.Function[In,Out], opts) Stream[Out] Applies a forge function per-item. Validation/compute failures → StreamApplyError on Errors. Fires stats.StreamObserver.RecordStreamItem + stats.TraceObserver span per item.
Filter[T](ctx, Stream[T], pred func(T) bool) Stream[T] Drops items where pred returns false. Errors pass through.
Tap[T](ctx, Stream[T], onValue func(T)) Stream[T] Domain event observation — calls onValue without transforming. Distinct from infrastructure metrics.
Map[In,Out](ctx, Stream[In], fn func(In) (Out, error), opts) Stream[Out] Typed 1→1 transform with an error path — lighter than Apply when no forge governance is needed. Failures → StreamMapError on Errors.
MapErr[T](ctx, Stream[T], fn func(error) (T, bool, error)) Stream[T] Recover / reclassify / silence errors.
Retry[T](ctx, Stream[T], fn func(error) (T, bool, error)) Stream[T] Alias for MapErr with a descriptive name for the retry pattern.
FlatMapSlice[In,Out](ctx, Stream[In], func(In) []Out) Stream[Out] Each value maps to a slice; elements emitted individually. Empty slice = filter.

Fan-in / fan-out

Operator What it does
Merge[T](ctx, ...Stream[T]) Stream[T] Combines multiple streams into one.
Tee[T](ctx, Stream[T]) (Stream[T], Stream[T]) Splits one stream into two copies.
CombineLatest2[A,B,Out] Emits combined value whenever either source emits (after both have emitted once).
CombineLatest3[A,B,C,Out] 3-source variant — ideal for OEE (Availability × Performance × Quality).
CombineLatest4[A,B,C,D,Out] 4-source variant.
Zip[A,B,Out](ctx, Stream[A], Stream[B], func(A,B) Out) Stream[Out] Pairs items by position: (a[0],b[0]), (a[1],b[1]), ... — unlike CombineLatest, waits for matched pairs.

More than 4 sources: nest CombineLatest calls (see the stream guide).

Routing

Operator What it does
Switch[T](ctx, Stream[T], []Case[T], opts) ([]Stream[T], Stream[T]) Routes each item to the FIRST matching named case (out[i]cases[i]); non-matches and src errors go ONLY to the rest stream. Panics on malformed cases (empty/duplicate Name, nil When) — a programming error, not a runtime failure.
Case[T]{Name, When} / CaseConstraint[T](name, codex.Constraint[T]) Named predicate case; CaseConstraint reuses a codec constraint's Check as the predicate — validation vocabulary doubles as routing vocabulary.
SwitchKey[T,K](ctx, Stream[T], keys []K, keyOf func(T) K, opts) Keyed static variant — share a TaggedUnion's named discriminator function so wire format, spec, and routing use ONE declaration.
GroupBy[T,K](ctx, Stream[T], key func(T) K, onKey, opts) Dynamic per-key sub-streams; onKey(k, sub) fires once per new key on the dispatch goroutine (start consumers, don't run them inline). Blocks until src closes; sub-streams close with the parent. Keys are unbounded. Errors fan out non-blocking to all active keys.
OfType[U,T](ctx, Stream[T]) Stream[U] Typed filter over an interface (sum-typed) stream; other types dropped silently, errors forwarded. Observer from ctx, location "oftype".
SwitchType2[A,B,T] / SwitchType3[A,B,C,T] Typed multi-case routing over a sum-typed stream: typed case streams + untyped rest. First match wins; errors → rest.
SplitEither[A,B](ctx, Stream[codex.Either[A,B]], opts) (Stream[A], Stream[B]) TOTAL split of a codec-native binary union — no rest stream (closed sum); errors fan out to both branches.

Time operators

Operator What it does
Buffer[T](ctx, Stream[T], n int, maxWait) Stream[[]T] Batches up to n items or until maxWait silence. Triggered by item arrival.
Window[T](ctx, Stream[T], duration) Stream[[]T] Fixed-interval ticker; emits all items collected per window (even empty slices). Consistent time slot boundaries.
SlidingWindow[T](ctx, Stream[T], size, step int) Stream[[]T] Overlapping windows: every step items, emit last size items. step==size = tumbling.
Debounce[T](ctx, Stream[T], d) Stream[T] Emits only after silence of d — useful for sensor settling.
Throttle[T](ctx, Stream[T], interval) Stream[T] At most one item per interval.

Sinks

Operator What it does
Drain[T](ctx, Stream[T], onValue, onError, opts) Consumes both channels in a single select loop. Safe default sink.
Collect[T](ctx, Stream[T]) ([]T, []error) Accumulates all items. For bounded streams and tests.

Topology (documentation)

Type/Function What it does
Topology Declarative pipeline descriptor — NewTopology(...).WithSource().WithApply(fn).WithSink()
WithApply[In,Out](topo, fn) Free function (required for generic type params) — captures forge function hash
Topology.WithSwitch(desc) / Topology.WithGroupBy(desc) Document routing steps (StepKindSwitch / StepKindGroupBy)
TopologySpec Machine-readable pipeline description
render/stream.Render(spec) Serialises TopologySpec as YAML

Two observer kinds

Infrastructure metrics — stats.StreamObserver

type StreamObserver interface {
    RecordStreamItem(function string, success bool, dur time.Duration)
}

Type-asserted from ApplyOptions.Observer. Existing Observer implementations need not change. stats.NoopObserver, stats.LoggingObserver, and stats.NewFanout all implement it.

Domain event observation — Tap

Tap is a first-class operator for observing typed business values flowing through the pipeline — independent from infrastructure metrics:

oeeStream = stream.Tap(ctx, oeeStream, func(oee OEE) {
    slog.Info("OEE computed", "value", float64(oee))
    dashboard.Publish(oee)   // domain event — not a counter
})

Structured errors (all implement slog.LogValuer)

Type Returned by Key fields
StreamDecodeError FromCodec — payload decode/validation failure Source string, Err error
StreamApplyError Apply — forge function failure Function string, Err error (inner forge.InputError etc.)

Both implement Error(), Unwrap(), and LogValue(). Use errors.As to reach the inner forge error from a StreamApplyError:

var sae stream.StreamApplyError
if errors.As(err, &sae) {
    slog.Warn("apply failed", "error", sae) // structured: {function, err}
    var ie forge.InputError
    if errors.As(err, &ie) {
        // per-field validation detail
    }
}

Design rationale — why forge and stream are separate packages

forge/ and stream/ were deliberately kept separate after an architectural evaluation during development. The key arguments:

Concern forge/ stream/
Execution Synchronous Apply(In) → (Out, error) — pull/batch Async goroutine loops — push/reactive
Composition unit []T slices <-chan T channels (per-item, continuous)
Governance SHA-256 hash + Author/ApprovedBy — KPI audit trail None
Spec output PipelineSpec → YAML via render/pipeline TopologySpec → YAML via render/stream
Same-named ops Map/Filter over []T Filter/FlatMapSlice over <-chan T

Why not merge: 1. Different execution models — a unified type would lose either batch simplicity or reactive capability 2. Governance belongs on forge functions (signed computations), not on stream operators (Debounce, Throttle) 3. Same names, different semantics — merging would require awkward disambiguation 4. Dependency direction is one-way and correct: stream imports forge; the reverse would create a circular dependency

The correct conceptual model:

codex/      Layer 1 — validated domain types
forge/      Layer 3 — governed synchronous computation + KPI spec
stream/     Layer 4 — reactive execution of forge functions over event streams
adapters/   Transport bridges (MQTT, ZeroMQ) supply source channels to stream/
  • forge/ = "what the computation is" (declarative, governed, signed)
  • stream/ = "how computation runs continuously over time" (reactive, async)

They compose: stream.Apply(ctx, mqttStream, forgeFunction, opts) — the forge function's governed computation runs per-item inside the reactive pipeline.


Connecting streams to transports — the ports package

Rather than importing a transport package directly into pipeline code, go-codex wires streams to the outside world through ports — a protocol-agnostic binding layer. A pipeline declares a typed port; the transport is bound separately (typically in main.go), so swapping MQTT for HTTP never touches domain logic.

Full patterns and adapter catalogue: Ports Guide · Ports Feature

stream.Single[T] — one-shot source

s := stream.Single(ctx, req)    // emits req once, closes

Used as the entry point for per-request pipelines inside a ports.ToolPort pipeline function, or any AsPipelineFunc-wrapped handler.

The four port types, in one line each

Port Direction Example adapters
ports.SourcePort[T] external → stream (fan-in) mqtt5.SubscribeAdapter, nethttp.IngestAdapter, sql.QueryAdapter, file.ScanAdapter/WatchAdapter
ports.SinkPort[T] stream → external (fan-out) mqtt5.PublishAdapter, nethttp.SSEAdapter, sql.DrainInsertAdapter, file.DrainWriteAdapter
ports.IOPort[Req,Resp] stream ↔ external (1 adapter) nethttp.CallAdapter, mqtt5.CallAdapter, sql.QueryEachAdapter, file.ReadEachAdapter
ports.ToolPort[In,Out] external request → pipeline → response (N adapters) mcpgo.ToolPipelineAdapter, nethttp.PipelineAdapter, zeromq.ServeAdapter, mqtt5.ServeAdapter
// domain/pipeline.go — no adapter imports
var SensorReadings = ports.NewSourcePort[SensorReading]("sensor-readings", sensorCodec, ports.PortOptions{})
var OEEResults = ports.NewSinkPort[OEE]("oee-results", oeeCodec, ports.PortOptions{})

func StartPipeline(ctx context.Context) {
    sensors := SensorReadings.Stream(ctx)
    oeeStream := stream.Apply(ctx, sensors, oeeCalcFn, stream.ApplyOptions{})
    go OEEResults.Feed(ctx, oeeStream)
}

// main.go — bind concrete transports here
domain.SensorReadings.Bind(ctx, mqtt5.SubscribeAdapter(client, router, sensorHandle, 0, format.JSON(sensorCodec), opts))
domain.OEEResults.Bind(ctx, mqtt5.PublishAdapter(client, alertHandle, format.JSON(oeeCodec), publishOpts))
domain.OEEResults.Bind(ctx, nethttp.SSEAdapter(mux, sseHandle, nethttp.SSEAdapterOptions{})) // fan-out

Request/response pipelines with ToolPort

ToolPort[In,Out] sets the pipeline function once and can bind it to multiple transports simultaneously — the same domain logic serves MCP, HTTP, and ZeroMQ:

var OEETool = ports.NewToolPort[OEEIn, OEEResult]("oee-calc", oeeInCodec, oeeCodec, ports.PortOptions{})

func init() {
    OEETool.SetPipeline(func(ctx context.Context, req OEEIn) stream.Stream[OEEResult] {
        s := stream.Single(ctx, req)
        s = stream.Apply(ctx, s, validateFn, stream.ApplyOptions{})
        s = stream.Tap(ctx, s, func(v ValidatedReq) { slog.Info("validated", "id", v.ID) })
        return stream.Apply(ctx, s, oeeCalcFn, stream.ApplyOptions{})
    })
}

domain.OEETool.Bind(ctx, mcpgo.ToolPipelineAdapter(mcpServer, mcpToolHandle, mcpgo.Options{}))
domain.OEETool.Bind(ctx, nethttp.PipelineAdapter(mux, httpHandle, nethttp.PipelineAdapterOptions{}))
domain.OEETool.Bind(ctx, zeromq.ServeAdapter(repSock, zmqHandle, zeromq.ServeOptions{}))

New error types: ports.PortBindError{Port,Adapter,Err}, ports.PortNoAdapterError{Port}, ports.PortNoPipelineError{Port} — all implement slog.LogValuer. Transport-level errors (NoLatestValueError, PipelineFullError, SSEWriteError, …) are unchanged and still surface through the same adapters.


I/O role taxonomy — source, intermediate, sink

Every I/O operation in a stream pipeline has a role. Recognising the role guides which API to use:

Role Character API pattern Examples
Source Emits items FROM an external system into the stream ports.SourcePort[T] + transport.XxxAdapter (fan-in) MQTT messages, SQL rows, HTTP ingest, NDJSON file
Intermediate (transform) Sends each item AS a request; receives a response; emits the response ports.IOPort[Req,Resp] + transport.CallAdapter/QueryEachAdapter/ReadEachAdapter ZeroMQ REQ/REP, MQTT5 request-reply, HTTP call, per-item SQL/file lookup
Side-effect I/O Fires I/O alongside the pipeline WITHOUT consuming a response stream.Tap + Publish/Write/Patch MQTT alert publish, JSON file write, audit log
Sink Consumes items FROM the stream into an external system ports.SinkPort[T] + transport.XxxAdapter (fan-out) MQTT publish, SQL insert, HTTP SSE/POST, NDJSON append
Request/response External request triggers the pipeline; a response is returned ports.ToolPort[In,Out] + transport.XxxAdapter (N transports) MCP tool call, HTTP endpoint, ZeroMQ REP, MQTT5 request-reply server
Pure computation Transforms items with zero I/O stream.Apply + forge.Function[In,Out] OEE calculation, normalisation, validation

The fundamental rule: forge functions are pure — zero I/O

stream.Apply accepts *forge.Function[In, Out]. These functions are governed domain computations — their only job is to transform typed inputs into typed outputs. They must have no I/O.

// ✅ Correct: forge function is pure
oeeCalcFn := forge.NewFunction("oeeCalc", "1.0.0", oeeInCodec, oeeCodec,
    func(in OEEIn) (OEE, error) {
        // Pure calculation — no file reads, no HTTP calls, no database queries
        return OEE(float64(in.Availability) * float64(in.Performance) * float64(in.Quality)), nil
    })

// ❌ Wrong: I/O inside a forge function violates the design principle
// func(in InputData) (Out, error) {
//     cfg, _ := configFile.Read(...)  // ← I/O inside forge
//     return combine(in, cfg), nil
// }

I/O that a forge function needs (e.g. a config lookup) must arrive as typed input — declared in the input codec and flowing from the stream layer:

// Config is an explicit input — loaded by the stream layer, not by the function:
type EnrichInput struct {
    Data   InputData
    Config ThresholdConfig   // explicit input — arrives via CombineLatest2
}

enrichFn := forge.NewFunction("enrich", "1.0.0", enrichInputCodec, outputCodec,
    func(in EnrichInput) (Out, error) {
        return combine(in.Data, in.Config), nil // pure: all inputs explicit
    })

// I/O stays in the stream layer:
configs := stream.Single(ctx, preloadedConfig)  // or a SourcePort bound to file.WatchAdapter for dynamic reload
combined := stream.CombineLatest2(ctx, dataStream, configs,
    func(d InputData, c ThresholdConfig) EnrichInput { return EnrichInput{d, c} })
enriched := stream.Apply(ctx, combined, enrichFn, stream.ApplyOptions{})

When to use each I/O container

Use ports.IOPort[Req,Resp] when the step's whole purpose is an I/O call — send a request, receive a response, forward the response. Bind exactly one adapter; swapping the adapter (HTTP → SQL → file) never touches the pipeline:

var Enrichment = ports.NewIOPort[Raw, Enriched]("enrichment", rawCodec, enrichedCodec, ports.PortOptions{})

// main.go — pick one:
domain.Enrichment.Bind(ctx, zeromq.CallAdapter(sock, enrichHandle, zeromq.CallStreamOptions{}))
// domain.Enrichment.Bind(ctx, mqtt5.CallAdapter(client, router, enrichHandle, mqtt5.CallOptions{}))
// domain.Enrichment.Bind(ctx, nethttp.CallAdapter(httpClient, baseURL, enrichHandle, nethttp.CallStreamOptions{}))
// domain.Enrichment.Bind(ctx, sql.QueryEachAdapter(enrichedCodec, queryFn, sql.QueryEachStreamOptions{}))

enriched := domain.Enrichment.Connect(ctx, rawStream)

I/O is explicit in the pipeline declaration. Errors are transport-typed, not wrapped in forge.ApplyError.

Use stream.Tap for side-effect I/O that produces no value for the pipeline — publishing, writing, logging:

results = stream.Tap(ctx, results, func(r OEEResult) {
    mqtt.Publish(ctx, client, alertHandle, 0, false, r, nil, mqtt.PublishOptions{}) // fire-and-forget
    resultFile.Write(nil, r, ports.FileOptions{})                                 // whole-file write
})

Use stream.FlatMapSlice for ad-hoc transforms that have I/O and are not governed (no forge hash, no schema validation — use when governance is not required):

// I/O in stream.FlatMapSlice is in the stream layer, not forge:
enriched := stream.FlatMapSlice(ctx, ids, func(id string) []Data {
    v, err := someFile.Read(map[string]string{"id": id}, opts)
    if err != nil { return nil } // errors silently dropped — use ports.IOPort for typed errors
    return []Data{v}
})

All transports have an intermediate transform operator

Every transport with a request/response protocol provides an IOAdapter for ports.IOPort — HTTP, ZeroMQ, MQTT5, SQL, and File all support per-item enrichment:

zeromq.CallAdapter      ✅  — send each item as REQ, emit each REP response
mqtt5.CallAdapter       ✅  — send each item as MQTT5 request-reply, emit responses
nethttp.CallAdapter     ✅  — full HTTP codec machinery (path vars, query/cookie/header
                              params, security, structured errors) per item
sql.QueryEachAdapter    ✅  — parameterized SQL query per item (1:N rows)
file.ReadEachAdapter    ✅  — per-item file read with path template vars

See the Ports Guide for the complete adapter catalogue.