Porting Guide
Guidance (informative).
Notes for implementing Benzene in another language. The strategy is spec-first: a port implements core-concepts.md, wire-contracts.md, and transport-bindings.md idiomatically — it does not translate the C# API. Interop with .NET Benzene services comes from the wire contracts, and is the first thing a port should prove, not the last.
Once a port is under way, every shippable increment is held to port-quality-standards.md — the Definition of Done that gates port work (DX-champion-in-the-loop workflow; conformance; layered packaging; a runnable multi-transport example per cloud provider tested through the port's own dogfooded test helpers; a CI gate; website-ready docs). This guide is how to translate; that document is when the translation is done.
1. What is concept vs C# idiom
| .NET mechanism | The concept underneath | Idiomatic equivalent elsewhere |
|---|---|---|
[Message("topic")] attribute + assembly scanning |
Explicit handler registration records (core-concepts §9) | Explicit registration calls (Go, Rust); decorators (TS/Python); codegen |
IMessageHandler<TRequest,TResponse> generic interface |
handle: TRequest → Result<TResponse> |
A function type / single-method interface; generics where available, per-registration types where not |
MS DI + IBenzeneServiceContainer adapter |
Registration + per-invocation scope + overridable defaults (core-concepts §8) | A context/registry object (Go); constructor injection frameworks where cultural (Java/TS) |
IMiddleware<TContext> + Func<Task> next |
Ordered onion pipeline with short-circuit (core-concepts §4) | The language's standard middleware shape (Go http-style wrappers, Express/Koa, Python ASGI-like) |
IAsyncEnumerable<T> streaming handlers |
Async stream of items, one pipeline run per call (core-concepts §3) | Channels (Go), async generators (TS/Python), Stream (Rust) |
GrpcServerCallAccessor scoped accessor |
Invocation-scoped facts available to handler code without transport coupling | Context values (Go context.Context), ALS (Node), contextvars (Python) |
Reflection-cached protobuf Descriptor/Parser lookups |
proto3-JSON bridging rule (wire-contracts §6) | Each language's protobuf library exposes the same JSON mapping natively |
BenzeneStartUp abstract class |
One definition, several transports, unhonourable mounts do nothing, registration validated before the first message (core-concepts §7) | A builder or plain functions; those three properties are what must survive |
Rule of thumb: if removing a mechanism would change what's on the wire or what a handler observes, it's a concept and it's in the spec. Otherwise it's an idiom — do what's natural in the target language.
Client generation (the codegen row above) has its own pinned contract: the .spec.json a
service derives and serves (contract-document.md), the topic-scoping and
schema-closure rules a generator must implement identically, and the language-neutral
contractHash and registrationHash algorithm — all conformance-pinned by contract-document-cases.json and
contract-hash-cases.json. A port that ships a client generator implements that document; method
naming and file layout stay idiom, same rule of thumb as above.
2. Suggested porting order
- Wire contracts first: envelope + status vocabulary + header conventions, verified against a running .NET Benzene service (send/receive the envelope, assert statuses round-trip). Cross-language interop is the product; prove it in week one.
- Result type, topic, registry, pipeline (with short-circuit + scope semantics).
- One inbound binding (HTTP is the cheapest) end-to-end, including status mapping and the correlation/trace middleware.
- One outbound client + decorators.
- Health checks (reserved topic + response format).
- Further bindings by demand — each is additive.
Problem details. Implement the failure payload from
wire-contracts.md §1.3 exactly as specified: the
member table, the replace-not-wrap rule, and the problem-details-cases.json /
envelope-cases.json fixtures that pin it (see conformance/). The
problem-type registry (wire-contracts.md §3.1) is
one rule, not a taxonomy to invent — type is the registry prefix plus the status, nothing to
design.
3. Conformance testing
A language-neutral test suite that every implementation runs:
- Fixture form (exists — see conformance/, which lists the fixtures
each conformance claim requires): JSON fixtures for the status vocabulary, the HTTP/gRPC mapping
tables in both directions, end-to-end envelope cases run against a canonical handler set, native
metadata and settlement, problem details, and the mesh. The .NET reference runner is
test/Benzene.Conformance.Test/; a port writes its own runner over the same files. - Interop form (future): a docker-composed pair — reference .NET service + candidate
implementation — exercising envelope round-trips, correlation/trace propagation, and the
benzene-statustrailer over real transports. - A port is "Benzene" when it passes both; API shape is explicitly not part of conformance.
Conformance comes in two levels (cloud-service-profile.md): the
fixtures above establish Benzene Core. A port that also wants its services to claim the
Cloud Service Profile passes the contract-document producer groups; one whose services should
be meshed (mesh.md §7.1) additionally implements the service-side
mesh feeds and trace propagation and passes mesh-trace-cases.json. Plan for both from the start
— together they are the difference between "interoperates" and "fully tool-operable and visible
in the fleet".
4. Recommended: an in-process transport
Not required for conformance (it carries nothing over the wire, so there is nothing for the
fixtures to check), but strongly recommended once the outbound client + decorators step (§2.4) is
done: a MessageSender/outbound-client implementation that dispatches straight to a handler
pipeline built in the same runtime, with no wire hop at all — not even loopback.
It exists for the modular monolith pattern: a service built as named, topic-addressed pipelines in one process, extracted into microservices later by swapping a routing-table entry from "in-process" to a real transport. A port without this transport still works for that pattern — a hand-rolled direct function call stands in — but it loses the point of the pattern: that a module boundary is a message, indistinguishable from a call that will one day cross a process, from the first commit.
Two things vary by port, and both are architecture, not oversight:
- Whether "two named pipelines can share a topic" needs a workaround. If handler registration
is a process-wide singleton (as in .NET's
MessageHandlerDefinitionIndexor a decorator/module scanner shared process-wide), two named in-process pipelines that both declare a handler for the same topic collide — fan-out to several pipelines then needs a per-target topic to disambiguate (see .NET'sInProcessFanOutTarget/DuplicateInProcessFanOutTargetException). If the registry is per-instance (constructed fresh per pipeline, as in Go and Python), there is no collision and no workaround is needed — fan-out just dispatches the caller's one topic to each pipeline's own registry. - Whether a separate boot-time validation pass is worth adding. A port whose wiring is
imperative and runs once at startup (Go, Python) already fails loudly on a typo'd pipeline name
at construction — nothing lazy is left to validate. A port with a declarative, possibly-deferred
routing table (.NET's
OutboundRoutingBuilder) benefits from an explicit startup check (.NET'sIStartUpCheck) that cross-references every route reference against the registered pipeline names before the service is considered healthy, rather than surfacing the mistake on first send.
Match the target language's existing idiom for outbound clients (a constructed object used directly, not a new routing concept invented for this one transport) rather than translating any one port's shape literally — see the .NET, Go, TypeScript, and Python implementations for four idiomatic answers to the same two questions above.