skip to content

Your team publishes a slog.Handler other teams import. What do you freeze in its contract before tagging?

level: principalimportance: nice to knowfreq 20%

answer

  1. signatures are the smallest part of the contract
  2. three audiences, only one is the compiler
  3. who is paged when the sink stalls
  4. the error nobody ever sees
  5. document less, test what you documented

basics

~10 s

Freeze the behaviour importers cannot see in a signature: the output field names and framing, whether Handle blocks or drops when the sink stalls, what Enabled promises, and what a silent write failure does.

solid answer

~50 s

The compiler only protects the signatures; what actually breaks importing teams is behaviour. I would write down four commitments. First, the output field names and framing, because downstream log pipelines and dashboards parse them — renaming a key is a breaking change that compiles cleanly. Second, whether `Handle` writes synchronously and may therefore block a request goroutine when the sink stalls, or queues and drops; both are defensible, but you cannot swap one for the other later without changing every consumer's failure mode. Third, what `Enabled` means — a pure level comparison, or something that samples — since anything non-deterministic there makes consumers' own `Logger.Enabled` checks unreliable. Fourth, what happens when a write fails, given that `slog.Logger` discards the error `Handle` returns. Everything else — buffer sizes, key ordering, whether attributes are pre-formatted — I would document as unspecified, and keep the struct's fields unexported so those choices stay mine.

go deeper

for a junior

Understand that other teams importing your package depend on more than the function signatures, and that changing what your log output looks like affects them.

for a middle

Be ready to name concrete behaviours a handler should document: output field names, concurrency safety, and what happens when the write fails.

for a senior

Show how you would enforce it — the standard contract suite in CI, a sibling-logger independence test, a race test, and a golden test over field names — and how you would land an additive change.

for a principal

Own the tradeoff between a tight additive-only contract and your ability to evolve the library, and argue it from who absorbs the migration cost and who carries the pager when the sink stalls.

## What is actually being frozen When a handler ships as a module that a dozen services import, three different audiences depend on it and only one of them is the compiler. - **The importing engineer** depends on the constructor and options. - **The service on call** depends on its failure behaviour: does a stalled sink stall requests? - **The log pipeline** depends on the bytes: field names, nesting, framing. A change that keeps every signature identical can break the second and third audiences completely. That is why the API freeze conversation has to be about behaviour, and why it is a decision a platform or architecture owner can legitimately overrule before the tag exists — and cannot afterwards. ## The commitments worth making explicit **The record's shape.** The key names for time, level and message, the nesting rule for groups, and the framing (one record per line). Downstream, someone has built an alert on a field name. Publishing the shape as part of the contract means a rename is treated as the breaking change it is, rather than a formatting tweak that ships on a Tuesday. Following the conventional `time`/`level`/`msg` keys is worth doing here precisely because it makes your handler interchangeable with the standard ones. **Blocking behaviour.** A handler that writes synchronously under a lock will make a slow sink into request latency. A handler that hands records to a background goroutine will not, but must then define its queue bound and what it drops when full, and must clone anything it retains beyond `Handle`. Whichever you choose, importers build their capacity assumptions on it, so it goes in the contract. "Never blocks" is the more dangerous promise, because it forecloses ever making the path synchronous again. **What `Enabled` promises.** If it is a level comparison against a `slog.LevelVar`, consumers can reason about it and use their own `Logger.Enabled` guard before assembling expensive arguments. If it also samples or rate-limits, that guard becomes a coin flip and debugging a missing log line becomes an escalation to your team. Sampling can still be right — but it is a published property, not an implementation detail. **Write failures.** `slog.Logger`'s logging methods discard the error `Handle` returns, so nothing observes it by default. Decide and publish what your handler does: count failures in a metric, fall back to a second sink, or fail silently. Leaving it undefined means every consumer discovers your answer during an incident. **Concurrency and derivation.** Safe for concurrent use, and derived handlers independent of each other and of the parent, are properties you promise, not features. They are also cheap to guarantee and expensive to retrofit. ## What to leave unspecified, deliberately Every property you document, you own. Field ordering within a record, internal buffer sizing, whether `WithAttrs` pre-formats its attributes, the exact allocation profile, the internal type names — say plainly that these may change. Keep struct fields unexported and prefer a constructor with option functions over an exported options struct, so adding a knob later is additive. Returning the concrete `*Handler` type from the constructor is usually right — it lets you add methods without breaking anyone — but understand that every exported method you add is then permanent, so add none you do not need. ## How the decision gets made and enforced The judgment is not purely technical. Two things push it: - **Who absorbs a change later.** If you can migrate every consumer yourself in an afternoon, you can afford a looser contract; if consumers are twelve teams on their own release schedules, the contract must be tight and additive-only, and a behaviour change needs a new option defaulted to the old behaviour. - **Who is paged.** If your team is not on call for the services that import this, you cannot unilaterally choose a failure mode that turns a sink outage into their latency incident. Enforcement is what makes the commitment real: run the standard contract suite in CI so the structural rules cannot regress, add a test that derives sibling loggers and asserts they do not share attributes, add a test that logs concurrently under the race detector, and add a golden test over the emitted field names so a rename cannot land accidentally. A contract nobody tests is a comment. ## The failure this avoids The bad version of this story is a handler that ships quietly, twelve services adopt it, and eight months later a well-meaning optimisation changes a key name, buffers output, or starts dropping under load. Each of those is invisible in code review, invisible to the type checker, and visible at 3am. Writing the four commitments down before the tag costs an afternoon and is the difference between a shared library and a shared liability.

  • You decide the handler must not block a request goroutine. What does that oblige you to specify?
    The queue bound, the policy when it is full (drop newest, drop oldest, or block after all), whether dropped records are counted anywhere, and the fact that records are retained past Handle — which means cloning them. It also obliges you to say what happens at shutdown, since a background writer that is never drained loses whatever it still holds.
  • A consuming team asks for a new output field. How do you ship it without breaking anyone?
    Additively: a new key is safe for parsers that select fields by name, so it goes in behind an option defaulted off if there is any doubt, and on by default only once the pipeline owners confirm it. Renaming or re-nesting an existing key is the change that needs a coordinated migration, and it is worth saying so in the same reply.
  • Would you return the concrete handler type or slog.Handler from your constructor?
    The concrete type, usually — it lets consumers use extra methods and lets you add them later without a breaking change, while they can always store it in a slog.Handler. The cost is that every exported method and field becomes permanent surface, so keep fields unexported and add methods only when a consumer has an actual need.

saying these in an interview costs you the question

  • Treats compiling signatures as the whole compatibility story
  • Renames output field names as a formatting change
  • Leaves write-failure behaviour undefined
  • Documents internal buffer sizes and ordering as guarantees
  • Promises the handler never blocks without defining the drop policy
  • Publishes a contract with no test enforcing it