Client SDK Generation

Because every Benzene handler declares its contract — a topic and typed request/response — Benzene can generate a strongly-typed C# client SDK for a service. Callers get a typed client instead of hand-assembling messages, and the client stays in sync with the service's handlers.

The generator lives in the Benzene.CodeGen.Client package and produces a {Service}ServiceClient class (implementing an I{Service}ServiceClient interface) with one …Async method per handler.

How it works

Generation runs off a service description called an EventServiceDocument — the same model behind the OpenAPI/AsyncAPI spec. You can build that document two ways:

Generating from a handler assembly

Given handlers in an assembly:

[Message("hello:world")]
public class HelloWorldMessageHandler : IMessageHandler<HelloWorldMessage, HelloWorldResponse>
{
    public Task<IBenzeneResult<HelloWorldResponse>> HandleAsync(HelloWorldMessage message)
        => BenzeneResult.Ok(new HelloWorldResponse { Message = $"Hello {message.Name}" }).AsTask();
}

Build the service document and run the SDK builder:

using Benzene.CodeGen.Client;
using Benzene.Core.MessageHandlers;
using Benzene.Schema.OpenApi.EventService;

// 1. Discover the handlers and turn them into a service document
var definitions = new ReflectionMessageHandlersFinder(typeof(HelloWorldMessageHandler).Assembly)
    .FindDefinitions();
var document = definitions.ToEventServiceDocument();

// 2. Generate the client SDK
var sdkBuilder = new MessageClientSdkBuilder(
    serviceName: "HelloWorld",
    baseNamespace: "Benzene.Examples.Clients");

var codeFiles = sdkBuilder.BuildCodeFiles(document);

// 3. Write the generated files out
foreach (var file in codeFiles)   // each ICodeFile has a Name and Lines
{
    File.WriteAllLines(file.Name, file.Lines);
}

This produces HelloWorldServiceClient.cs containing a HelloWorldServiceClient with a HelloWorldAsync(HelloWorldMessage message) method (plus a header-aware overload) and a HashCode property carrying the hash of the contract it was generated against.

Using the generated client

The generated client takes an IBenzeneMessageSender (from Benzene.Clients) in its constructor and returns results as IBenzeneResult<T> — the same result model your handlers use:

public class HelloWorldServiceClient : IHelloWorldServiceClient
{
    public HelloWorldServiceClient(IBenzeneMessageSender sender) { /* generated */ }

    public Task<IBenzeneResult<HelloWorldResponse>> HelloWorldAsync(HelloWorldMessage message) { /* generated */ }
    public Task<IBenzeneResult<HelloWorldResponse>> HelloWorldAsync(HelloWorldMessage message, IDictionary<string, string> headers) { /* generated */ }
}

The generator also emits a sibling HelloWorldServiceClientRouting.RequiredTopics array, for ValidateOutboundRouting()'s startup check — see Validating routes at startup.

Configure the underlying transport by routing each of the client's topics via AddOutboundRouting(...) — for example .UseSqs(...)/.UseSns(...) from Benzene.Clients.Aws to call the service via AWS, or an HTTP transport for calling it over HTTP. The generated client is transport-agnostic; the outbound route registered for each topic decides how the message is actually sent.

var client = new HelloWorldServiceClient(sender);
var result = await client.HelloWorldAsync(new HelloWorldMessage { Name = "World" });
if (BenzeneResult.IsSuccess(result))
{
    Console.WriteLine(result.Payload.Message);
}

Registering the client with DI

You don't have to write the registration — the generator emits it. Alongside each client comes a {Service}ServiceClientRegistration.cs with one extension method:

// generated
public static class HelloWorldServiceClientRegistration
{
    public static IBenzeneServiceContainer AddHelloWorldServiceClient(this IBenzeneServiceContainer container)
    {
        // Scoped, not singleton: AddOutboundRouting registers IBenzeneMessageSender
        // scoped, so a singleton client would be a captive dependency.
        return container.AddScoped<IHelloWorldServiceClient, HelloWorldServiceClient>();
    }
}

Two deliberate choices in there:

Call it wherever you configure the container — under UsingBenzene when hosting on Microsoft.Extensions.DependencyInjection:

services.UsingBenzene(x => x.AddHelloWorldServiceClient());

In topic-client mode you get both shapes: each per-topic client folder carries its own Add{Topic}ServiceClient() (so dropping in a single client folder for a single topic brings its registration with it, which is the whole point of a self-contained atomic client), plus one {Service}ClientsRegistration.cs at the root whose Add{Service}Clients() calls every per-topic extension — a single line for a consumer that takes several topics off the same service. The aggregate is named from --service-name; without one, only the per-client extensions are emitted.

services.UsingBenzene(x => x.AddPaymentsClients());          // all of them
services.UsingBenzene(x => x.AddPaymentsCaptureServiceClient()); // or just the one topic

Generating message handler stubs

The same package includes MessageHandlerBuilder, which generates handler stubs from a service document — useful for scaffolding a new service from an existing contract (consumer-first scaffolding: point it at a producer's published contract and get compilable handler stubs to fill in, rather than hand-typing the [Message(...)] boilerplate):

var handlerFiles = new MessageHandlerBuilder("MyService.Handlers").BuildCodeFiles(document);

From the CLI, this is --output message-handlers:

benzene build -file Orders.spec.json -output message-handlers -namespace MyService.Handlers -directory Generated/

Two client shapes: whole-service vs. per-topic

Benzene.CodeGen.Client generates two different client shapes from the same EventServiceDocument, both usable directly or via the benzene CLI's build command (--output client / --output topic-client):

client (MessageClientSdkBuilder) topic-client (AtomicClientSdkBuilder)
Shape One {Service}ServiceClient class with one method per topic One small, self-contained client class per topic, each in its own folder
RequiredTopics / contract hash Covers every topic the client was generated for Scoped to just that one topic
Best for A consumer that calls most/all of a service's topics — one client, one thing to inject A consumer that calls one or a handful of topics out of a larger service

The coupling difference is the point of topic-client: ValidateOutboundRouting()'s startup check and the client's contract hash are both driven by RequiredTopics, so a whole-service client's consumer is coupled to every topic the service happens to expose, including ones it never calls — an unrelated change to a topic it doesn't use still shows up as a hash change or a startup-check failure. A per-topic client scopes both to the one topic it actually calls, so unrelated producer changes neither drag in unused surface nor invalidate the client. The tradeoff is more types to inject when a consumer genuinely does call most of a service — that's when client mode is the better fit.

Generating a per-topic client directly:

var atomicBuilder = new AtomicClientSdkBuilder(new ClientSdkOptions { Namespace = "Acme.Orders.Clients" });
var codeFiles = atomicBuilder.BuildCodeFiles(document);
// -> OrderCreate/OrderCreateServiceClient.cs (namespace Acme.Orders.Clients.OrderCreate), etc.

From the CLI:

benzene build -file Orders.spec.json -output topic-client -namespace Acme.Orders.Clients -directory Generated/

Scoping generation with --topics

Both client and topic-client modes accept a --topics <a,b,c> comma-delimited include-list (or, programmatically, ClientSdkOptions.Topics) that limits generation to exactly those topics — the minimal-coupling-surface option for a consumer that only calls a handful of a service's topics. Naming a topic scopes it consistently everywhere: in client mode, only the named topics get methods on the class and interface, and only they appear in RequiredTopics; in topic-client mode, only the named topics get their own per-topic client at all. A topic named in --topics that the document doesn't have fails the build (a non-zero exit naming the document's actual topics), rather than silently generating a client that's missing what you asked for.

Generated clients cover domain topics only

Benzene's reserved endpoints (benzene:spec, benzene:mesh, benzene:healthcheck, …) are deliberately kept separate from a service's domain surface: they are framework plumbing, answered by framework middleware, and a consumer calls them — if at all — through the mesh or its monitoring, not through a typed domain client. So no benzene:* topic is ever generated into a client: no method, no interface member, and above all no RequiredTopics entry. They are excluded by default in both modes, and you can opt a non-health reserved topic back in programmatically (ClientSdkOptions.IncludeReservedTopics = true, or by naming it in Topics; there is no CLI flag for this yet).

benzene:healthcheck used to be the exception — every generated client implemented IHasHealthCheck, emitted a HealthCheckAsync(), and listed benzene:healthcheck in RequiredTopics unconditionally. That last part broke adoption outright: AddOutboundRouting registers the outbound-routing start-up check, which enforces by default, so any service that adopted any generated client failed to start until it invented an outbound route for a topic it never meant to call. The health check is now simply not generated, and a generated client does not implement IHasHealthCheck.

Nothing is lost by that, because a downstream health call needs no generated code in the first place: its payload is standard and known up front (fixed by the libraries), unlike domain payloads, which differ per service and are the reason domain clients are generated at all. Calling a downstream's health check is a health-check concern — like pinging a database or a queue — so it lives in Benzene.Clients.HealthChecks as AddServiceCheck(...), built on the library's own ServiceHealthCheckClient. Pass the generated client's HashCode when you also want contract-drift reporting:

app.UseContractsCheck(x => x
    .AddServiceCheck("Payments", new PaymentsServiceClient(sender).HashCode));

That check sends benzene:healthcheck, so the consumer registers an outbound route for it — now an explicit opt-in per dependency rather than something forced on every consumer of a generated client. See Contract testing and Kubernetes health checks.

# Only these two topics: one client, methods/interface/RequiredTopics scoped to exactly them.
benzene build -file Orders.spec.json -output client -service-name Orders \
  -topics "order:create,order:cancel" -directory Generated/

# The same include-list on topic-client: exactly two per-topic clients, nothing else.
benzene build -file Orders.spec.json -output topic-client -namespace Acme.Orders.Clients \
  -topics "order:create,order:cancel" -directory Generated/

Controlling the generated namespace with --namespace

By default the generated namespace is derived from --lambda-name or --service-name (see Generating from a deployed service below). --namespace overrides that: given, it is used exactly — no magic suffix — across the client class, its interface and its DTOs alike (programmatically, ClientSdkOptions.Namespace). In topic-client mode it's the root: each per-topic client still lands in its own namespace one level below it ({Namespace}.{ClientName}), since every atomic client is self-contained.

Generating from a deployed service

To generate a client from a service you don't have the source for, fetch its EventServiceDocument from the running service's spec endpoint (the service must have UseSpec() in its pipeline), then feed that document into MessageClientSdkBuilder exactly as above. The Benzene.CodeGen.Cli tool wraps this flow for command-line use — see the two shapes above, plus --file/--url/--mesh for where the spec comes from (Phase 1's build artifact, a running service's spec endpoint, or a mesh manifest, all offline of any deployed AWS Lambda).

One-line MSBuild integration

Everything above assumes you run benzene build yourself, by hand, whenever a contract changes. Benzene.CodeGen.Build removes that step: commit the producer's .spec.json file into your repo (the same way you'd commit a .proto file or an OpenAPI document — Phase 1's Benzene.Descriptor emits one on every producer build, or run benzene spec and save its output), add one item, and the client regenerates and compiles automatically:

<ItemGroup>
  <BenzeneServiceContract Include="contracts/orders.spec.json"
                           Mode="topic-client"
                           ServiceName="Orders"
                           Namespace="Acme.Orders.Clients"
                           Topics="order:create,order:cancel" />
</ItemGroup>

Mode (default topic-client), ServiceName (default: the file's own stem) and the optional Namespace/Topics map 1:1 onto the -output/-service-name/-namespace/-topics flags shown above — this is the exact same benzene build -file flow, just run automatically before every CoreCompile instead of by hand. Regeneration is incremental (an unchanged contract is skipped on the next build, ordinary MSBuild Inputs/Outputs, nothing bespoke) and a broken contract fails the build with the CLI's own error message, rather than reporting a silent green build with a stale or missing client.

See examples/CodeGen/Benzene.Examples.CodeGen.Contracts.Consumer for a complete, building example, and src/Benzene.CodeGen.Build/README.md for the full attribute reference and how to point it at the CLI another way (a local tool manifest, or running it from source).

Further Reading