Skip to content

messaging

Two services need to talk. The obvious answer is a channel between them, and it works until the day one of them moves to another process, at which point the channel is a rewrite rather than a configuration change.

This module is the layer that makes that day cheap. A domain service knows two things: this module's interface, and go/cloudevents. It never imports a transport, so moving the carrier from a Go channel to an in-process broker to a cluster somebody else operates changes wiring only.

bus, err := messaging.New(memory.New(), messaging.Settings{
    Subscriptions: []messaging.SubscriptionSpec{
        {
            Name:    "answers",
            Pattern: "question.>",
            Shape:   messaging.Fanout,
            Handler: func(ctx context.Context, d messaging.Delivery) error {
                return answer(ctx, d.Event)
            },
        },
    },
})

A bus, and a broker

A bus is the thing services put messages on. A broker is the server that carries them, and NATS is one. Naming them apart is deliberate rather than fussy: a system that holds both should not call them the same thing on the first day.

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.

What you are buying

Four guarantees, and they are this module's rather than the backend's:

  • Every subscription is bounded. Not configurable. An unbounded queue is not a policy; it is a decision to fail once memory is gone rather than at a number somebody chose.
  • Every discard is counted, and attributed to the subscription it happened to. A discarded message is gone with no signal to whoever sent it, so the count is the only evidence it existed, and an aggregate answers "something was dropped" when the useful question is "which consumer is behind".
  • Subscriber code never runs on a backend goroutine. That is what lets a panicking handler stop its own subscription instead of the process.
  • The unit is a CloudEvent. A Go pointer handed down a channel does not survive a process boundary; a marshalled event does.

What it refuses, and what a backend can declare instead

The bus itself keeps nothing: no queue of its own, no replay, no state to operate. That is a refusal rather than an omission: a bus that gains state has gained a component somebody has to operate, meaning backup, migration, corruption, capacity, a second thing to be down.

What a backend can do beyond that, it declares in Capabilities and is held to by the conformance suite: at-least-once delivery under a DurableName, storage that survives a restart, a lease it can extend, wildcard patterns, per-subscriber ordering, replay from the oldest message it holds. The in-memory backend declares almost none of it, honestly; a broker-backed one declares what its broker earns. The guarantee under every one of them is that a failure is counted, not that it did not happen.

If you want a delivery guarantee stronger than what a declared capability gives you, that is a different component with a different name.

Where to go next

Design decisions live on the wiki, starting with spec 0001.

Status

Released at v0.1.0, pre-1.0 and tracking its specs. Four provider modules implement the same conformance suite this module ships: messaging-nats (core NATS and JetStream), messaging-sqs (FIFO and standard), messaging-rabbitmq (quorum queues) and messaging-amqp (AMQP 1.0, Artemis first). The in-memory backend in this module talks to no broker at all, which is what makes getting started possible with no infrastructure.