Forge Pipelines¶
See also:
forgepackage on pkg.go.dev ·render/pipelineon pkg.go.devRunnable demos:
examples/forge-oee·examples/forge-collection·examples/oee-chain
forge is the third layer of go-codex. It adds named, versioned, and governance-tracked computation on top of the validated domain types from Layer 1 and the event/REST channels from Layer 2.
Programming model: inside-out development¶
go-codex is designed for inside-out development: you start from the domain and work outward to the application boundary, not the other way around.
Step 1 — Domain core (inside, zero adapter imports)
codex.Codec[T] ← validated domain types
forge.NewFunction[In, Out](...) ← governed pure computation
ports.NewSourcePort / SinkPort / ← protocol-agnostic IO enforcement points
IOPort / ToolPort
Step 2 — Application boundary (outside, main.go only)
domain.SomePort.Bind(ctx, transport.SomeAdapter(...)) ← wire to a concrete transport
Why this order matters:
The domain core is transport-independent. Ports are declared with a codec and a
name — no transport import required. The same pipeline can be bound to MQTT messages,
HTTP requests, ZeroMQ frames, or a plain Go channel in a test — without changing a
single line of domain logic. See ports package documentation
for the complete API.
// domain/pipeline.go — zero adapter imports
var oeeCalcFn = forge.NewFunction("oeeCalc", "1.0.0", oeeInCodec, oeeCodec,
func(in OEEIn) (OEE, error) {
return OEE(float64(in.Availability) * float64(in.Performance) * float64(in.Quality)), nil
},
)
var SensorReadings = codex.Must(ports.NewSourcePort[OEEIn]("sensor-readings", oeeInCodec, ports.PortOptions{}))
var OEEResults = codex.Must(ports.NewSinkPort[OEE]("oee-results", oeeCodec, ports.PortOptions{}))
func StartPipeline(ctx context.Context) {
sensors := SensorReadings.Stream(ctx)
oeeStream := gstream.Apply(ctx, sensors, oeeCalcFn, gstream.ApplyOptions{})
go OEEResults.Feed(ctx, oeeStream)
}
// main.go — all transport decisions here
// Wire A: MQTT source → domain pipeline → MQTT sink
domain.SensorReadings.Bind(ctx, mqtt5.SubscribeAdapter(client, router, sensorHandle, 0, fmt, opts))
domain.OEEResults.Bind(ctx, mqtt5.PublishAdapter(client, alertHandle, fmt, publishOpts))
// Wire B: HTTP trigger → domain pipeline → HTTP response (ToolPort, request/response)
var OEETool = codex.Must(ports.NewToolPort[OEEIn, OEE]("oee-tool", oeeInCodec, oeeCodec, ports.PortOptions{}))
OEETool.SetPipeline(func(ctx context.Context, req OEEIn) gstream.Stream[OEE] {
return gstream.Apply(ctx, gstream.Single(ctx, req), oeeCalcFn, gstream.ApplyOptions{})
})
domain.OEETool.Bind(ctx, nethttp.PipelineAdapter(mux, httpHandle, nethttp.PipelineAdapterOptions{}))
// Wire C: test — plain Go channel, no transport at all
ch := make(chan OEEIn, 1)
ch <- OEEIn{Availability: 0.9, Performance: 0.85, Quality: 0.95}
close(ch)
domain.SensorReadings.Bind(ctx, ports.ChanSourceAdapter(ch))
out := make(chan OEE, 1)
domain.OEEResults.Bind(ctx, ports.ChanSinkAdapter(out))
The application boundary is where the domain meets the outside world. go-codex ports define this boundary using four positions:
| Position | Declarative pattern | Direction |
|---|---|---|
| Source | ports.NewSourcePort[T](...) + .Bind(ctx, transport.XxxAdapter(...)) |
External world → domain pipeline (fan-in: multiple adapters merge) |
| Sink | ports.NewSinkPort[T](...) + .Bind(ctx, transport.XxxAdapter(...)) |
Domain pipeline → external world (fan-out: broadcast to all adapters) |
| Intermediate I/O | ports.NewIOPort[Req,Resp](...) + .Bind(ctx, transport.XxxAdapter(...)) |
Domain pipeline ↔ external service/store (exactly one adapter) |
| Tool (request/response) | ports.NewToolPort[In,Out](...) + .SetPipeline(fn) + .Bind(ctx, transport.XxxAdapter(...)) |
External request → pipeline → response; same pipeline can serve MCP + HTTP + ZeroMQ simultaneously |
Each port carries the payload codec (and optional IOParam routing parameters). Adapters
map the port to protocol-specific concerns (topic, path, security) at Bind time. The
adapter handles codec validation, error routing, and observer calls automatically.
Development order in practice:
1. Define domain types: var oeeCalcFn = forge.NewFunction("oeeCalc", ...)
2. Test in isolation: domain.SensorReadings.Bind(ctx, ports.ChanSourceAdapter(testCh))
3. Connect to triggers: domain.SensorReadings.Bind(ctx, mqtt5.SubscribeAdapter(...))
4. Connect to sinks: domain.OEEResults.Bind(ctx, mqtt5.PublishAdapter(...))
5. Add intermediate I/O: domain.Calibration.Bind(ctx, nethttp.CallAdapter(...))
6. Expose as request/response: domain.OEETool.Bind(ctx, mcpgo.ToolPipelineAdapter(...))
Steps 1–2 require no transport dependencies at all. Steps 3–6 plug the domain core into the outside world declaratively, without changing the forge functions. See the ports feature guide for the full adapter catalogue.
Three-layer architecture¶
┌─────────────────────────────────────────────────────────────────────┐
│ LAYER 1 — codex: validated domain types │
│ │
│ PlannedTime, Downtime, Availability, OEE … │
│ codex.MapCodecSafe(float64 → PlannedTime) ← wire-type bridging │
│ codex.Struct[AvailabilityIn].RefineFunc ← cross-field rules │
├─────────────────────────────────────────────────────────────────────┤
│ LAYER 2 — api/events: transport contracts │
│ │
│ events.NewChannel[SensorReading](...).Register(b) │
│ b.AsyncAPISpec() │
├─────────────────────────────────────────────────────────────────────┤
│ LAYER 3 — forge: governed KPI computation │
│ │
│ forge.NewFunction("availabilityCalc", "1.0.0", …) │
│ forge.Registry → pipeline YAML spec + graph inference │
│ stats.PipelineObserver → per-Apply telemetry │
└─────────────────────────────────────────────────────────────────────┘
MapCodecSafe vs forge.Function¶
| Aspect | codex.MapCodecSafe |
forge.Function[In, Out] |
|---|---|---|
| Purpose | Structural type mapping (wire bridging) | Named, governed domain computation |
| Direction | Bidirectional (encode + decode) | Unidirectional: In → Out only |
| Identity | None — anonymous | name + version + SHA-256 contract hash |
| Governance | None | FunctionMeta{Author, ApprovedBy, …} |
| Spec output | No | Registry.Spec() → pipeline YAML |
| Telemetry | None | PipelineObserver.RecordApply |
| Error types | codec errors | InputError, OutputError, ApplyError, RefinementError |
Rule of thumb:
- codex.Map* answers: "How do I represent float64 as PlannedTime?" — structural, bidirectional, anonymous.
- forge.Function answers: "What named computation derives Availability from AvailabilityIn?" — business logic, unidirectional, governed.
Why Function is a value, not a bare closure¶
A bare func(In) (Out, error) closure cannot participate in governance. The
*forge.Function[In, Out] value is a thin wrapper that adds identity to the closure:
| What the value carries | Why a closure can't provide it |
|---|---|
Spec.Name, Spec.Version |
Go closures have no name at runtime |
Spec.Hash (SHA-256) |
Go functions are not comparable; hashing a closure is impossible. The hash is computed from the codec schemas — the contract, not the bytecode. |
inputCodec / outputCodec |
Input/output schema for pipeline YAML, OpenAPI, AsyncAPI |
observer (injected by Register) |
Inversion of control — the Registry wires the observer, not the function |
The bare func(In) (Out, error) lives inside Function.apply — it IS a free function.
The Function value is the governance envelope around it.
Composition is always via free functions. The caller composes, the value carries identity:
Free function operators: stream.Apply, stream.Filter, forge.Compose
↓ compose over ↓
Identified values: *forge.Function[In, Out]
↓ registered in ↓
Registry: forge.NewRegistry(...).WithObserver(obs)
stream.Apply(ctx, src, oeeCalcFn, opts) — free function at the composition layer,
*forge.Function[In, Out] at the identity layer. Both are needed; neither alone is sufficient.
Forge functions are pure domain computations. They receive typed inputs, return typed
outputs and errors, and have no knowledge of observers, transports, or streams. The
stream.Apply operator and the Registry are where observability (PipelineObserver,
StreamObserver) attaches. Functions neither call nor require an observer in their body.
Zero I/O inside forge functions. A forge function body must not perform I/O:
// ✅ Correct: pure transformation
func(in OEEIn) (OEE, error) {
return OEE(float64(in.Availability) * float64(in.Performance) * float64(in.Quality)), nil
}
// ❌ Wrong: I/O inside forge function violates the design
// func(in InputData) (Out, error) {
// cfg, _ := configFile.Read(...) ← file I/O
// resp, _ := nethttp.Call(...) ← HTTP call
// row, _ := db.Query(...) ← database query
// return combine(in, cfg, resp), nil
// }
If a forge function needs data from an external source (config, lookup table, enrichment
service), that data must arrive as a typed input in the input codec — loaded by the
stream layer (via WatchStream, QueryStream, CombineLatest2) before the function
is called:
// ✅ Correct: external data flows IN as typed input
type EnrichInput struct {
Sensor SensorReading // from MQTT stream
Config ThresholdConfig // from config file stream
}
enrichFn := forge.NewFunction("applyThresholds", "1.0.0",
enrichInputCodec, alertCodec,
func(in EnrichInput) (Alert, error) {
// Pure: both inputs are already validated and available
if float64(in.Sensor.Value) > in.Config.MaxValue {
return Alert{Sensor: in.Sensor.ID, Exceeded: true}, nil
}
return Alert{}, nil
},
)
// I/O stays in the stream layer — forge function receives the result:
configs := /* file.WatchStream + FlatMapSlice + ports.File.Read */
combined := stream.CombineLatest2(ctx, sensorStream, configs,
func(s SensorReading, c ThresholdConfig) EnrichInput { return EnrichInput{s, c} })
alerts := stream.Apply(ctx, combined, enrichFn, stream.ApplyOptions{})
This separation is the design intent: adapters and stream bridges handle I/O; forge functions handle computation. The stream layer wires them together.
Defining a function¶
import "github.com/DaniDeer/go-codex/forge"
// forge.NewFunction is infallible — panics only on empty name or version.
var availabilityCodec = codex.Float64().WithTitle("availability")
availabilityCalc := forge.NewFunction(
"availabilityCalc", "1.0.0",
availabilityInCodec, // Codec[AvailabilityIn] — validates inputs
availabilityCodec, // Codec[Availability] — validates output
func(in AvailabilityIn) (Availability, error) {
return Availability(
(float64(in.PlannedTime) - float64(in.Downtime)) / float64(in.PlannedTime),
), nil
},
forge.FunctionMeta{
Description: "Computes availability as (plannedTime - downtime) / plannedTime.",
Author: "oee-team",
},
)
// Apply — input and output are codec-validated; errors are structured.
avail, err := availabilityCalc.Apply(AvailabilityIn{PlannedTime: 8.0, Downtime: 1.0})
var ie forge.InputError
if errors.As(err, &ie) {
fmt.Printf("input failed: %v\n", ie.Err)
}
Validation sequence¶
When Apply is called:
1. Input codec decodes and validates → InputError on failure
2. Optional cross-input refinement runs → RefinementError on failure
3. User function executes → ApplyError on failure
4. Output codec validates → OutputError on failure
Multi-input functions (struct input codec)¶
type AvailabilityIn struct {
PlannedTime PlannedTime
Downtime Downtime
}
// Cross-field constraint: downtime cannot exceed planned time
var availabilityInCodec = codex.Struct[AvailabilityIn](
codex.RequiredField("plannedTime", plannedTimeCodec, ...),
codex.RequiredField("downtime", downtimeCodec, ...),
).RefineFunc(func(a AvailabilityIn) error {
if float64(a.Downtime) > float64(a.PlannedTime) {
return fmt.Errorf("downtime exceeds plannedTime")
}
return nil
})
Governance metadata¶
forge.NewFunction("calc", "1.0.0", inCodec, outCodec, fn,
forge.FunctionMeta{
Description: "Human-readable description",
Author: "team-name",
ApprovedBy: "reviewer",
ApprovedAt: "2024-03-01",
},
)
Composing functions¶
// Compose chains f1: A→B and f2: B→Out into Function[A, Out].
// Type-safe: Out of f1 must match In of f2.
combined := forge.Compose("combined", "1.0.0", f1, f2,
forge.FunctionMeta{Description: "chained pipeline"},
forge.WithRefinement(func(a A) error { /* pre-compose constraint */ return nil }),
)
Registry and pipeline spec¶
reg := forge.NewRegistry("OEE Pipeline", "1.0.0").
WithAuthor("engineering@example.com").
WithApproval("quality-board", "2024-01-15").
WithObserver(myObserver)
reg = availabilityCalc.Register(reg)
reg = performanceCalc.Register(reg)
reg = oeeCalc.Register(reg)
// Registry infers graph edges by matching input port names to output port names.
// Port names come from codec.Schema.Title (set via .WithTitle).
spec, err := pipeline.Render(reg.Spec()) // YAML pipeline document
fmt.Println(string(spec))
PipelineObserver telemetry¶
type myObserver struct{}
func (myObserver) RecordApply(name, version string, success bool, d time.Duration) {
log.Printf("[forge] %s@%s ok=%v dur=%v", name, version, success, d)
}
Structured errors¶
| Error type | When |
|---|---|
forge.InputError{Err} |
Input codec validation failed |
forge.RefinementError{Function, Err} |
Cross-input RefineFunc or WithRefinement failed |
forge.ApplyError{Function, Err} |
Compute function returned an error |
forge.OutputError{Err} |
Output codec validation failed |
forge.CollectionElementError{Index, Function, Err} |
Slice collection op failed at element |
forge.CollectionKeyError{Key, Function, Err} |
Map collection op failed at key |
Collection operations¶
| Constructor | Signature | Kind in YAML |
|---|---|---|
forge.Map |
Function[In,Out] → Function[[]In, []Out] |
map |
forge.Filter |
predicate func(T) bool → Function[[]T, []T] |
filter |
forge.Reduce |
step func(Acc,T) Acc → Function[[]T, Acc] |
reduce |
forge.MapValues |
Function[In,Out] → Function[map[string]In, map[string]Out] |
mapValues |
forge.MapValuesK |
Codec[K] + Function[In,Out] → Function[map[K]In, map[K]Out] |
mapValues |
All four return *Function[_,_] — composable with Compose, registerable in a Registry, and represented in pipeline YAML with kind/wraps fields:
forge.Map and forge.MapValues/forge.MapValuesK wrap an existing *Function and delegate per-element Apply. forge.Filter and forge.Reduce accept raw predicates / step functions plus an explicit element codec.
// Lift scalar function over slice
mapToCelsius := forge.Map("mapToCelsius", "1.0.0", rawToCelsius,
forge.WithRefinement(func(readings []RawReading) error {
if len(readings) == 0 {
return fmt.Errorf("batch must contain at least one reading")
}
return nil
}),
)
// Errors attributed to element or key
_, err := mapToCelsius.Apply(batch)
var ce forge.CollectionElementError
if errors.As(err, &ce) {
fmt.Printf("element %d failed in %q: %v\n", ce.Index, ce.Function, ce.Err)
}
MapValuesK validates all keys atomically before processing any value — one bad key returns InputError → KeyError → ConstraintError immediately.
See also¶
- examples/forge-oee — OEE KPI computation, governance, Compose, MeasuredCodec
- examples/forge-collection — Map, Filter, Reduce, MapValuesK on sensor batches
- examples/oee-chain — full three-layer chain: codex + api/events + forge + AsyncAPI + pipeline spec
Binary data in forge functions¶
forge.NewFunction accepts any codec type — including codex.Bytes for raw binary data (images, documents, sensor captures). Binary functions work exactly like numeric or struct functions:
pngCodec := codex.Bytes().
Refine(validate.MaxBytes(5 * 1024 * 1024)).
Refine(validate.PNG).
WithTitle("rawImage")
// Validates input + output; PNG magic-byte check runs on both
resizeImage := forge.NewFunction("resizeImage", "1.0.0",
pngCodec,
pngCodec.WithTitle("resizedImage"),
func(raw []byte) ([]byte, error) {
return resizePNG(raw, 128, 128)
},
forge.FunctionMeta{Description: "Downscale PNG to 128×128 thumbnail."},
)
result, err := resizeImage.Apply(pngBytes)
// validate.PNG ran on pngBytes (input) and result (output)
Port names come from .WithTitle(...). The pipeline YAML emits schema: {type: string, format: binary} for binary ports — readable and machine-processable.
MeasuredCodec with binary values¶
MeasuredCodec wraps any codec, including binary:
Choose the value codec based on how Measured[[]byte] is serialised downstream:
| Downstream serialisation | Value codec | Why |
|---|---|---|
| forge computation only (no serialisation) | codex.Bytes() |
Raw bytes, no encoding overhead |
Published via format.Binary (MQTT, HTTP binary) |
codex.Bytes() |
Identity marshal — bytes stay raw |
Published via format.JSON (REST, MQTT JSON) |
codex.Base64() |
Go's JSON encoder base64-encodes []byte; Base64() makes this explicit and round-trip correct |