sensor-service

command
v0.12.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 29, 2026 License: MIT Imports: 32 Imported by: 0

README

sensor-service

The go-codex flagship example: a small but complete sensor-readings service, structured like a real project. It tells one coherent story and shows how protocol-agnostic ports make every IO boundary of a pipeline composable — the pipeline code never imports an adapter; swapping MQTT → ZeroMQ or SQL → HTTP changes only main.go.

The use case

  1. Ingest — sensors publish readings over MQTT.
  2. Persist — every reading is validated and written to the database.
  3. Alert — readings above a threshold (configured via env var) publish an MQTT alert.
  4. Query — the time series of one sensor is served over REST, queried from the database.
  5. Export — a REST call triggers an export of all readings, written to a typed JSON file.

Data flow — every hop is a port

                    MQTT sensors/{sensorID}/data
                                │
                    ┌───────────▼───────────┐
                    │ Sensors  SourcePort   │  EventPattern plugged in at wiring
                    └───────────┬───────────┘  time (PluginEventPattern) →
                                │              mqtt.SubscribeAdapter
                                │ Chain(buildParams.Apply, pure)
                    ┌───────────▼───────────┐
                    │ Params   PipePort     │  pipeline-internal stage, no adapters
                    └───────────┬───────────┘
                                │ ChainStream(Persist.Connect + Tap(log))
                    ┌───────────▼───────────┐
                    │ Saved    PipePort     │  the SQL persistence hop is its OWN
                    └───────────┬───────────┘  real edge (Params → Saved) — see
                                │              ioports.Readings' IOPort below
                                │ Stream() ──────────────► GET /readings/latest
                                │                          (reactive cache)
                     ChainStream(Filter(threshold) + FlatMap(buildAlert))
                    ┌───────────▼───────────┐
                    │ Alerts     SinkPort   │  EventPattern plugged in at wiring
                    └───────────┬───────────┘  time (PluginEventPattern) →
                                ▼              mqtt.PublishAdapter
                    MQTT alerts/{sensorID}

  Readings  IOPort      SQLPattern{readings, insert_reading} → sql.QueryEachAdapter
  (Connected INSIDE the Params→Saved ChainStream transform above — the
  pipeline never hides an IO hop inside a bigger stage's closure; every
  boundary, including this one, is a real, named port edge)

  GET /sensors/{sensorID}/readings          POST /export
  ┌────────────────────────┐                ┌────────────────────────┐
  │ HistoryTool  ToolPort  │ RESTPattern    │ ExportTool  ToolPort   │ RESTPattern
  └───────────┬────────────┘                └───────────┬────────────┘
              │ Single(SensorQuery)                     │ Single(ExportRequest)
  ┌───────────▼────────────┐                ┌───────────▼────────────┐
  │ History  IOPort        │ SQLPattern     │ ExportQuery  IOPort    │ SQLPattern
  └───────────┬────────────┘                └───────────┬────────────┘
              │ Stream[TimeSeries]                      │ Stream[ExportSnapshot]
              ▼                                Tap ─────┼──► Exports SinkPort
        200 TimeSeries                                  │    FilePattern
                                       Apply(buildExportResult)  {exportID}.json
                                                        ▼         → file adapter
                                          201 ExportResult{File, Count}

Package layout

Package Responsibility Imports (internal)
domain/ Models, codecs, field factories, topic constraint, pure business rules (BuildInsertParamsFromMQTT, NewShouldAlert, NewExportSnapshot, …) db
pipeline/ Business logic: pure forge functions + the segmented MQTT pipeline (ports.PipePort/Chain/ChainStream). Persistence and queries go through ports passed in as dependencies; never imports ioports — Sensors/Alerts are parameters to Build, not package vars domain, db
ioports/ The service's complete IO surface: every port declared once (structural shape + shared builder reference), each with a standalone Pattern value plugged in at wiring time via PluginEventPattern/PluginRESTPattern/etc. — declare, plug in, bind, the same three-step model uniformly across all port types domain, db
observability/ Cross-cutting CountingObserver — one instance, fanned out with a LoggingObserver, stored once in the context
adapters/ Infrastructure edge: mock MQTT client, SQL ReadingStore, HTTP handler factories domain, db
db/ sqlc-generated queries + goose migrations
main.go Wiring only: app.New owns the root context (observer pre-injected) and the LIFO teardown (OnShutdown hooks for the exports port and HTTP server); config, DB, adapter binds everything above
demo.go The runnable demo scenario (drives the wired service, prints the specs) everything above

Import direction is strictly acyclic; domain imports nothing internal but db.

Ports declared in ioports/

Port Type Pattern Bound adapter (main.go)
Sensors SourcePort[MQTTPayload] EventPattern sensors/{sensorID}/data, plugged in at wiring time (PluginEventPattern(SensorsPattern)) mqtt.SubscribeAdapter
Readings IOPort[InsertReadingParams, Reading] SQLPattern{readings, insert_reading} sql.QueryEachAdapter
Alerts SinkPort[SensorAlert] EventPattern alerts/{sensorID}, plugged in at wiring time (PluginEventPattern(AlertsPattern)) mqtt.PublishAdapter
History IOPort[SensorQuery, TimeSeries] SQLPattern{readings, list_by_sensor} sql.QueryEachAdapter
ExportQuery IOPort[ExportRequest, ExportSnapshot] SQLPattern{readings, list_readings} sql.QueryEachAdapter
NewExportsPort(dir) SinkPort[ExportSnapshot] FilePattern {exportID}.json file.DrainWriteFileAdapter (request-fed via Start/Push/Close)
HistoryTool ToolPort[struct{}, TimeSeries] RESTPattern GET /sensors/{sensorID}/readings nethttp.PipelineAdapter
ExportTool ToolPort[ExportRequest, ExportResult] RESTPattern POST /export nethttp.PipelineAdapter
Latest LatestPort[db.Reading] RESTPattern GET /readings/latest nethttp.LatestAdapter

Alongside the ports, ioports declares the three classic REST routes and registers them against the same shared builder at declaration time:

Route Handle Endpoint Wired via (main.go)
CreateRoute CreateHandle POST /readings nethttp.Register + handler factory
GetRoute GetHandle GET /readings/{id} nethttp.Register + handler factory

Spec generation — the declarations are the spec

The ioports declarations are the single source of truth; the demo renders three artifacts from them, without any separate spec-authoring step:

  • AsyncAPI (EventsBuilder.AsyncAPISpec()) — both event ports appear as channels + receive/send operations with full payload schemas and topic parameters. Each EventPattern was registered internally by its port's constructor against the shared EventsBuilder (which also enforces the topic-format constraints at construction time — an invalid topic fails port construction, not spec rendering).
  • OpenAPI (RESTBuilder.OpenAPISpec()) — covers all five HTTP endpoints with request/response schemas: the two RESTPattern-based tool ports register internally at port construction, and the three classic routes register explicitly next to their declarations (CreateHandle = codex.Must(CreateRoute.Register(RESTBuilder))). Header fields declared with codecs ride along: ExportTool's rest.HeaderParam{Name: "X-Api-Key", Required: true}.WithCodec(domain.APIKeyCodec) is enforced by the adapter before the pipeline runs (400 + rest.HeaderParamError, observer location "header") and appears in the spec as an in: header parameter — one declaration, both behaviors.
  • Pipeline spec (ports.PipelineSpec("Sensor Service MQTT Pipeline", "1.0.0", ioports.Sensors, pipeline.Params, pipeline.Saved, ioports.Alerts)) — the MQTT pipeline shape derived directly from the real port/PipePort wiring, not hand-typed: pipe/port names, buffer sizes, bound adapter identities, and every Chain/ChainStream edge (including each transform's real Go function identity via reflection — e.g. buildInsertParams for the pure map, honestly closure-opaque for the SQL-persistence and filter+alert transforms). No separate pipeline.Topology function to keep in sync with the code — see docs/features/ports.md.

The same declare-once principle covers the file boundary: the export response's file path comes from the same FilePattern declaration that writes the file (FileHandle.BuildPath).

HTTP endpoints

Endpoint Purpose
POST /readings Create a reading (codec-validated before the DB)
GET /readings/{id} Fetch one reading
GET /readings/latest Most recent reading — served from the Latest cache port's atomic cell, zero DB queries
GET /sensors/{sensorID}/readings Time series of one sensor, queried from the DB through the History port
POST /export Export all readings to a typed JSON file through the Exports port. Requires the codec-validated X-Api-Key header (sk- prefix, domain.APIKeyCodec) — missing or malformed keys get 400 before the pipeline runs

Configuration

Env var Default Contract
APP_ALERT_THRESHOLD 50.0 domain.AlertConfigCodecfloat64, MinFloat(0); loaded once in main() via config.FromEnv, pipeline functions close over the typed config

Run

go run ./examples/sensor-service

# raise the alert threshold — the 87.3 °C reading no longer alerts:
APP_ALERT_THRESHOLD=90 go run ./examples/sensor-service

The demo runs the full story in-process (mock MQTT client, in-memory SQLite, httptest server) and prints each scene, the observer summary, the derived pipeline spec, and the AsyncAPI/OpenAPI specs. Lifecycle is managed by app.New: the demo ends with a.Shutdown() (ordered LIFO teardown); a real service would call a.Run(ctx) and get SIGINT/SIGTERM handling on the same teardown path.

After a run, the exported snapshot sits right here in exports/ ({exportID}.json, per the FilePattern declaration) — open it to see the codec-shaped file the Exports port wrote. The directory is wiped and recreated on each run and is gitignored.

Regenerating the database layer

cd examples/sensor-service
sqlc generate   # query/readings.sql → db/

Migrations in migrations/ are applied at startup via sqladapter.NewMigrator (goose).

Documentation

Overview

Command sensor-service is the go-codex flagship example: a small but complete sensor-readings service structured as a real project, with each concern in its own package:

domain/         — Layer 1+2: models, codecs, field factories, constraints,
                  pure business rules (validated-config factories included)
pipeline/       — business logic: pure mapping functions + the segmented
                  MQTT pipeline (ports.Chain/ChainStream over boundary
                  SourcePort/SinkPort + internal PipePort stages),
                  parameterized by the consumer-defined Store interface
ioports/        — the service's complete IO surface: every boundary
                  follows the SAME three-step model (declare port shape →
                  plug in a Pattern → bind an adapter); EventPattern/
                  SQLPattern/FilePattern/RESTPattern + REST routes
observability/  — cross-cutting CountingObserver (fanned out with a
                  LoggingObserver, stored once in the context)
adapters/       — infrastructure edge: mock MQTT client, SQL ReadingStore,
                  HTTP handler factories
db/             — sqlc-generated queries + goose migrations
main.go         — wiring ONLY: config, DB, observer, adapter binds, server
demo.go         — the runnable demo scenario

Import direction is strictly acyclic: main → {ioports, pipeline, adapters, observability, domain}; pipeline → domain; ioports → domain; adapters → domain; domain → nothing internal but db.

What it demonstrates

  • ports.SourcePort + ports.SourcePort.PluginEventPattern + adaptermqtt.SubscribeAdapter — MQTT ingestion wired to the pipeline's first stage (ioports.Sensors); pipeline code has no MQTT import. The topic + params are declared once as a standalone value (ioports.SensorsPattern), plugged in at wiring time — PluginEventPattern registers it AND returns the typed handle in one call, no separate events.NewChannel/Register step needed.
  • ports.SinkPort + ports.SinkPort.PluginEventPattern + adaptermqtt.PublishAdapter — MQTT alert publishing wired to the pipeline's LAST stage (ioports.Alerts); supports fan-out to additional sinks via additional Bind calls.
  • ports.Chain + ports.ChainStream — pipeline.Build segments the MQTT pipeline into named stages (Sensors → Params → Saved → Alerts) using the SAME call shape whether the endpoint is a boundary SourcePort/ SinkPort or an internal PipePort. Persistence is its OWN Params→Saved edge — ports.IOPort + sqladapter.QueryEachAdapter (ioports.Readings): the pipeline's mapping function stays PURE (payload → insert params); the save happens through the port, whose adapter is chosen here, INSIDE that edge's transform — never buried inside a bigger, multi-purpose stage. Table/Op metadata declared once via ports.SQLPattern, plugged in via ports.NewSQLPort (ioports.Readings/ History/ExportQuery are all declared this way — a thin convenience constructor combining "declare port" + "plug in SQLPattern").
  • ports.PipelineSpec — the MQTT pipeline's shape (pipe/port names, buffer sizes, bound adapter identities, every Chain/ChainStream edge with its transform's real Go function identity) derived directly from the four stages, printed in demo.go — no hand-typed topology to keep in sync.
  • ports.ToolPort + nethttp.PipelineAdapter — GET /sensors/{sensorID}/readings (ioports.HistoryTool, declared via ports.NewRestToolPort): the tool pipeline Connects through ioports.History (IOPort, SQLPattern) — REST layer and database never meet directly.
  • ports.SinkPort + fileadapter.DrainWriteFileAdapter — POST /export (ioports.ExportTool): query through ioports.ExportQuery (SQLPattern), write the snapshot through ioports.NewExportsPort's ports.FilePattern ({exportID}.json); the response path comes from the SAME declaration via File.BuildPath.
  • nethttp.HandlerLatest — reactive cache endpoint; GET /readings/latest returns the most recently saved reading without querying the DB.
  • Validated-config factory pattern — main() loads domain.AlertConfig once via config.FromEnv (APP_ALERT_THRESHOLD, default 50.0); the pipeline functions close over the typed, validated config (see domain.NewShouldAlert).
  • One stats.NewFanout observer across HTTP, MQTT, SQL, file, and stream.

Run:

go run ./examples/sensor-service
APP_ALERT_THRESHOLD=90 go run ./examples/sensor-service

Directories

Path Synopsis
Package adapters holds the sensor service's infrastructure edge: the mock MQTT client used for the demo, the SQL-backed ReadingStore, and the HTTP handler factories.
Package adapters holds the sensor service's infrastructure edge: the mock MQTT client used for the demo, the SQL-backed ReadingStore, and the HTTP handler factories.
Package domain is Layer 1 + Layer 2 of the sensor service: models, codecs, and pure business rules.
Package domain is Layer 1 + Layer 2 of the sensor service: models, codecs, and pure business rules.
Package ioports declares every IO boundary of the sensor service as a protocol-agnostic port or route — the service's complete IO surface, readable as a compact spec, with ZERO adapter imports.
Package ioports declares every IO boundary of the sensor service as a protocol-agnostic port or route — the service's complete IO surface, readable as a compact spec, with ZERO adapter imports.
Package observability holds the cross-cutting observer for the sensor service.
Package observability holds the cross-cutting observer for the sensor service.
Package pipeline is the sensor service's business logic layer: pure mapping functions and the stream topology.
Package pipeline is the sensor service's business logic layer: pure mapping functions and the stream topology.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL