|
| 1 | +--- |
| 2 | +number: "0016" |
| 3 | +slug: protobuf-rpc-over-nats-micro-binding |
| 4 | +status: accepted |
| 5 | +date: 2026-07-04 |
| 6 | +--- |
| 7 | + |
| 8 | +# ADR 0016: Protocol Buffers RPC over NATS micro Binding |
| 9 | + |
| 10 | +## Context |
| 11 | + |
| 12 | +[ADR 0009](./0009-protocol-buffers-wire-contracts.md) makes Protocol Buffers the |
| 13 | +wire contract for first-party owned service and message contracts. |
| 14 | +[ADR 0011](./0011-jsonrpc-over-nats-binding.md) defines how the JSON-RPC family |
| 15 | +(ACP, MCP, A2A) is carried over the NATS backbone, but it is explicitly scoped to |
| 16 | +JSON-RPC. There is no equivalent rule for how a first-party **protobuf** service |
| 17 | +is carried over NATS, so any such service would have to invent its own subject |
| 18 | +scheme, success/error signaling, and discovery. ADR 0011 exists because three |
| 19 | +JSON-RPC mappings diverged and lost errors; a protobuf service on NATS is exposed |
| 20 | +to the same class of drift with no governing rule. |
| 21 | + |
| 22 | +`trogon-proto` already ships |
| 23 | +[`trogon.nats.micro.v1alpha1`](https://github.com/TrogonStack/trogon-proto/blob/main/proto/trogon/nats/micro/v1alpha1/options.proto), |
| 24 | +a protocol-neutral set of options that attach NATS Services metadata to any |
| 25 | +protobuf `service` (`ServiceOptions`: `version`, `description`, `metadata`, |
| 26 | +`content_type`) and `rpc` method (`MethodOptions`: endpoint `metadata`). Its own |
| 27 | +example is a generic `OrderService`. This shared mechanism has no ADR governing |
| 28 | +what it means on the wire. |
| 29 | + |
| 30 | +NATS Services (NATS micro, |
| 31 | +[ADR-32](https://github.com/nats-io/nats-architecture-and-design/blob/main/adr/ADR-32.md)) |
| 32 | +already provides the RPC substrate a protobuf service needs: request/reply, |
| 33 | +queue-group load balancing, discovery (`$SRV.PING|INFO|STATS`), versioning, |
| 34 | +per-endpoint stats, and a standard error channel (`Nats-Service-Error`, |
| 35 | +`Nats-Service-Error-Code`). This ADR binds annotated protobuf services to that |
| 36 | +substrate as one shared rule, the protobuf-family sibling of ADR 0011. |
| 37 | + |
| 38 | +This is a naming binding, not a protocol tunnel. It borrows gRPC's *idiom* |
| 39 | +(method-in-path routing, canonical status semantics) so the shape is familiar. It |
| 40 | +does not run gRPC: there is no HTTP/2, no gRPC framing, and no gRPC library on the |
| 41 | +path. The transport is core NATS; the RPC semantics are NATS micro. |
| 42 | + |
| 43 | +## Decision |
| 44 | + |
| 45 | +### 1. An annotated protobuf service is a NATS micro service |
| 46 | + |
| 47 | +A protobuf `service` carrying `option (trogon.nats.micro.v1alpha1.service)` is |
| 48 | +registered as one NATS micro service. Each `rpc` becomes a micro **endpoint**. |
| 49 | +`ServiceOptions.version`, `description`, and `metadata` populate the service's |
| 50 | +discovery record; `MethodOptions.metadata` populates the endpoint's. The proto |
| 51 | +`service` is the canonical wire contract, and the registered micro service must |
| 52 | +expose exactly its methods as endpoints. |
| 53 | + |
| 54 | +### 2. The subject is derived from the service and the method |
| 55 | + |
| 56 | +An endpoint's subject is `<group>.<EndpointName>`, where the group is a configured |
| 57 | +prefix plus the service name and the endpoint name is the `rpc` method name. |
| 58 | + |
| 59 | +| Field | NATS location | Rule | |
| 60 | +| --- | --- | --- | |
| 61 | +| Service | subject group | `<subject_prefix>.<service-name>` | |
| 62 | +| Method | endpoint subject suffix | The `rpc` method name. Mirrors gRPC `/package.Service/Method` | |
| 63 | +| Request | request payload | The request message bytes | |
| 64 | +| Reply | reply payload | The response message bytes | |
| 65 | + |
| 66 | +The request **message type** is a property of the endpoint (held from the service |
| 67 | +descriptor), never recovered by parsing the subject. Because micro subscribes only |
| 68 | +to declared endpoints, routing is structural: an unknown method has no |
| 69 | +subscription and is unroutable at the client. |
| 70 | + |
| 71 | +### 3. Success and error discriminate on the micro error header |
| 72 | + |
| 73 | +A reply is an error if, and only if, `Nats-Service-Error-Code` is present. That |
| 74 | +header is also the decode rule for the body: when it is absent the body is the |
| 75 | +method's response message; when it is present the body is one **complete** |
| 76 | +serialized |
| 77 | +[`google.rpc.Status`](https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto) |
| 78 | +(`code`, `message`, and `details` together), encoded per the negotiated |
| 79 | +`Content-Type` under the same rule as any other payload (section 4). The body is |
| 80 | +never a fragment of `Status`: `details` is a `repeated google.protobuf.Any` field |
| 81 | +*inside* the one `Status` message and is never serialized on its own. |
| 82 | + |
| 83 | +The micro error headers are copies of two `Status` fields, not replacements for |
| 84 | +them; the fields stay in the body so the payload is self-contained: |
| 85 | + |
| 86 | +| `google.rpc.Status` field | Header mirror | Rule | |
| 87 | +| --- | --- | --- | |
| 88 | +| `code` | `Nats-Service-Error-Code` | The canonical [`google.rpc.Code`](https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto) numeric value; never `OK` (0) | |
| 89 | +| `message` | `Nats-Service-Error` | The developer-facing description | |
| 90 | +| `details` | none | [`google.rpc` error details](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto) (`ErrorInfo`, `BadRequest`, `RetryInfo`, `QuotaFailure`, ...); read only by decoding the body | |
| 91 | + |
| 92 | +The duplication is deliberate. The headers exist so middleware can route on the |
| 93 | +subject and meter on the error header without decoding the body, exactly as |
| 94 | +ADR 0011 requires for `Jsonrpc-Error-Code`; the body exists so a client, a log |
| 95 | +pipeline, or a dead-letter inspector holding only the payload still has the whole |
| 96 | +error. The shared binding layer builds the headers and the body from one `Status` |
| 97 | +value, so they agree by construction; on any disagreement the headers are |
| 98 | +authoritative. Error codes use the canonical `google.rpc.Code` space; HTTP |
| 99 | +familiarity comes from the canonical-to-HTTP mapping documented in `code.proto`, |
| 100 | +not from a second vocabulary on the wire. |
| 101 | + |
| 102 | +The micro error channel signals **service faults**: malformed input, timeouts, |
| 103 | +and handler failures, which micro also counts in `num_errors`. A defined |
| 104 | +*application-level negative outcome that still executed successfully* (for example |
| 105 | +a validation result the caller acts on) belongs in the typed response body, not on |
| 106 | +the error channel, so that `num_errors` stays a health signal rather than a |
| 107 | +business-outcome counter. Each specializing ADR defines its own outcome taxonomy |
| 108 | +over this rule. |
| 109 | + |
| 110 | +### 4. Content type is negotiated from the service option |
| 111 | + |
| 112 | +`ServiceOptions.content_type` governs the request and reply `Content-Type` header. |
| 113 | +`CONTENT_TYPE_UNSPECIFIED` accepts both `application/protobuf` and |
| 114 | +`application/json` on the same endpoints; `CONTENT_TYPE_PROTOBUF` or |
| 115 | +`CONTENT_TYPE_JSON` restrict it. JSON is `google.protobuf.Any`-style canonical |
| 116 | +JSON handled at the host edge (transcoded to and from protobuf via a |
| 117 | +`FileDescriptorSet`); the authoritative contract remains protobuf, consistent with |
| 118 | +[ADR 0009](./0009-protocol-buffers-wire-contracts.md). |
| 119 | + |
| 120 | +### 5. Discovery, versioning, and scaling come from micro |
| 121 | + |
| 122 | +Discovery (`$SRV.INFO/PING/STATS` and their `.<name>` and `.<name>.<id>` |
| 123 | +variants), version reporting, and per-endpoint stats are provided by NATS micro, |
| 124 | +so a service does not hand-roll a routing inventory. Endpoints subscribe on the |
| 125 | +micro default queue group `q` so replicas share load; the queue group is |
| 126 | +overridable at the service, group, or endpoint level. |
| 127 | + |
| 128 | +### 6. The binding is a shared layer |
| 129 | + |
| 130 | +The subject derivation, content-type negotiation, error-channel mapping, and |
| 131 | +discovery wiring are one shared component, not reimplemented per service. Domain |
| 132 | +crates inject only what is domain-specific: the typed request and response |
| 133 | +messages, the subject prefix, and any outcome taxonomy layered on the error rule. |
| 134 | + |
| 135 | +## Invariants |
| 136 | + |
| 137 | +- A reply is a service error if, and only if, `Nats-Service-Error-Code` is |
| 138 | + present. Never infer success or failure by structurally deserializing the body. |
| 139 | +- An error reply's body is one complete `google.rpc.Status`, never a fragment of |
| 140 | + it, whose `code` and `message` match the micro error headers; the headers are |
| 141 | + authoritative on disagreement. The code space is canonical `google.rpc.Code`, |
| 142 | + never `OK` (0) on an error reply. |
| 143 | +- The method is carried by the subject; the request message type is a property of |
| 144 | + the endpoint, never parsed from the subject. |
| 145 | +- The registered micro service exposes exactly the annotated `service`'s methods |
| 146 | + as endpoints. |
| 147 | +- `Content-Type` is authoritative for payload encoding and is constrained by |
| 148 | + `ServiceOptions.content_type`. |
| 149 | +- The authoritative message contract is protobuf; JSON is an edge encoding. |
| 150 | + |
| 151 | +## Alternatives Considered |
| 152 | + |
| 153 | +### Carry protobuf services over the JSON-RPC binding (ADR 0011) |
| 154 | + |
| 155 | +Rejected: protobuf services are not JSON-RPC, so 0011's codec (envelope fields, |
| 156 | +`Jsonrpc-*` headers, `id` semantics) does not apply. ADR 0011 is scoped to the |
| 157 | +JSON-RPC family by its own terms. |
| 158 | + |
| 159 | +### Let each protobuf service invent its own NATS mapping |
| 160 | + |
| 161 | +Rejected for the reason ADR 0011 exists: independent mappings diverge, disagree on |
| 162 | +success-versus-error, and lose structured errors. A single shared rule over the |
| 163 | +shared options proto prevents that. |
| 164 | + |
| 165 | +### Run real gRPC (HTTP/2) tunneled over NATS |
| 166 | + |
| 167 | +Rejected: NATS is the transport under [ADR 0003](./0003-ai-protocol-transport-taxonomy.md), |
| 168 | +and NATS micro already supplies request/reply, discovery, and error semantics. |
| 169 | +Tunneling a second transport inside NATS adds framing and a dependency for no |
| 170 | +gain. gRPC is referenced only as a naming idiom. |
| 171 | + |
| 172 | +## Consequences |
| 173 | + |
| 174 | +- First-party protobuf services get one discoverable, versioned, load-balanced |
| 175 | + NATS surface without hand-rolling routing or error signaling. |
| 176 | +- Routing and health metering happen on the subject and the standard micro error |
| 177 | + header without decoding protobuf payloads. |
| 178 | +- The `trogon.nats.micro.v1alpha1` options proto now has a governing wire |
| 179 | + contract; annotating a service is sufficient to define its NATS binding. |
| 180 | +- Future first-party service APIs inherit this binding; specializations (for |
| 181 | + example decider commands) are defined in their own ADRs. |
| 182 | + |
| 183 | +## References |
| 184 | + |
| 185 | +- [`trogon.nats.micro.v1alpha1` options](https://github.com/TrogonStack/trogon-proto/blob/main/proto/trogon/nats/micro/v1alpha1/options.proto) |
| 186 | +- [NATS Service API (ADR-32)](https://github.com/nats-io/nats-architecture-and-design/blob/main/adr/ADR-32.md) |
| 187 | +- [`google.rpc.Status`](https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto), |
| 188 | + [`google.rpc.Code`](https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto), |
| 189 | + and [`google.rpc` error details](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto) |
| 190 | +- [ADR 0003: AI Protocol Transport Taxonomy](./0003-ai-protocol-transport-taxonomy.md) |
| 191 | +- [ADR 0009: Protocol Buffers Wire Contracts](./0009-protocol-buffers-wire-contracts.md) |
| 192 | +- [ADR 0011: JSON-RPC over NATS Binding](./0011-jsonrpc-over-nats-binding.md) |
0 commit comments