Cross-Module Communication
When messages need to reach components in a different module, they leave the pod over the cross-module wire. TinySystems supports two substrates behind the same contract:
- NATS — the primary wire, used whenever
TINY_NATS_URLis set. - gRPC — the fallback, used when
TINY_NATS_URLis unset (see gRPC Fundamentals).
Both satisfy the same runner.Handler shape, so the scheduler — and your components — never know which wire carries an edge. The blocking I/O model is preserved on all of them: the sender blocks until the receiver's handler (and everything downstream of it) returns.
Transport Selection
Selection happens once at module startup (cli/run.go):
| Environment | Wire |
|---|---|
TINY_NATS_URL unset | gRPC via AddressPool |
TINY_NATS_URL set | NATS core request/reply (default) |
TINY_NATS_URL set + TINY_NATS_TRANSPORT=jetstream | JetStream-backed durable wire |
If TINY_NATS_URL is set but the connection fails, the module logs a warning and falls back to gRPC.
Overview (NATS core)
+-----------------------------------------------------------------------+
| CROSS-MODULE COMMUNICATION OVER NATS |
+-----------------------------------------------------------------------+
common-module Pod http-module Pod
+----------------------------+ +----------------------------+
| +----------------------+ | | +----------------------+ |
| | Router Component | | | | HTTP Server | |
| | handler() ----------|--|--- NATS ----|->|-> Handle() | |
| +----------------------+ | req/reply | +----------------------+ |
| | | | ^ |
| v | | | |
| +----------------------+ | | +----------------------+ |
| | Scheduler | | | | Scheduler | |
| | not local -> | | | | Handle(ctx, msg) | |
| | transport.Handler | | | | (any, error) | |
| +----------------------+ | | +----------------------+ |
| | | | ^ |
| v | | | |
| publish to subject | | QueueSubscribe on |
| tinymodule.http-module | | tinymodule.http-module |
| .msg, block on reply | | .msg (queue group = |
| | | module name) |
+----------------------------+ +----------------------------+
Subject Layout
| Subject | Purpose |
|---|---|
tinymodule.<module-name>.msg | Business-port messages. Each module pod subscribes with a queue group equal to its module name, so N replicas load-balance incoming messages |
tinymodule.<module-name>.sysmsg | System-port writes (_control, _settings, _reconcile, _identity). Fan-out: every pod of the module receives every message; each component's in-handler IsLeader check decides whether to act |
The target module name is parsed from the message's To full name; the transport publishes to that module's subject.
Wire Metadata
Metadata travels in NATS headers:
| Header | Meaning |
|---|---|
x-from | Source full port name |
x-to | Target full port name |
x-edge-id | Edge ID that carried the message |
x-message-depth | Hop count, for cycle detection (MaxMessageDepth = 256) |
x-error | On the reply only — non-empty means the handler failed |
x-error-code | Set when the component marked the failure with module.NonRetryable(code, err); lets the edge-retry loop short-circuit |
x-empty | On the reply — the handler succeeded with no data |
traceparent | W3C trace context for OpenTelemetry propagation |
Blocking Semantics
Core NATS uses request/reply: the sender blocks on RequestMsgWithContext until the responder writes back on the reply inbox, the caller's context is cancelled, or — when the context carries no deadline — the transport's default timeout (5 minutes) fires. Flows with longer-running components must pass an explicit deadline.
On the receiving side, every incoming message dispatches into the scheduler:
Handle(ctx context.Context, msg *runner.Msg) (any, error)
The any return is the synchronous response payload that flows back over the reply — this is how blocking I/O components (like http-server) receive responses computed several modules away. An error return travels back in the x-error header (plus x-error-code for non-retryable failures) and surfaces to the sender as a Go error.
common-module NATS http-module
| | |
| handler("http-server:req")| |
| ==========================+========================= |
| publish + block | |
| | ------- deliver -------> |
| | Handle() |
| | | |
| | (processing) |
| | | |
| | <---- reply on inbox --- |
| return (response data) | |
| <=========================+ |
v v v
JetStream: the Durable Wire
With TINY_NATS_TRANSPORT=jetstream, requests publish to a durable work-queue stream (module-edges) instead of a plain subject:
- Pod-death recovery: receivers run as durable consumers. If a pod dies mid-handler, the broker redelivers the message to another replica after
AckWait(30s) expires. Live handlers extendAckWaitwithInProgressticks every 10s, so long-running edges (e.g. slow LLM calls) stay in flight on the pod that owns them. - Bounded redelivery:
MaxDeliver= 3 caps broker-driven redelivery; a message that keeps killing pods is dropped rather than looping. - Single-shot on handler error: when a handler returns an error and a caller is waiting on the reply, the error is sent back and the message is
Term-ed — never redelivered. The runtime does not silently retry logical failures. - Fire-and-forget durable hops (durable execution mode,
RunIDset): the sender returns as soon as the broker acks the publish — no reply wait — and the hop carries an idempotency key so a redelivered handler's re-emit dedupes. On these hops only, a failure the component marked transient (module.ShouldRetry(err)true) isNak-ed for broker redelivery instead ofTerm-ed, bounded byMaxDeliver. - Replies still travel on a core-NATS inbox — the caller is the only reader, so no durability is needed there.
Retries Are Per-Edge and Explicit
The runtime never retries edge dispatch implicitly. Authors opt in per edge via retryPolicy on the edge (see Internal Routing for the EdgeRetryPolicy shape). Components signal permanently-failing errors with module.NonRetryable(code, err); the transport stamps the code on x-error-code and the retry loop short-circuits when the code matches the policy's nonRetryableErrorCodes.
Error Handling
// Sender side — both wires surface remote failures as Go errors
res, err := s.msgHandler(ctx, outMsg)
if err != nil {
// Route to an error port, or let it propagate to the caller
}
Component-level best practice is unchanged: catch downstream failures and emit them on a dedicated error port rather than swallowing them:
func (c *Client) Handle(ctx context.Context, handler module.Handler, port string, msg any) module.Result {
res, err := c.callRemoteService(ctx, msg)
if err != nil {
return handler(ctx, "error", ErrorOutput{Error: err.Error()})
}
return handler(ctx, "success", res)
}
Best Practices
1. Minimize Cross-Module Hops
Group tightly-coupled components in the same module when possible — an in-process hop is a direct function call; a cross-module hop is a network round trip:
Good:
+-------------------------------------+
| http-module |
| +---------+ +---------+ |
| | Server |-->| Parser | |
| +---------+ +---------+ |
| | |
| v |
| +---------+ |
| | Router |--> (to other modules) |
| +---------+ |
+-------------------------------------+
2. Set Deadlines on Long Chains
Without a context deadline, a blocked cross-module call is capped by the transport default (5 minutes). Pass an explicit deadline when the caller needs a tighter or looser bound; per-edge timeoutMs in the retry policy caps a single dispatch attempt.
3. Use Durable Mode for Runs That Must Survive Pod Death
Flows marked with the durable execution mode ride the JetStream wire fire-and-forget, so a mid-run pod reschedule migrates the run instead of failing it.
4. Watch Depth on Cyclic Graphs
Every hop increments the message depth; exceeding MaxMessageDepth (256) returns a permanent error. If you hit it, look for an unintended cycle in the flow graph.
Next Steps
- Client Pool - gRPC fallback connection management
- gRPC Fundamentals - The fallback wire in detail
- Internal Routing - In-module routing and edges