A self-extending type registry in Go
In a telephony system, the record you write for a call — who called whom, when it
rang, how it ended, what it cost — comes in many shapes. An inbound call, an
outbound call, a transferred call, a call handled by an AI agent: each carries
different fields and follows a different lifecycle. The naive way to handle that
is a growing switch on a type tag, and it rots exactly the way you’d expect.
The shape of the problem
These records flow through an asynchronous queue: something produces a record, something else decodes and persists it later. The decoder needs to turn a tag on the wire (“this is an outbound-call record”) into the right concrete type. A big type switch in the decoder means every new record type is a change to the decoder — a central chokepoint that every contributor has to touch, with no compile-time help if you forget a case.
The registry pattern
Instead, each record type registers itself. At package initialisation, a type declares “my tag is X, and here’s the factory that builds me”:
func init() {
Register("outbound_call", func() Record { return &OutboundCall{} })
}
The decoder becomes dumb and permanent: read the tag, look up the factory, build
the record, hand it on. Adding a new call type is now purely additive — drop in
a new file with its own init(), and the decoder extends itself without anyone
touching it. New variants can’t silently break old ones, and each type can be
tested in complete isolation.
The honest exception
There’s exactly one case that doesn’t fit the async pattern: the start of an inbound call. That record has to be written synchronously, because everything downstream needs its database ID immediately to correlate the rest of the call’s events against it. You can’t queue it and move on; you need the ID now.
The temptation is to treat this as a grubby special case and bury it. The better move is to treat it as an invariant and write it down: “inbound-call-start is synchronous, on purpose, because downstream correlation requires the ID before the next event arrives.” One sentence in a design doc turns a confusing exception into a rule the next person can trust.
Why I like this
Self-extending registries are at their best when new variants are genuinely additive and the system’s job is to dispatch, not to decide. The win isn’t cleverness — it’s that the central decoder never has to change again, and the one place reality refused to be uniform is documented as a deliberate choice rather than discovered as a surprise.