sensor-service

command
v0.11.0 Latest Latest
Warning

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

Go to latest
Published: Jul 15, 2026 License: MIT Imports: 30 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 (topic + params)
                    └───────────┬───────────┘
                                │ Stream[MQTTPayload]
                     Apply(buildInsertParams)      ← pure forge function
                                │ Stream[InsertReadingParams]
                    ┌───────────▼───────────┐
                    │ Readings  IOPort      │  SQLPattern{readings, insert_reading}
                    └───────────┬───────────┘      → sql.QueryEachAdapter
                                │ Stream[db.Reading]   (validated, stored rows)
                       Tap(log) │ Tee ──────────────────► GET /readings/latest
                                │                         (reactive cache)
                     Filter(value > $APP_ALERT_THRESHOLD)
                                │ FlatMap(buildAlert)
                    ┌───────────▼───────────┐
                    │ Alerts   SinkPort     │  EventPattern
                    └───────────┬───────────┘      → mqtt.PublishAdapter
                                ▼
                    MQTT alerts/{sensorID}

  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 + stream topology. Persistence and queries go through ports passed in as dependencies domain, db
ioports/ The service's complete IO surface: every port and route, declared once with its Pattern (EventPattern/SQLPattern/FilePattern/RESTPattern) + the shared EventsBuilder/RESTBuilder 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: config, DB, observer, adapter binds, HTTP server 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 mqtt.SubscribeAdapter
Readings IOPort[InsertReadingParams, Reading] SQLPattern{readings, insert_reading} sql.QueryEachAdapter
Alerts SinkPort[SensorAlert] EventPattern alerts/{sensorID} 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
HistoryTool ToolPort[struct{}, TimeSeries] RESTPattern GET /sensors/{sensorID}/readings nethttp.PipelineAdapter
ExportTool ToolPort[ExportRequest, ExportResult] RESTPattern POST /export nethttp.PipelineAdapter

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
LatestRoute LatestHandle GET /readings/latest nethttp.RegisterLatest (reactive cache)

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.
  • Stream topology (pipeline.Topology(...).Spec()) — the MQTT pipeline shape as a machine-readable spec, including the pure forge functions' governance metadata (name, version, content hash).

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 stream's reactive cache, 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 format.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 stream topology, and the AsyncAPI/OpenAPI specs.

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: forge functions + stream topology,
                  parameterized by the consumer-defined Store interface
ioports/        — the service's complete IO surface: protocol-agnostic
                  ports (EventPattern/SQLPattern/FilePattern) + 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 + adaptermqtt.SubscribeAdapter — MQTT ingestion wired to a protocol-agnostic SourcePort; pipeline code has no MQTT import. The topic + params are declared once via ports.EventPattern on the port itself (ioports.Sensors); ports.EventHandle derives the *ChannelHandle for the adapter — no separate events.NewChannel/Register step needed.
  • ports.SinkPort + adaptermqtt.PublishAdapter — MQTT alert publishing wired to a SinkPort (ioports.Alerts); supports fan-out to additional sinks.
  • ports.IOPort + sqladapter.QueryEachAdapter — persistence as an explicit intermediate IO step (ioports.Readings): the pipeline's forge function stays PURE (payload → insert params); the save happens through the port, whose adapter is chosen here. Table/Op metadata declared once via ports.SQLPattern.
  • ports.ToolPort + nethttp.PipelineAdapter — GET /sensors/{sensorID}/readings (ioports.HistoryTool, ports.RESTPattern): 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 ports.FileHandle.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 format.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: forge functions and the stream topology.
Package pipeline is the sensor service's business logic layer: forge functions and the stream topology.

Jump to

Keyboard shortcuts

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