Service Communication

Status: DRAFT v0.1 — part of the two-tier pattern.

Orchestrators drive processes by calling core services, and core services never call each other. This document is about those calls: how a caller addresses a service, how an address becomes a concrete destination, and — on AWS — how the call is realized as Lambda-to-Lambda. It also covers the central-routing-lambda options and their cost/latency trade-offs, because getting inter-service routing right is what keeps this architecture fast and cheap.


Address by topic, not by transport

The application-level rule: a caller says what it wants, by topic, and nothing about where or how.

(informative, .NET) The call site is just a topic and a request:

// An orchestrator step. No queue URL, no ARN, no function name, no client type here.
IBenzeneResult<TenantCreated> result =
    await sender.SendAsync<CreateTenant, TenantCreated>("tenant:create", request);

SendAsync(topic, request) returns the same Result<TResponse> a handler returns, so a saga step's Do(...) is exactly this call and needs no adapter. The response type drives the delivery semantics: a real response type means request/response; a Void response means fire-and-forget (the caller does not wait).

Keeping destinations out of the call site is what makes the two tiers composable: an orchestrator is written against topics its saga needs, and where tenant:create lives — which queue, which Lambda, which region — is a deployment concern resolved by the routing table, not a line of process logic.


The routing table

A routing table maps each outbound topic to a concrete destination (a queue, a topic/ARN, a Lambda function, an HTTP endpoint). It is the one place addresses turn into destinations.

(informative, .NET) The table is declared once, at startup, in code:

services.AddOutboundRouting(routing => routing
    .Route("tenant:create", pipeline => pipeline.UseSqs(tenantQueueUrl).UseRetry(3))
    .Route("audit:log",     pipeline => pipeline.UseSns(auditTopicArn)));

Each Route builds a small outbound middleware pipeline for that topic; the pipeline encodes the transport and the destination. At runtime SendAsync("tenant:create", …) is a dictionary lookup by topic string onto that pipeline — an unknown topic is an UnroutedTopicException, not a silent drop.

Three properties make this safe at fleet scale:

What ships today, precisely (informative, .NET): the outbound router ships transport middleware for SQS and SNS (UseSqs, UseSns) — i.e. queue/event delivery is routed by topic today. The request/response Lambda path (below) is currently a per-function client rather than a topic-routed transport; wiring Lambda request/response in behind the same AddOutboundRouting(...) table is the natural next step, and the pattern is written to that goal. Treat "everything is addressed by topic" as the target shape; today SQS/SNS reach it through the router and Lambda request/response reaches it through the client just below.


The AWS realization: Lambda-to-Lambda

On AWS, the reference realization for an orchestrator→core call is a direct Lambda invocation. It suits this architecture unusually well:

(informative, .NET) The client wraps Lambda.Invoke and picks the invocation type from the response shape:

// A response type → InvocationType.RequestResponse (synchronous, typed result)
IBenzeneResult<TenantCreated> created =
    await lambdaClient.SendMessageAsync(new BenzeneClientRequest<CreateTenant>("tenant:create", req));

// A Void response type → InvocationType.Event (fire-and-forget)
await lambdaClient.SendMessageAsync(new BenzeneClientRequest<AuditLog>("audit:log", entry)); // TResponse = Void

The message's topic travels inside the envelope for the callee to route on — the topic does not choose the destination Lambda. Which function to invoke is a separate question, and it is the crux of everything below.


The routing problem, and the central-routing-lambda options

Direct Lambda-to-Lambda has one cost: the caller must know the callee's function name. A topic (tenant:create) is not a function name (prod-tenant-service); something has to bridge the two. There are three broad answers, and the difference between them is where and when the topic→function binding is resolved.

Option 0 — Bind it in the caller (no routing lambda)

The caller holds the topic→function map itself and invokes the target directly. This is what the routing table does: the binding is declared in the caller's wiring and resolved locally.

This is the default and, for most fleets, the right answer. The two "routing lambda" options exist for when you want the map to live in one place instead of every caller.

Option A — Routing lambda in the path (the double hop)

A central routing Lambda receives every inter-service message, looks up the destination, forwards the message, waits for the response, and returns it to the caller.

  caller ──▶ routing lambda ──▶ target lambda
  caller ◀── routing lambda ◀── target lambda        (two round trips, in series)

Option B — Routing lambda returns the name (resolve, then call direct)

The central routing Lambda is a directory, not a relay: the caller asks it "what is the function for tenant:create?", gets a name back, and then invokes the target directly. The router is never in the message path.

  caller ──▶ routing lambda            "tenant:create?"
  caller ◀── routing lambda            "prod-tenant-service"
  caller ─────────────────▶ target lambda    (direct; router not involved)

Where the binding lives

Option B's cache, and Option 0's map, can be populated at three different times — a spectrum from most-static to most-dynamic:

Bound at How Changes require Best when
Compile time The topic→destination map is code (the AddOutboundRouting table). A rebuild + redeploy. The map is stable. This sounds heavier than it is — builds are cheap and routine, and a compiled map is validated at startup and impossible to typo into a runtime surprise.
Startup / config The map is configuration (env, Parameter Store, a config file) read once when the service boots. A restart / redeploy of config. You want to retarget without a code change (blue/green, per-environment endpoints). Still resolved once, still validated at boot.
Runtime The map is fetched from the routing Lambda (Option B) on first use and cached; or loaded into a routing table at deploy time and refreshed. Nothing — the directory is the source of truth; callers pick up changes as their cache refreshes. The fleet is large or changes often and you cannot redeploy every caller to move one service.

The three are not exclusive. A common, robust shape is compile-time or startup binding as the default, with an Option-B routing Lambda as the dynamic fallback for topics not in the local table — static and fast for the stable core, dynamic for the parts that move.

What ships today, precisely (informative, .NET): Benzene's built-in outbound routing binds in the caller at startup, from code (AddOutboundRouting), validated at boot and resolved at runtime as a topic lookup — i.e. Option 0 with compile-time/startup binding. A central routing Lambda (Option A or B) is an architectural choice you layer on top; Benzene does not ship one. Option B integrates cleanly: a small resolver populates the same topic→destination table the IBenzeneMessageSender already reads, so the choice of binding-time is a wiring decision, not a change to any call site.

Choosing


Delivery semantics recap

A saga's forward steps are almost always request/response (the orchestrator must know each succeeded before proceeding); the events it emits about the finished process are fire-and-forget.


Checklist

Inter-service communication is well-formed when:

Back to the pattern overview.