The Two-Tier Microservice Architecture
Status: DRAFT v0.1 — a reference architecture, not wire format.
The specification defines what one good Benzene service is — the middleware pipeline, message handlers, topics, results, and (at the top of the adoption ladder) the Cloud Service Profile that makes a service a first-class fleet citizen. It is deliberately silent on how you compose many services into a system.
This pattern fills that gap for one field-tested shape: a system split into a layer of small domain-owning core services and a layer of process-owning orchestrators on top of them — the shape of a real large-scale platform built on a Benzene-family framework, distilled into something a team can follow or an agent can execute. Nothing here is normative; it builds on top of the spec's vocabulary (topics, handlers, results, the mesh, the Cloud Service Profile) and links to it rather than restating it. Where it names a concrete API it is marked (informative, .NET) — the shape is language-neutral; the .NET names are illustrative, exactly as in design-principles.md.
┌───────────────────────────────────────┐
API / event in ─────► │ ORCHESTRATORS │ ─────► events out
(HTTP, queue, │ business processes · distributed │ (SNS/EventBridge/…)
schedule) │ transactions · sagas (all-or-nothing) │
└───────────────────────────────────────┘
│ │ │ │
request/response over the mesh (e.g. Lambda-to-Lambda),
one call per aggregate operation, per the routing table
▼ ▼ ▼ ▼
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
│Tenant │ │ User │ │Order │ │Billing│ CORE SERVICES
│ CRUD │ │ CRUD │ │ CRUD │ │ CRUD │ one/two aggregate roots each
└───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ light on process, heavy on data
│ │ │ │
┌─▼─┐ ┌─▼─┐ ┌─▼─┐ ┌─▼─┐
│DB │ │DB │ │DB │ │DB │ share-nothing: one DB per service
└───┘ └───┘ └───┘ └───┘
Two service types, one rule each:
Core services own data. Each looks after one or two aggregate roots, owns its own database (share-nothing), validates its own objects, and holds almost no business process. They are the system's system-of-record. A core service is a textbook Benzene Cloud Service: CRUD topics served by handlers, a derived spec, health checks, mesh feeds.
Orchestrators own process. They take a request (from an API) or an event, drive a business process across several core services, emit events on success or failure, and — crucially — run the multi-service write as a saga: it either wholly succeeds or is wholly compensated, never left half-applied.
The two tiers talk over the mesh with ordinary Benzene request/response calls. On AWS the reference realization is Lambda-to-Lambda — fast, cheap, and a natural fit for Benzene's envelope — with a routing table that turns a topic into a destination. Service communication covers that, including the central-routing-lambda options and their latency/cost trade-offs.
Why split this way
| Concern | Core services | Orchestrators |
|---|---|---|
| Owns | One/two aggregate roots + their database | A business process |
| Business logic | Minimal — validation and CRUD | Where all the process logic lives |
| State | The system of record (a database) | Ideally stateless between steps; saga state is transient |
| Fails how | A single write fails cleanly | A multi-service process fails atomically (saga) |
| Changes when | The shape of the domain data changes | The steps of a business process change |
| Scales on | Data volume / read-write load for its aggregate | Process throughput |
The payoff is that the two axes of change are separated. Redefining a business process touches an orchestrator and no core service; reshaping an aggregate's data touches one core service and no process. Each core service is independently deployable, independently scalable, and — because it shares no database — independently reasoned about.
When to use this pattern
Use it when you have a domain that decomposes into clear aggregate roots and business processes that span several of them, you want independent deployability and share-nothing data ownership, and you can tolerate eventual consistency between aggregates (bridged by sagas for the writes that must be atomic).
Don't reach for it when a single service with a single database would do. This pattern buys you fleet-scale separation of concerns at the cost of distributed-systems complexity (network calls, partial failure, sagas, a routing table). A small system does not need two tiers; start with one service and let it split when an aggregate boundary or a process boundary makes itself obvious. The adoption ladder applies here too — climb it as the need appears, not before.
Rules of the pattern (for a team or an agent to follow)
These are the invariants that make the architecture hold together. The per-layer docs expand each one; this list is the checklist.
- One database per core service; share nothing. No two services touch the same store. Cross- aggregate references are by id only — see core-services.md.
- Dependencies point one way: child knows parent, parent does not know child.
Userholds atenantId; theTenantservice has never heard of users. This keeps the dependency graph acyclic — see core-services.md. - Core services hold no cross-service process. If a piece of logic needs to touch two aggregates, it belongs in an orchestrator, not in a core service.
- Every multi-service write is a saga. An orchestrator that writes to more than one core service does it as an all-or-nothing saga with a compensation for every effect — see orchestrators.md.
- Address services by topic, not by transport. Application code calls
send("tenant:create", req); the routing table maps the topic to a destination. Never hard-code a queue URL or a function name at a call site — see service-communication.md. - Every service is a good mesh citizen. Core services and orchestrators alike aim for the Cloud Service Profile so the fleet is visible and operable end to end.
The documents
- Core services — the data-owning CRUD layer: aggregate roots, share-nothing databases, validation, reference-by-id, directional dependencies.
- Orchestrators — the process-owning layer: request/response and event-driven triggers, event emission, and the saga pattern for atomic distributed writes.
- Service communication — how the tiers talk: topic-based addressing, the routing table, the AWS Lambda-to-Lambda realization, and the central-routing- lambda options with their cost/latency analysis.
Relationship to the specification
This pattern is a consumer of the spec, never a competitor to it:
- A core service and an orchestrator are both just Benzene services — same pipeline, same handlers, same result model (core-concepts.md).
- "A good fleet citizen" means the Cloud Service Profile; the pattern adds how to organize a fleet of profiled services, not new requirements on any one of them.
- Fleet visibility — which service consumes which topic, how often, over which transport — comes from the mesh for free; the pattern relies on it rather than redefining it.
If while following this pattern you find yourself wanting a new observable contract (a wire
shape, a status, a mesh field), that is a spec change, made in docs/specification/** — not a
pattern change. Patterns describe how to arrange the pieces; the spec defines the pieces.