Skip to content

Development Guide

This is about working on the module, not with it

Everything here concerns contributing to config itself. If you are configuring an application using it, start at the tutorial.

Setup

Prerequisites

  • Go 1.26.5 or later — the module's go.mod requires it.
  • just — the task runner; just --list shows everything.
  • golangci-lint — the linter is part of the definition of done, not an optional pass.
  • mockery — only needed if you change an interface.
  • zensical — only needed to build or serve the documentation site.
git clone https://gitlab.com/phpboyscout/go/config.git
cd config

go mod download
just            # tidy, lint, test — the default recipe

The recipes that matter

Command What it does
just tidy, lint, test — run this before you push anything
just ci the full local CI: tidy, test, race, lint
just test unit tests with coverage
just test-race the race detector
just lint / just lint-fix golangci-lint, optionally auto-fixing
just coverage an HTML coverage report
just deadcode unreachable exported symbols
just vuln vulnerability scan

Project structure

The module is deliberately flat. There is no internal/, no pkg/, and no subpackage except the generated mocks — a library this size does not need a directory tree, and a flat layout makes the boundaries between components visible in the file names.

config/
├── store.go              # the Store: the single owner of all config I/O
├── snapshot.go           # immutable resolved configuration + provenance
├── view.go               # the scoped read surface over a snapshot
├── layer.go              # Layer, Source, and merge/normalisation rules
├── backend.go            # Backend / WatchableBackend, file and reader backends
├── env.go, flag.go       # the environment and flag backends
├── write.go              # WritableBackend, Pending, the write path
├── plan.go               # routing a change to the layer that owns it
├── watch.go              # fsnotify with a per-path polling fallback
├── settle.go             # coalescing a burst of foreign changes
├── notify.go             # the observer contract: ordering, exactly-once
├── observed_section.go   # ObserveSection[T] and the typed-section binding
├── unmarshal.go          # UnmarshalSection[T]
├── typed.go              # Value[T] and the named accessors
├── schema.go, validate.go, validate_generic.go
├── mocks/                # generated by mockery — do not edit by hand
├── features/             # godog feature files
└── docs/                 # this site

The one architectural rule

The Store is the sole owner of configuration I/O. Nothing else reads, writes or watches a source. Everything else is a view over the immutable snapshot it publishes.

Almost every design decision in the module falls out of that rule, and most bugs found so far have been somewhere that quietly broke it. If a change requires a second component to touch a file, that is a signal to stop and reconsider rather than a detail to work around.

The reasoning is in The Store; the decisions are recorded in the specs.

Workflow

  1. Write or extend a spec first for anything non-trivial. See Feature Specifications — this is a hard rule, not a preference.
  2. Get the spec approved before implementing. Walk the open questions one at a time rather than picking the convenient answer to keep moving.
  3. Implement test-first, following the phases the spec defines.
  4. Run just ci — tidy, test, race, lint. All four.
  5. Cite the spec and decision number in the commit and the merge request.
  6. Flip the spec's status to implemented when it merges.

Branching and history

  • Rebase and fast-forward. No merge commits; a branch that has diverged is rebased onto the latest target before merging.
  • Never squash from the merge-request UI. If history genuinely needs squashing, do it locally and force-push with --force-with-lease.
  • Meaningful, self-contained commits survive. Squashing is for consolidating WIP-style commits that add no historical value.

Commit messages

Conventional commits, because releaser-pleaser derives the changelog and the next version from them. The module is pre-1.0, so a breaking change is a feat! with a BREAKING CHANGE: footer only when it genuinely is one — consider whether the change can stay additive first.

Explain why, not what. The diff already says what changed.

Conventions

Comments explain reasoning, not mechanics

The house style is that a comment earns its place by recording something the code cannot: why this approach rather than the obvious one, what broke when it was done differently, what invariant a reader would otherwise violate. A comment restating the line beneath it is noise that rots.

This matters more than usual here because much of the module's behaviour is deliberately counter-intuitive — refusing writes from observers, dropping superseded snapshots, returning a usable store alongside an error. Each of those looks like a bug until you know why.

Errors are named sentinels

Every failure a caller might branch on is a package-level Err… value matched with errors.Is. No string comparison, ever. If you add a failure mode a caller could reasonably handle differently, it needs a sentinel and a row in the relevant guide's error table.

ErrInternal means this module has a bug — it must never be reachable through ordinary misuse of the public API. If a caller can trigger it, it is the wrong error.

Interfaces stay small, and split by capability

Backend, WritableBackend and WatchableBackend are separate on purpose: being readable does not make a source writable or watchable, and a single fat interface would force every implementation to stub what it cannot do. Prefer adding a narrow interface over widening an existing one.

Public API changes

The module is consumed by other phpboyscout modules and by downstream tools. Before changing an exported name or signature, check what it costs those consumers, and say so in the spec. just deadcode finds exported symbols nothing reaches.

Documentation

The site follows Diátaxis: tutorial, how-to, explanation, reference. Put a change where it belongs rather than where it is convenient — a guarantee readers need to cite belongs in explanation, and a recipe belongs in a how-to.

Two rules that exist because breaking them has cost real time:

  • Every code sample must be run, not merely written. Several guides have shipped snippets that could not compile — a helper never defined, a //go:embed at function scope, an output block showing three lines for two changes. Where a sample is important enough to be load-bearing, back it with a test: custombackend_test.go and coherence_test.go both exist to keep a documented claim honest.
  • Never document behaviour without checking it against the spec. Behaviour that contradicts an approved decision is a divergence to fix, not a caveat to write down. This has happened twice; both times the documentation was written first and the divergence found afterwards.
zensical build                 # build the site into site/
zensical serve -a 0.0.0.0:8000 # live-reloading preview

Do not run zensical build while zensical serve is running

Both use site/. Building — or cleaning up after a build — empties the directory the dev server is serving from. The server keeps listening and returns 404 for every page, which looks exactly like a broken site. Restart the server.

Further reading