← Engineering
· 8 min read

No broker, on purpose


Open almost any “how we built it” post about a system that might one day scale, and somewhere near the architecture diagram there’s a message broker — RabbitMQ, Kafka, a cloud queue — sitting between the services like it’s a law of nature.

MMO Reconnect doesn’t have one. No RabbitMQ, no Kafka, no SQS, no queue of any kind in the running production system — and that absence is deliberate, not an oversight. We wrote “no broker in production” into the architecture’s non-negotiables as an explicit constraint, so it’s something you can point at rather than something we merely never got around to adding. That’s not a corner we cut to ship faster, and it’s not a bill we’re deferring until it hurts. It’s a considered call, and the reasoning is worth writing down — because it runs against the reflex.

One service, built to split

Start with the shape. At launch, MMO Reconnect is one deployable service. Backend and frontend live in one repository; the API is a single .NET process.

But “one service” is a starting posture, not a destination. The codebase is organized into self-contained slices — claims, search, identity, moderation — and there’s one rule they all obey: slices integrate through events and messages, never through cross-slice internal calls. The claims slice doesn’t reach into the search slice and call a method. It publishes a fact — this claim was removed — and whoever cares reacts to it.

That rule is the whole game. It’s the discipline that makes the system split-ready: because slices already talk the way separate services would, pulling one out into its own process later is a deployment exercise, not a rewrite. And — this is the part people miss — you can hold that discipline entirely in code. It does not require a broker.

Your event store is already a message log

Here’s the thing a broker gives you that feels irreplaceable: a durable, ordered, replayable log of messages that decouples the sender from the receiver. Producer writes, consumer reads, nobody blocks, nothing is lost if a consumer is down.

We already have exactly that. MMO Reconnect is event-sourced on Marten, which means nearly every meaningful state change is already an immutable, ordered event persisted in PostgreSQL. That log isn’t a copy of the truth we ship off to a queue — it is the truth. And a read model is just a projection that reads down that log: Marten’s async daemon feeds each new event to the projections that care, while Wolverine handles the commands that write those events and hosts the daemon — all in-process.

So when the claims slice removes a claim, the removal is appended to that claim’s event stream, and the search slice’s projection — reading the same log a beat later — drops the row. That interaction is durable (it’s in the store), ordered (the log is ordered), replayable (rebuild the read model any time by replaying the log), and decoupled (the claims slice has no idea search exists). Every property you’d reach for a broker to get, the event store already provides — with the bonus that it’s the system of record, not a second thing to keep in sync with it.

graph LR
    subgraph proc["One .NET process — no transport, no network hop"]
        claims["Claims slice"]
        store[("Event store<br/>Marten · Postgres")]
        daemon["Async daemon"]
        proj["Search projection"]
        index[("search_index")]

        claims -->|"AppendOne(ClaimRemoved)"| store
        store -->|"ordered log"| daemon
        daemon -->|"feeds each event"| proj
        proj -->|"delete row"| index
    end

    classDef hero fill:#2a2410,stroke:#fec229,stroke-width:2px,color:#f4f4f5;
    classDef slice fill:#1c2b28,stroke:#5bc1a6,color:#f4f4f5;
    class store hero;
    class claims,proj slice;

The log is the transport. Nothing in that path publishes to a queue or crosses a network boundary — the durability is just the event already sitting in Postgres.

// The claims slice appends a fact to its stream. It doesn't know — or care —
// who consumes it, or whether that consumer lives in this process or another
// one someday.
stream.AppendOne(new ClaimRemoved(claimId, OwnerRemoved, now));

// The search slice owns a projection. Marten's async daemon reads the ordered
// log and hands it each ClaimRemoved — no publish, no transport, no handler
// subscribed to a queue. The durability is the event already being in the store.
public void Project(ClaimRemoved e, IDocumentOperations ops)
    => ops.QueueSqlCommand("delete from search_index where claim_id = ?", e.ClaimId);

The projection above doesn’t mention a transport. That’s not an accident — it’s the payoff.

What a broker would have bought us at launch: nothing we can use yet

A broker earns its keep when you have multiple processes exchanging messages across a network: independent services that scale separately, fan-out to several consumers on different machines, backpressure between systems moving at different speeds, delivery across a boundary that in-process method dispatch can’t cross.

At one service, every one of those is answered without leaving the process. There is no network hop to make reliable, no second consumer to fan out to, no independent scaling to coordinate. Dropping RabbitMQ into that picture doesn’t unlock a capability — it adds a component to provision, secure, monitor, and reason about (at-least-once delivery, deduplication, poison messages, dead-letter queues), plus a fresh way for production to break, in exchange for zero functionality we could actually call on. It’s infrastructure bought to solve a problem we don’t have.

And there’s a real bill attached. The whole platform runs for something like $16–25 a month — a container app, a managed Postgres, a static site at the edge, and a cookieless analytics plan. A broker isn’t just conceptual complexity; it’s another managed resource, another line item, and another thing a small team has to keep alive at 2 a.m. The cheapest, simplest design that is also correct wins, and here it happens to be all three.

The discipline is the point, not the deferral

It would be easy to misread this as “we’ll bolt a broker on when it starts to hurt.” That’s not it. The discipline — slices talking only through events and messages — is in the code today, whether or not a broker ever appears.

That’s what makes the deferral safe. The day one slice genuinely needs to become its own service, the integration contract already exists: it’s the events that slice writes and consumes. Splitting it out means carrying those same events across the network to the new process — it subscribes to the event stream instead of reading the log in-process — while the projection that drops a search row stays exactly as it is above. You buy the broker at the moment you add the second service and that stream has to cross a network hop, when it finally starts doing the job brokers are for, and not one day sooner.

This is a house style, not a one-off. The same reasoning governs a cache: our identity data is shaped so a cache layer could slot in later, but a Postgres primary-key lookup is plenty at launch, so Redis is ruthlessly deferred. Design so it’s possible; wait until it’s needed. The instinct to add a component — cache, broker, staging environment, admin panel — is usually solving a problem the current version doesn’t have yet.

The exception that proves the rule

Full honesty: there is exactly one place a broker touches this system, and how we handled it is the tell.

We run a local-only monitoring console (CritterWatch) in development, and its telemetry side-channel wants RabbitMQ. So RabbitMQ exists — confined to that dev tooling, wired in only when you’re running locally with a connection string present. The Azure deployment never sees it, never gets that connection string, and the production integration backbone is untouched: still just Wolverine in-process over the event store.

The detail that matters: rather than quietly soften the “no broker” rule to accommodate this, we annotated the rule with the explicit exception. The constraint stayed a constraint; the one place it doesn’t apply is written down as a named carve-out, not a silent narrowing. A principle you’re allowed to erode invisibly isn’t a principle — it’s a preference. Keeping the exception loud is how “no broker in production” stays true even after a broker shows up in the repo.

The takeaway

“No broker” isn’t a stance against scale. It’s a stance about timing.

An event store is already a durable, ordered, replayable message log, so an event-sourced system gets decoupled, message-based, split-ready integration essentially for free — no queue required. That lets you keep the architectural discipline that makes future service extraction cheap while skipping the operational tax a broker charges every single month, whether or not you’re using it.

Add the broker when you add the second service and the network hop it has to cross — the moment it earns its keep. Until then, the best broker is the one you didn’t deploy.