Generated clients (benzene-codegen)

benzene-codegen turns a service's committed Contract Document ({Service}.spec.json, produced by that service's own port — the .NET Benzene.Descriptor, or this port's own contract.Derive, served on benzene:spec and GET /benzene/spec, see the specification) into a typed Go client: one struct per request/response DTO, one method per topic, an embedded contract hash, and a RequiredTopics list — with zero transport dependency. It works against a Contract Document produced by any Benzene port, not only this one: a Go consumer of a .NET, TypeScript, or Python service gets the same typed, topic-scoped client this page describes.

This is a Go port of the .NET repo's Benzene.CodeGen.Client (benzene build), implementing the same language-neutral generation rules — the schemaDialect check, topic scoping, the schema-closure walk, and the contractHash/registrationHash algorithm — from contract-document.md. Method naming and file layout are not part of that specification (§5.5): the choices below are this port's own idiom, not a promise every language port makes the same ones.

Where it lives, and why

The generator (codegen/) is a separate Go module from the one this documentation otherwise describes, with its own go.mod: github.com/daniellepelley/benzene-go/codegen. It hashes with the root module's contract.HashDocument — the same code a serving Go service hashes its own document with, over the hand-written RFC 8785 canonicalizer internal/jcs — and depends on github.com/gowebpki/jcs only to fuzz that canonicalizer against an independent implementation (codegen/jcsfuzz), a dependency that must never leak into the root module or any of its dependency-free packages (client, httpclient, …), matching this repo's awssqs/kafka/ grpcbinding pattern of giving a third-party dependency its own module. A client generator has this dependency; the runtime it generates code for does not — a generated client's only import from this port is client.Sender, httpclient.Unmarshal, and benzene.Result[T]/benzene.Topic, all already dependency-free.

codegen/
  contractdoc/   parses a Contract Document, applies topic scoping, walks the schema closure,
                 hashes via contract.HashDocument — generic-JSON-only, no OpenAPI/JSON-Schema library
  jcsfuzz/       differential fuzz test: internal/jcs against github.com/gowebpki/jcs
  gengo/         Go code generation: type mapping, naming, the two client shapes below
  cmd/
    benzene-codegen/   the CLI

The CLI

go run github.com/daniellepelley/benzene-go/codegen/cmd/benzene-codegen build \
  -file contracts/payments.spec.json \
  -out ./paymentsclient \
  -service Payments \
  -package paymentsclient
Flag Meaning
-file path to the Contract Document (required)
-out output directory (required)
-mode service (default, see below) or topic
-service service name → {Service}Client/New{Service}Client (mode=service)
-package generated Go package name, used exactly — must be a legal Go identifier, or the CLI fails loud
-topics comma-separated topic include-list (contract-document.md §5.2)

The CLI exits non-zero — with a message on stderr naming the problem — on an unknown flag, an unparseable document, a schemaDialect other than openapi-3.0 or none at all (contract-document.md §1: a consumer rejects a dialect it does not know, and a missing one is unknown), or an include-listed topic the document doesn't have (the error names both the unknown topic(s) and the document's actual topics, per §5.2's fail-loud rule).

Two output shapes

Service client (-mode service, the default) — one Go type covering every in-scope topic:

const ContractHash = "sha256:..."

var RequiredTopics = []benzene.Topic{{ID: "payments:capture"}, {ID: "payments:get-all"}}

type PaymentsClient struct{ /* ... */ }

func NewPaymentsClient(sender client.Sender) *PaymentsClient
func (c *PaymentsClient) CapturePayments(ctx context.Context, req CapturePayment) (benzene.Result[PaymentDto], error)
func (c *PaymentsClient) CapturePaymentsWithHeaders(ctx context.Context, req CapturePayment, headers map[string]string) (benzene.Result[PaymentDto], error)

By default this covers every requests[] topic. A producer never lists a reserved benzene: topic, and the generator ignores any such entry it meets (contract-document.md §5.1). A request with no response is a handler that returns no payload, generated as benzene.Result[struct{}]; {} is a payload of any shape, benzene.Result[any]. -topics scopes RequiredTopics/the generated methods to just the named topics; components/DTOs are not narrowed by an include-list (§5.2) — every schema in the document is still generated, even one reachable only from an excluded topic, matching the reference implementation exactly.

Topic (atomic) client (-mode topic) — one self-contained client per topic, in its own package, with components narrowed to exactly that topic's schema closure (contract-document.md §5.3):

go run .../benzene-codegen build -file contracts/payments.spec.json -out . -mode topic -topics payments:capture
# -> ./paymentscapture/{client.go,types.go}

Each topic client gets its own directory/package ({clientName}, derived from the topic via TopicMethodName below) so two independently-generated topic clients can never collide on a shared DTO name — mirroring the .NET reference's per-client namespace/folder. Its embedded ContractHash covers only that topic's projection (one request, no events or sends) — see contract-document.md §6.4 for why a service-level hash and a topic-scoped hash are never comparable to each other.

Naming

Type mapping

Plain Go structs with json:"..." tags, no validation library — matching this repo's existing hand-written style (see examples/http-helloworld's greetRequest), not the .NET reference's [ExcludeFromCodeCoverage]-decorated classes.

Schema Go
$ref the referenced schema's own Go type name
"type": "string" string (format: "date-time" → time.Time; format: "uuid" → string — Go has no built-in UUID type, and this generator does not want to force a UUID library on every generated client, unlike .NET's built-in Guid)
"type": "integer" int (format: "int64" → int64)
"type": "number" float64 (no format-driven precision heuristics — the schema's declared type governs, matching the reference)
"type": "boolean" bool
"type": "array", items []ItemType
"type": "object", schema-valued additionalProperties map[string]ValueType
allOf (one $ref branch + inline properties) Go struct embedding: the base type is embedded anonymously, so encoding/json flattens it on marshal/unmarshal — the honest Go equivalent of a C# base class, with no inheritance keyword needed
oneOf + discriminator an unexported marker-method interface (type Pet interface { isPet() }) plus an isPet() method on each subtype the discriminator's mapping names — Go has no union type, so this seals the interface to exactly the declared subtypes rather than faking generic polymorphism
a required property (schema's own required[], and not itself nullable: true) a non-pointer field, no omitempty
an optional or nullable: true property a pointer field (a slice/map field stays unwrapped — already nil-able — just omitempty)

The required/optional pointer split is this Go port's own addition (the .NET reference has no concept of it — C# reference types are nullable by default); it follows this repo's own struct/tag convention, not a promise the specification makes.

The schema closure

A topic client's components is exactly the set contract-document.md §5.3 defines: walk the topic's request/response schemas through $ref/items/additionalProperties (only when itself a schema, not a boolean)/properties/allOf/anyOf/oneOf, cycle-safe (a $ref is only walked the first time it's reached — the same rule that terminates a two-schema reference cycle). This walk is pinned byte-for-byte by docs/specification/conformance/contract-document-cases.json's schemaClosureCases, vendored into this repo's conformance/testdata/ and run by codegen/conformance/conformance_test.go.

The contractHash

Every generated client embeds a contractHash (contract-document.md §6) — the hash of the document it was generated from, or of its projection:

contractHash     = "sha256:" + lowercase-hex(sha256(JCS(P({requests, events}))))
registrationHash = "sha256:" + lowercase-hex(sha256(JCS(P({requests, events, sends}))))

P is §6.2's projection: only requests/events (and sends) with their allow-listed members, benzene: entries dropped, every $ref dereferenced (a cycle cut to {}), annotations (title, description, format, example, x-*, …) and discriminator.mapping removed, required sorted, entries and httpMappings in canonical order — so component names, hoisting depth, entry order and documentation never move a hash. JCS is RFC 8785. Both are computed by the root module's contract.HashDocument, so a generated client and a Go service hash one way; ApplyScope and TopicScopedProjection recompute both hashes over the projection they return. codegen/conformance/conformance_test.go runs this against every case of docs/specification/conformance/contract-hash-cases.json byte-for-byte, projections included.

A default client (no include-list) embeds the whole-service contractHash, which equals the value the producing service serves on benzene:spec. Compare the two to detect drift between the client you hold and the service you're calling — the same comparison clienthealthcheck.ServiceCheck performs, with WithExpectedContractHash(yourclient.ContractHash). An include-list or topic client's hash is only comparable with the same projection recomputed over the provider's current document (§6.4).

The go:generate pattern

Generated code is committed, matching Go's own go generate convention (the tool runs at development time, never at build time). examples/codegen-helloworld is the worked example:

//go:generate sh generate.sh

(a small wrapper script, not an inline go run ../../codegen/cmd/benzene-codegen — the generator lives in a separate module with its own go.mod, so a plain relative go run path can't resolve it from a directory in the root module; the script cds into the generator module and calls back with the caller's own absolute directory, so -file/-out still resolve relative to the example).

CI (or a pre-merge check in your own repo) should run:

go generate ./... && git diff --exit-code

— regenerating and diffing catches a Contract Document that changed without its generated client being regenerated and re-committed to match. codegen/gengo/dogfood_committed_test.go reproduces this exact check as an ordinary go test, so a generator change that would silently invalidate the example's committed output fails in CI immediately rather than only on the next manual regeneration.

Wiring a generated client

There is no DI container reflection in this port (client/di.go's RegisterSender/ SenderFromScope, not a declarative outbound-routing table) — so a generated client is just a constructor over client.Sender:

sender := httpclient.NewClient("https://payments.internal/benzene/invoke")
payments := paymentsclient.NewPaymentsClient(sender)

result, err := payments.CapturePayments(ctx, paymentsclient.CapturePayment{
	OrderId:  "order-123",
	Currency: "GBP",
})
if err != nil {
	// a genuine local fault before the send even happened (e.g. JSON marshal failure) - not a
	// domain outcome
}
if !result.IsSuccessful() {
	// result.Status / result.Errors - never a Go error for a domain failure
}

RequiredTopics is a plain exported []benzene.Topic — free, self-documenting, with nothing to wire it into (this port's client package deliberately has no container-wide outbound-routing validation to register it with, see client/'s package doc); use it for your own startup checks if you want one.

See examples/codegen-helloworld for the full worked example, including a fake-client.Sender test proving the generated method sends the right topic with the right typed payload.