ADR 0015: The Go backend renders one package per endpoint, verbs with distinct names, and meta as a value the core type-asserts
- Status: accepted
- Date: 2026-09-15
Context
truewire generate go is the fourth backend over the plan (docs/plan.md). Go has no
overloads, no sum types, no generic methods, and it exports by capitalisation. Three of the
decisions the Python, TypeScript and Rust backends made cannot be carried over as they are:
- Name collisions inside a module. Every endpoint of a router shares one Python module,
one TypeScript file and one Rust module, so the plan's
Request/Responsenames are disambiguated by file. Go scopes names by package, which is a directory. - A core serving several contracts. Rust's
HttpEndpoint<SpotMeta> + HttpEndpoint<FuturesMeta>and TypeScript's structural intersection let one core value serve twometashapes and two transports under one method name (request). A Go type has one method per name. - Validation and the raw path. Go's
encoding/jsondoes not check required keys, does not keep undocumented ones, and cannot tell an absent key from a null one.
Decision
- One package per endpoint (
repos/get,repos/listcommits), holding its types under the plan's own names and anEndpointstruct; one package per router holding the router struct; the root package holds the root struct,meta/,types/andreplay/. Imports are exact because[go]declaresmoduleandroot(the directory ofgo.mod). - Distinct verbs:
HttpEndpoint.Request,CommandEndpoint.Command,StreamEndpoint.Subscribe. A router whose subtree uses several declares aCoreinterface embedding them, so one core value still serves a whole client. metais a value, not a type parameter: the call carriesMeta anyholding the generatedmeta.XMeta{...}, and the core type-asserts it. The contract interfaces are not generic, so a core serving endpoints with differentmetashapes is still one type. What Rust's compiler checks at construction, a Go core checks once per call.- Records carry their own validation: each generated struct has
UnmarshalJSON/MarshalJSONthat calltruewire.DecodeObject/EncodeObjectwith one descriptor per key (Required,RequiredNullable,OptionalField,OptionalNullable). A key both optional and nullable istruewire.Optional[T]; an optional or nullable key is otherwise the nil-able form (*T, a slice, a map,any). Undocumented keys land inExtra. Unions are structs of variant pointers tried in order, tuples structs of positions. validate: falseis theRawtwin, as in Rust:Getreturns the typed value,GetRawthejson.RawMessagethe core returned.- Walkers return
*truewire.PaginatedResponse[Row, State]withPages/Rowsasiter.Seq2; aseekwalk (ADR 0013) is rendered overtruewire.Seek, whose state isSeekState{Pos, Carried}. A declared streamreply(ADR 0014) makes the typed method return*truewire.Subscription[Message, Reply]. - An
rpcendpoint declaring both transports holds atruewire.RpcEndpoint(HttpEndpointandCommandEndpointembedded) and chooses the verb per call fromCallOptions.Transport(truewire.WithTransport), the first declared transport by default: the Go form of Python'stransport=keyword, with no second method per wire. A router's twin (Raw,Paged) that collides with a sibling endpoint's own method is renamed with a numeric suffix; the endpoint's own method keeps its name. - The printer reproduces
gofmt(tabs, struct field columns broken by comments and blank lines, sorted import groups) and emits nothinggofmtwould align otherwise, so--checkneeds no Go toolchain andgofmt -lstays empty in CI.
Consequences
- A hand-written Go core is one type per transport with
Request/Command/Subscribemethods; the constructor a caller uses (github.New) is a hand-written file beside the generated root, since generated code never imports the core (ADR 0011). - A
metamismatch between the generated literal and the core is a runtime type assertion, not a compile error. - Package names are the segment's words run together (
list_commits->listcommits), so a spec whose two sibling segments differ only by separators is refused by rule 18 as in every other backend.