Guide: Declarative wire-format vocabulary¶
A package that owns a wire/file format (a JSON manifest, a device config patch, a directory of per-device files, …) accumulates its OWN small vocabulary of constants and conventions as it grows: top-level wire-key names, dotted-key prefixes, file/directory path templates, and named identifier types wrapping validated strings. Left undeclared, these tend to get copy-pasted (and drift) across every file that touches the same format. This guide is the recipe for consolidating them into ONE file per package — a single source of truth — built entirely from existing go-codex primitives (no new package required).
The recipe: one keys.go/config.go per package¶
Put ALL of the following in one file, grouped by concern:
- Top-level wire-key constants — plain
conststrings for the fixed keys a wire format uses (e.g."modulesContent","$edgeAgent"). - Dotted-key prefixes + codecs — for keys shaped "prefix + validated
name segment", use
codex.PrefixedKeyCodec(the more minimal option) orcodex.DottedKeyCodecwith a single-{var}template (when the package already usesDottedKeyCodec/DottedPatchMapCodecfor other, multi-segment or wildcard keys, for one consistent vocabulary) — instead of hand-rolling aConstraint+MapCodecValidatedpair. See the dotted-key decision guide below for the full "which one do I need" table. - File/directory path templates — build these with plain
fmt.Sprintf(readable, and avarrather thanconstsincefmt.Sprintfisn't a constant expression — evaluated once at package init, functionally identical to a const for every call site), then pass the resulting template string toports.NewFile/ports.NewDirwithports.FilePathParam/ports.DirPathParamdeclarations. - Named identifier types — wrap a validated string in a named Go
type (
type ModuleName string) viacodex.MapCodecSafe, with a smart constructor (NewModuleName(s string) (ModuleName, error)) built on the codec's own.New(...). - Fixed-value ("constant") fields — for a field that may ONLY ever
equal one specific value at a specific place in the format (not one
of several, and not user-configurable), use
codex.Eq(base, value)instead of a degenerate single-elementvalidate.OneOf(value). See Fixed-value fields:Eq/Purebelow.
Reference implementation¶
examples/go-edge-models has two real files following this recipe —
read them directly rather than a synthesized snippet:
models/azure/iothub/keys.go— wire-key constants (#1) + dotted-key codecs viaDottedKeyCodec's single-{var}template form (#2), for a deployment manifest's$edgeAgent/$edgeHubbuckets. See the siblinglifecycle.go'sTypeCodecfor #5 (fixed-value fields).models/iotedge/usecase/config.go— path templates viafmt.Sprintf(#3) + named identifier types (#4), for a use case's on-disk file layout. This file has no fixed-value fields of its own (nocodex.Structat all — only path templates and identifier types), so #5 doesn't apply here; seelifecycle.goinstead.
Both files are the FIRST thing a maintainer reads to answer "what does this wire key actually look like" or "where do these files live on disk" — every other file in the package imports these constants/codecs instead of re-hardcoding the same literal strings.
Is this a go-codex library gap, or just usage?¶
Just usage of existing primitives, with one small addition. Everything in the recipe above composes primitives that already exist:
| Concern | Primitive | New in this guide? |
|---|---|---|
| Wire-key constants | plain Go const |
no |
| Dotted-key codec (prefix + exactly one name segment, minimal constructor) | codex.PrefixedKeyCodec |
yes — generalizes a recipe that used to be hand-rolled per package |
Dotted-key codec (single-{var} template — same shape as PrefixedKeyCodec, via the template mechanism) |
codex.DottedKeyCodec |
yes — see iothub.ModuleNameCodec/RouteNameCodec |
| Dotted-key codec (fixed multi-segment struct key, no wildcards) | codex.DottedKeyCodec |
yes — MQTT-style template syntax, generalizes what examples/flat-key-patch's former twoPartKeyCodec hand-rolled |
| Path templates | ports.FilePathParam/DirPathParam, fmt.Sprintf |
no |
| Named identifier types | codex.MapCodecSafe + smart constructor (.New) |
no |
| REST path / event topic vars | rest.NewPathParam/events.NewTopicParam |
no |
| Fixed-value ("constant") fields | codex.Eq/codex.Pure |
no |
| Applying a patch (flat) | codex.ApplyPatch |
yes — new in-memory primitive, previously only bundled inside file-I/O machinery |
| Applying a patch (dotted-path) | codex.ApplyDottedPatch/ApplyDottedPatchTo/DottedPatchMapCodec |
yes — generalizes what finaldeviceconfig.Merge/deviceconfig.PatchCodec used to hand-roll |
The genuine gaps closed here are codex.PrefixedKeyCodec, DottedKeyCodec,
and the patch-application primitives (ApplyPatch/ApplyDottedPatch/
DottedPatchMapCodec) — small, purely-compositional additions to the
codex package (see
Codec concepts
and Applying a patch).
Path/route/topic-variable declaration ALREADY has a fully declarative,
consistent story across boundaries (rest.PathParam, events.TopicParam,
ports.FilePathParam/DirPathParam, ports.CacheKeyParam) — this guide
does not introduce anything new there, only documents how to USE what
already exists well.
Reusing a Topic/Path/FilePathTemplate¶
events.ChannelHandle.BuildTopic, rest.RouteHandle.BuildPath,
reqreply.RouteHandle.BuildTopic, and ports.File.BuildPath/
ports.Dir.BuildPath are all logically independent of their generic
payload type — they only touch the template string plus the declared
variable params. events.Topic, rest.Path, reqreply.Topic,
ports.FilePathTemplate, and ports.DirPathTemplate extract that
payload-independent shape into its own reusable value.
This is opt-in, not the default. The plain-string form
(events.NewChannel[T]("devices/{deviceID}/status", codec, opts...), and
the rest/reqreply/ports equivalents) remains completely unchanged
and stays the default, primary way to declare a one-off
channel/route/file/dir. Reach for Topic/Path/FilePathTemplate/
DirPathTemplate ONLY when you find yourself declaring the SAME
template+params shape for two or more channels/routes/files (of
different payload types) and want that shape to have exactly one source
of truth:
var deviceStatusTopic = events.NewTopic("devices/{deviceID}/status",
events.TopicParam{Name: "deviceID", Codec: &deviceIDCodec},
)
var deviceOnlineChannel = events.NewChannelFromTopic(deviceStatusTopic, deviceOnlineCodec,
events.Subscribe{Summary: "Receive device online event"},
)
var deviceOfflineChannel = events.NewChannelFromTopic(deviceStatusTopic, deviceOfflineCodec,
events.Subscribe{Summary: "Receive device offline event"},
)
// Standalone use — no payload codec involved at all:
topic, err := deviceStatusTopic.BuildTopic(map[string]string{
"deviceID": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
})
A channel/route/file declared this way is byte-for-byte identical to one
declared with the plain-string constructor and the same params passed
inline — nothing downstream (adapters, Register, spec generation) can
tell the difference. Every other option (ChannelMeta, Subscribe/
Publish, RouteMeta, WithSecurityScheme, formats, error patterns, …)
is passed through to NewChannelFromTopic/NewRouteFromPath/
reqreply.NewRouteFromTopic/NewFileFromPathTemplate/
NewDirFromPathTemplate exactly as it would be to the plain constructor
— using a bundle for the template+params portion restricts nothing else
about the declaration.
See examples/api-events, examples/rest-builder, and examples/file-io
for runnable demonstrations of each.
Does this restrict richer route/channel declarations (e.g. HATEOAS)?¶
No. Path/Topic/FilePathTemplate capture ONLY the template+params
slice — they are not a competing or parallel declaration model. A future
codec-defined-HATEOAS feature (see docs/roadmap/idea-codec-defined-hateoas.md)
would reference full rest.Route VALUES (method, codecs, meta — the
whole thing), regardless of whether that Route's path portion was built
via NewRoute directly or via NewRouteFromPath — adopting a bundle
where convenient changes nothing about how the resulting Route/
Channel/File looks or behaves afterward.
Fixed-value fields: Eq/Pure¶
"Define a constant with a codec, use it in exactly one place, and only this constant is valid there" is already fully supported — see Codec concepts: Pure and Eq for the full mechanics. This section covers WHEN to reach for it as part of a package's wire-format vocabulary:
codex.Eq(base, value)— the field only accepts exactlyvalue; anything else is aConstraintError. Use for a field that is always one specific value BY DEFINITION of the format (not user-configurable, not one of several valid choices).codex.Pure(value)— the field ALWAYS decodes/encodes tovalueregardless of wire input (no error, just forced). Use for a field that is automatically/always set by the format itself (e.g. a protocol version marker), never meaningfully supplied by a caller.
Spotting the case: a validate.OneOf(...) constraint called with
EXACTLY ONE argument is a degenerate single-element enum — a strong
signal the field is actually a fixed constant, not a genuine multiple-
choice enum, and codex.Eq communicates that intent more directly:
// Before: a single-value OneOf reads like there COULD be more choices.
var TypeCodec = codex.MapCodecSafe(
codex.String().Refine(validate.OneOf("docker")),
func(s string) Type { return Type(s) },
func(t Type) (string, error) { return string(t), nil },
)
// After: Eq states directly that "docker" is the ONLY valid value.
var TypeCodec = codex.MapCodecSafe(
codex.Eq(codex.String(), "docker"),
func(s string) Type { return Type(s) },
func(t Type) (string, error) { return string(t), nil },
)
Same wire shape, same validation, same error on mismatch — purely a
clearer expression of intent. See
models/azure/iothub/lifecycle.go's
TypeCodec for the real, in-repo version of this exact refactor —
contrast with the SAME file's StatusCodec/RestartPolicyCodec, which
keep validate.OneOf because they are genuine multi-value enums
("running"/"stopped", "always"/"on-failure"/…).
Two ideas considered and rejected for this recipe:
- A thin codex.ConstString(value) wrapper over Eq(codex.String(), value)
— real but marginal (saves a few characters over an already-one-line
call); not worth its own constructor.
- Enforcing that a constant-codec pair may only be referenced from
exactly one call site in the whole codebase — NOT achievable as a
codec/library feature at all (a Codec[T] value has no visibility
into its own static call graph); would require an unrelated
static-analysis/lint tool, and would work against Eq/Pure's own
reuse benefit (the same fixed value validated identically wherever the
format legitimately reuses it).
Applying a patch: flat and dotted-path¶
A package that hand-rolls a "patch" wire format (like deviceconfig.Patch
did) usually needs TWO more pieces beyond the recipe above: something to
declare the patch's own wire-key vocabulary (if its keys are dynamic/
dotted), and something to actually APPLY the patch onto a base value.
Both are now covered by existing codex primitives:
- Applying the patch —
codex.ApplyPatch(fixed field list) orcodex.ApplyDottedPatch/ApplyDottedPatchTo(dotted paths of arbitrary depth) — see Codec concepts: Applying a patch. - Declaring the dynamic-key wire bucket —
codex.DottedKeyCodec(typed, no wildcards) orcodex.DottedPatchMapCodec(opaque bucket, MQTT-style wildcards) — see Codec concepts: DottedKeyCodec/DottedPatchMapCodec.
Dotted-key decision guide¶
go-codex already has an MQTT topic-matching engine
(internal/templatematch.MatchMQTTWildcard: {varName}/+/#).
codex.DottedKeyCodec/DottedPatchMapCodec adapt the SAME algorithm to
dotted wire keys ("." as the level delimiter instead of "/") — giving
a package's own dotted-key vocabulary the SAME declarative template
syntax MQTT topics already use, instead of hand-rolled Constraint +
manual strings.Split/SplitN parsing.
| You have... | Use |
|---|---|
A dotted key extracting exactly ONE name segment after a fixed prefix, wrapped as ONE named string type (e.g. "user:42" → UserKey("42")), and the package has NO other dotted-key needs |
codex.PrefixedKeyCodec — the more minimal constructor, no FieldCodec ceremony |
The SAME single-segment shape, but the package already uses DottedKeyCodec/DottedPatchMapCodec for OTHER keys (one consistent template vocabulary) — e.g. examples/go-edge-models's iothub.ModuleNameCodec/RouteNameCodec ("properties.desired.modules.{name}" → ModuleName("cv-writer")) |
codex.DottedKeyCodec with a single-{var} template |
A dotted key extracting a FIXED, KNOWN number of named segments into a STRUCT key (e.g. "properties.desired.modules.{tenant}.{name}" → ModuleKey{Tenant, Name}) |
codex.DottedKeyCodec |
A dotted key needing +/# WILDCARDS — a whole opaque-value BUCKET where the key shape varies per entry (e.g. "{moduleName}.#" for arbitrary depth, or "{moduleName}.env.+" for exactly one env var) |
codex.DottedPatchMapCodec |
Wildcards are match-only: +/# can validate/extract from an
ALREADY-EXISTING key (many different concrete keys, one shape), but have
no meaning when building exactly ONE new concrete key from named values
(there's no single value to substitute for "any segment") — this is
exactly why DottedKeyCodec (builds one typed key) PANICS if its
template contains a wildcard, while DottedPatchMapCodec (validates a
whole bucket of existing keys) is where wildcards belong.
examples/go-edge-models's deviceconfig.PatchCodec (the $edgeAgent
bucket, now DottedPatchMapCodec(ModuleKeyPrefix+"{moduleName}.#", ...))
and finaldeviceconfig.Merge (the actual patch-application step, needs
ZERO changes — it consumes already-decoded map[string]any, agnostic to
which codec produced it) are both built on these primitives — read
deviceconfig.go
and
finaldeviceconfig/merge.go
directly for the real, in-repo reference. examples/flat-key-patch's
Section 10 (DottedKeyCodec) and new Section 12 (DottedPatchMapCodec
with +/#) demonstrate both constructors standalone. Typed per-path
LEAF validation (e.g. "validate the value at
factory-gw.settings.createOptions specifically") remains a deferred,
still-open idea.