Skip to content

A bus, not a broker

A bus is the thing services put messages on. A broker is the server that carries them.

That distinction is not fussiness about words. This estate has already paid for the same ambiguity twice: scope doing two jobs, and tenant found doing three jobs in one database column. A system that holds both a bus and a broker should not call them the same thing on the first day, because the day you need the distinction is the day it is expensive to introduce.

So: services publish to a bus, the bus has a backend, and a backend may or may not talk to a broker. The in-memory one does not, and it is not a lesser bus for that.

What the distinction buys

A domain service depends on the bus and never on the broker. It imports this module and go/cloudevents, and nothing else. It cannot accidentally reach for a NATS type, because NATS is not in its dependency graph — enforced by a test rather than by review.

That is dependency inversion doing the job it is for: the high-level policy (a gateway, a worker) and the low-level detail (NATS) both depend on an abstraction this module owns.

Why the unit is a CloudEvent

The obvious alternative is any, or []byte, and both are worse for the same reason.

A Go pointer handed down a channel does not survive a process boundary. If the bus carries any, every service works beautifully until the day the carrier changes, and then every message type is a migration. The whole promise — that moving from a channel to a cluster changes wiring only — is void.

Bytes push the envelope decision to every consumer, and they will each answer it differently. That is how two services end up unable to read each other's messages while both believing they use the same bus.

A CloudEvent also settles a question that would otherwise recur: a bus carrying more than one kind of message needs no union type and no second bus, because type discriminates.

The cost, stated

You cannot put a non-serialisable thing on this bus. That is the trade, and it is the right one for what this module is for, but it does exclude some things you might reasonably want to send — an audio buffer being the case that came up first. See what this module does not do.

The origin, since it explains the shape

An earlier design specified a service's messaging directly against go/nats. That produced a design in which the nats.Subscribe handler was the seam, so a subscriber's panic ran inside the connection's goroutine and killed the process — measured, not assumed, and the sibling subscriber received nothing.

Nothing about NATS required that. It followed from having no layer that owned the boundary. This module is that layer, and most of its decisions are downstream of that one failure.