
Routers: The Gnome Village Mailmen
Routers are the mailmen of the gnome village. They don’t bake bread, run shops, or manage anything. They stand at the sorting table, look at the envelope, read the address, and forward the message to the right place.
That is the entire job description. If a Router starts doing anything other than sorting and delivering mail, it has stopped being a Router and started being something else.
This post defines the Router archetype, shows the common subtypes, and explains how to keep Routers from drifting into Gatekeeper or Observer territory.
What is a Router?
A Router does one thing, in four small steps:
- Receive a message
- (Optionally) inspect it
- Decide where it should go
- Forward it
Nothing more.
Preferably with no side effects, no domain logic, no shared state, and no flow control. Occasional logging or trivial routing logic is fine, but anything beyond that belongs to another archetype.
Routers shape message flow. They do not own it, regulate it, or interpret it. They simply send each message to its destination.
What a Router Is Not
Before we define the subtypes, it’s important to keep the boundaries clean.
A Router is not a Gatekeeper. It does not:
- limit, throttle, or sequence messages
- enforce ordering
- block or regulate flow
- protect a resource
A Router is not an Observer. It does not:
- monitor
'DOWN'messages - restart failing processes
- track health
- enforce invariants
A Router is not a Resource Owner. It does not:
- hold domain state
- own a session, connection, or user
- aggregate or accumulate data
If a process starts “helping out,” it is no longer (just) a Router. It is not necessarily always bad, but it makes it harder to reason about the system.
Router Subtypes
These are the Router types you actually see in real systems. They fit the archetype precisely and avoid stepping into Gatekeeper or Observer responsibilities.
1. Broadcaster: Fan Out
One message comes in. Many messages go out.
handle_info({broadcast, Msg}, State) ->
[Pid ! Msg || Pid <- State#state.subscribers],
{noreply, State}.
Used for:
- pub/sub
- cache invalidations
- event fan-out
No filtering. No rate control. No state. Pure fan-out.
2. Director: Route to the Right Process
The classic Router. Looks at metadata, picks exactly one target.
handle_info({msg, UserId, Data}, State) ->
Pid = maps:get(UserId, State#state.sessions),
Pid ! Data,
{noreply, State}.
Used for:
- user/session routing
- mapping logical IDs to processes
- request dispatch inside a system
Directors are the backbone of large BEAM systems.
3. Load Balancer and Worker Pools
A load balancer is a Router that picks one worker out of many using a simple, predictable strategy:
- round-robin
- hash-based
- random
- “lowest mailbox length”
Routers can keep lightweight routing metadata (like a counter for round-robin), but nothing domain-specific or persistent. The job is routing, not state.
This is exactly what the process in front of a worker pool does. The pool manager in poolboy and similar libraries is a Router:
- it receives a job
- picks a worker
- forwards the job
- and stops there
The workers behind the pool may also act as Resource Owners if they hold long-lived resources (like a DB connection), but that does not change the Router’s role. It is still just forwarding messages.
The key property remains: the Router does not apply backpressure or enforce limits. If it starts doing that, it is no longer (just) a Router, it has become a Gatekeeper.
4. Demultiplexer (Message Splitter)
Split one inbound stream into several logical channels based on message shape.
Example:
case Event of
{audit, D} -> AuditPid ! D;
{analytics, D} -> AnalyticsPid ! D;
{telemetry, D} -> TelemetryPid ! D
end.
Used for:
- separating analytics from real-time paths
- multi-purpose message streams
- protocol front-ends
Demuxing is classification only.
5. Multiplexer (Message Merger)
A Multiplexer is the reverse of a Demultiplexer: many inbound streams, one unified forward path. It simply forwards messages from several sources into a single output channel.
Used for:
- bridging legacy single-threaded systems
- merging updates from several producers
- simplifying downstream processing
Pure multiplexing is still just forwarding. No buffering. No priority. No state.
When Multiplexers Drift Into State
Sometimes you want more than merging. For example:
- you need ordering across streams
- or you need to batch or aggregate messages before sending them downstream
- or you need to emit one combined payload (e.g. a JSON array or binary packet)
- or you need to coordinate replies from multiple workers before acting
The moment you introduce:
- buffering
- collecting
- waiting for multiple sources
- sequencing
- assembling a single final result
…you are no longer implementing a Router.
You have quietly moved into Resource Owner (state + transformation) or Gatekeeper territory (sequencing, synchronization).
That isn’t wrong, but it is a different archetype.
A Router does not shape when messages flow or how many messages become one. It only shapes where messages flow.
6. Protocol Router
Routes based on protocol, after the smallest possible decoding step.
Used for:
- TCP front-ends handling several protocols
- gateways
- hybrid endpoints (WebSocket + HTTP + JSON-RPC)
A Protocol Router chooses handlers based on shape, not behaviour. If it enforces rules or validates messages, it becomes a Gatekeeper.
Here is a minimal, slightly contrived example:
%% protocol_router.erl
handle_info({tcp, Sock, Bin}, State) ->
case decode(Bin) of
{http, ReqBin} ->
State#state.http_handler ! {Sock, ReqBin};
{jsonrpc, JsonBin} ->
State#state.jsonrpc_handler ! {Sock, JsonBin};
{websocket, FrameBin} ->
State#state.ws_handler ! {Sock, FrameBin};
_Other ->
%% Unknown protocol: hand off to a generic handler
State#state.fallback_handler ! {Sock, Bin}
end,
inet:setopts(Sock, [{active, once}]),
{noreply, State}.
The decode/1 function does only the bare minimum required to classify the message:
decode(<< "{", _/binary >> = Bin) ->
{jsonrpc, Bin};
decode(<<"GET ", _/binary>> = Bin) ->
{http, Bin};
decode(<<0x81, _/binary>> = Bin) ->
{websocket, Bin};
decode(Bin) ->
{unknown, Bin}.
Key points
- The Router reads bytes from the socket.
- Performs a minimal test to classify the protocol, not a full parse.
- Forwards the original payload to the appropriate handler.
- Does not enforce sequencing, limits, authentication, or retries.
- Keeps no state beyond handler PIDs.
A note of caution
In a real system, you probably do not want to parse the payload inside the Router. Parsing large JSON or WebSocket frames in the Router turns it into a bottleneck, or a denial-of-service surface. A flood of oversized JSON messages can stall the Router, preventing it from classifying the next packet.
As Richard Carlsson pointed out in a review of this post:
A Protocol Router should avoid parsing payloads. It should classify quickly and forward immediately. Let the handler process deal with parsing and errors.
7. Shard Router
A particular instance of a Director with predictable routing based on key hashing.
Shard = erlang:phash2(Key, NumShards),
lists:nth(Shard, ShardPids) ! Msg.
Used for:
- per-user or per-account isolation
- scalable ledger domains
- consistent partitioning
Shard Routers create isolation boundaries, not ordering guarantees.
Summary
Routers keep the village moving. They don’t decide when messages should flow, or how fast, or what happens if something goes wrong. They only decide where messages go.
Keeping Routers simple makes debugging obvious and system behaviour predictable. If you give a Router too much responsibility, it stops being a Router.
Next up: Gatekeepers, the archetype that actually cares about flow, limits, and containment. They do the work Routers deliberately refuse to do.