Content-Based Routing
Overview
Section titled “Overview”Content-based routing makes delivery decisions at runtime based on what is in the message. A filter on the 'beforeConsuming' stage inspects the envelope — its headers and raw body — and either lets it continue to the local handler, redirects it to a different queue via bus.send(...), or drops it outright. The producer does not need to know the routing rules; the consumer side gets to evolve them without changing the wire contract.
When to use
Section titled “When to use”Use content-based routing when the right destination depends on data the producer either does not know or should not have to think about: regional sharding, tenant isolation, A/B partitioning, audit-only mirrors, or quarantining traffic from a deprecated client version. The producer publishes a single typed message; the routing layer decides where it actually goes.
It is also useful for graceful migrations — temporarily steer a fraction of traffic to a new handler, or shadow production traffic to a staging queue, by toggling the routing filter rather than redeploying the publisher.
When not to use
Section titled “When not to use”If the routing decision is static and known by the caller, a routing slip or a direct bus.send to the right endpoint is simpler and cheaper — no filter to maintain. If you find yourself encoding business logic in a routing filter, that logic probably belongs in a handler instead; filters should classify, not transform.
Example
Section titled “Example”The destination type must be registered before the filter can re-send it — bus.send throws MessageTypeNotRegisteredError for an unregistered type:
import { FilterAction, asFilter } from '@serviceconnect/core';
bus.registerMessage<AuditEvent>('AuditEvent');
bus.use( 'beforeConsuming', asFilter(async (envelope) => { const region = envelope.headers.region; if (region && region !== 'eu-west-1') { // The filter runs BEFORE deserialization, so the envelope carries only `headers` // and the raw `body` (Uint8Array). Decode the body explicitly to forward it. const payload = JSON.parse(new TextDecoder().decode(envelope.body)) as AuditEvent; await bus.send('AuditEvent', payload, { endpoint: `audit-${region}` }); return FilterAction.Stop; } return FilterAction.Continue; }),);The filter inspects the region header. If the message is for this region ('eu-west-1') it returns Continue and the handler runs normally. Otherwise the body is decoded, re-sent to a region-specific audit queue as a registered AuditEvent, and the local pipeline stops — the local handler never sees it.
Behind the scenes
Section titled “Behind the scenes”- Filters receive the
Envelope—headersand the rawbody(Uint8Array). ThebeforeConsumingstage runs before deserialization, so there is nomessageobject yet; route on a header (the concrete type is available as themessageTypeheader) or decodebodyyourself. There is no separate “router” object; it is just a filter that returnsStopafter redirecting. - Combine
bus.send(...)(orbus.publish(...)) inside the filter withFilterAction.Stopto re-route a message without ever processing it locally. The local handler is skipped; the rerouted copy goes through the destination queue’s pipeline as a fresh delivery. - Routing slip is a different pattern: that’s a pre-baked sequence of destinations attached to the message; content-based routing decides at delivery time and the message itself does not carry an itinerary.