A Go-first, codegen service framework: define a service as a plain Go interface plus request/response structs, and the framework generates the HTTP server wiring, a typed client, and a shared caller interface. Zero to a running service in minutes — the compiler, not documentation, drives you through each step.
The definition source of truth is a Go interface (ent-style, Go-first).
No protobuf, no custom DSL. The whole loop stays inside go build.
Status: proof of concept. HTTP is the only transport implemented today. See
docs/SPEC.mdfor the full design — architecture, IR, acceptance criteria, and milestones.
You write:
// framework:service base=/v1
type Orders interface {
// framework:route GET /orders/{id}
GetOrder(ctx context.Context, req GetOrderRequest) (*Order, error)
}framework gen (wired to go generate) reads that interface plus its
request/response structs and emits:
- a server handler you implement and mount,
- a typed HTTP client,
- a shared interface your tests call through, whether they're talking to the real client or an in-process fake.
No annotations beyond doc-comment directives. framework:service is
required — it marks which interface is the service definition, and
parsing hard-fails without one. framework:route is optional: a sane
HTTP convention (verb from the method-name prefix, kebab-cased path) fills
the gap when it's omitted. Any other framework:* directive is validated
and fails loudly if unrecognized — never silently absorbed. No reflection
at request time: every binding and serialization decision is made once, at
generation time, and baked into committed, readable Go source.
The pipeline is three stages: parse → IR → emit.
api/orders.go user's interface + structs (source of truth)
│ parse (golang.org/x/tools/go/packages + go/types)
▼
IR (model pkg) transport-neutral intermediate representation
│ emit (one emitter per transport)
▼
zz_http_server.gen.go generated, committed, readable code
zz_http_client.gen.go
zz_caller.gen.go
- Parse —
go/packages(withgo/types) loads the user'sapipackage, resolves the service interface and its request/response structs, and rejects anything that doesn't fit the supported shape with a precisefile:linediagnostic. - IR (
model) — a transport-neutral description of the service: methods, typed request/response fields, doc comments. Zero HTTP concepts live in the core IR; transport-specific detail (verbs, paths, param binding) lives in a per-transport extension bag owned by the emitter. - Emit — one emitter per transport turns the IR into Go source, run
through
go/formatso the output is gofmt-clean and readable.
There are two plugin seams, and the PoC exercises both without hardening either into more than it needs to be:
- Codegen emitters (
emit.Emitter) — adding a second transport (gRPC, Connect, NATS) means adding a new emitter package, not modifying the parser, the IR, or the existing HTTP emitter. - Runtime transports (
runtime.Transport) — a single service implementation can be hosted over any number of transports simultaneously;runtime.Serverjust coordinates their lifecycle.
HTTP is the only transport shipped in this PoC. The seams above exist
specifically so a second transport can be added later without touching
model or parser.
This walks through the exact flow framework new is designed to produce —
the same flow the scaffold acceptance test drives end to end.
Install the framework CLI onto your PATH from this repo. This step
is required, not optional: the scaffold's //go:generate framework gen
directive (used a few steps below) shells out to a framework binary on
PATH, so go generate will fail until it's installed.
$ go install github.com/pavelpascari/framework/cmd/framework@v0.1.0
# from a local checkout instead: go install ./cmd/frameworkScaffold a new project:
$ framework new demo
$ cd demoThis generates go.mod, api/demo.go (an example service interface),
main.go (server wiring with a handler struct{} stub), a Makefile, a
Dockerfile, and a CI workflow.
The framework isn't published to a module proxy yet. The scaffolded
go.modrequiresgithub.com/pavelpascari/framework, but nothing can resolve that on a clean machine until it's published.demo/go.modships with a commented-outreplaceand instructions right above it:// replace github.com/pavelpascari/framework => /path/to/frameworkUncomment it (or run
go mod edit -replace github.com/pavelpascari/framework=/path/to/your/framework/checkout) and point it at your local checkout of this repo.framework genworks without this step — it only loads your stdlib-onlyapipackage — butgo build,go test, and CI all need the module resolvable, so do this before anything else.
Generate the server/client/caller code:
$ go generate ./...This runs the bare framework gen (via the //go:generate framework gen
directive in api/demo.go — go generate invokes it with the working
directory already inside api/, so no path argument is needed) and writes
api/zz_http_server.gen.go, api/zz_http_client.gen.go, and
api/zz_caller.gen.go.
Try to build — it fails on purpose:
$ go build ./...
# ./main.go:35:56: cannot use handler{} (value of struct type handler) as api.DemoHandler value
# in argument to api.NewDemoHTTPHandler: handler does not implement api.DemoHandler
# (missing method GetDemo)This is the intended UX: the scaffold's main.go wires up
handler struct{} as the implementation before handler implements
anything. The compiler error tells you exactly which methods to add — no
docs required.
Implement the interface (in stub.go, handler.go, wherever you like,
package main):
func (h handler) GetDemo(ctx context.Context, req api.GetDemoRequest) (*api.DemoItem, error) {
return &api.DemoItem{ID: req.ID, Name: "hello"}, nil
}
func (h handler) ListDemo(ctx context.Context, req api.ListDemoRequest) (*api.ListDemoResponse, error) {
return &api.ListDemoResponse{Items: []api.DemoItem{}}, nil
}Build and run:
$ go build ./... # now succeeds
$ make run # go run .Probe it:
$ curl -s localhost:8080/healthz
ok- Implement
<Service>Handler. That's the only interface the framework asks you to satisfy. It's a plain Go interface with the same method set as your sourceOrders/Demo/… interface — no framework types leak into your business logic. - Return
runtime.Errorvalues deliberately. Build one with a constructor —runtime.NotFound(...),runtime.InvalidArgument(...),runtime.AlreadyExists(...),runtime.PermissionDenied(...),runtime.Internal(...),runtime.Unavailable(...)— each returns an*runtime.Errorcarrying aCode(runtime.CodeNotFound, etc.). ThatCodeis the API surface — it becomes an HTTP status and a client-visible contract. Any error that isn't a*runtime.Errorbecomes a generic 500; the underlying cause is logged server-side but never serialized to the client. - Test against
<Service>Caller, not the concrete client. The generatedzz_caller.gen.godeclares an interface (e.g.OrdersCaller) satisfied by both*OrdersClientand any in-process fake you write. Write your tests against the interface so they don't care whether calls cross the network. - Commit the
zz_*.gen.gofiles. They are first-class, readable, reviewable Go source — never edit them by hand. If generated code looks wrong, the definition or the framework is wrong; fix that and regenerate. - Regenerate in CI, and fail the build if regeneration produces a diff
(the scaffolded
.github/workflows/ci.ymldoes exactly this: runframework gen, thengit diff --exit-code). The build must never depend on a contributor's local tool version silently.
A service is wired up as:
transport := httptransport.New(api.NewOrdersHTTPHandler(impl), httptransport.WithAddr(":8080"))
server := runtime.New(runtime.Config{}, transport)
if err := server.Run(context.Background()); err != nil {
log.Fatal(err)
}-
Graceful shutdown.
Server.Runinstalls aSIGINT/SIGTERMhandler. On signal, every transport is told to drain withinConfig.ShutdownTimeout(default 15s): in-flight requests complete, new ones are refused, andRunreturns the first fatal error (ornilon a clean stop). -
Health endpoints. The HTTP transport always serves
GET /healthz(200 once the listener is up) andGET /readyz(200 while serving, 503 once draining has started) — no configuration needed. -
runtime.LoggingInterceptor(logger)is the one shipped interceptor: it wraps every call and emits a single structuredlog/slogrecord with the method ("Orders.GetOrder"form), duration, and error, if any. Wire it in withapi.WithInterceptor(runtime.LoggingInterceptor(logger))when building the handler. Interceptors are transport-agnostic (func(next Invoker) Invoker), so the same seam is where auth, tracing, or metrics would plug in later. -
Error model.
runtime.Error{Code, Message}is the only error type the wire protocol understands. The HTTP transport maps codes to statuses:Code HTTP status not_found404 invalid_argument400 already_exists409 permission_denied403 internal500 unavailable503 The wire envelope is
{"error": {"code": "...", "message": "..."}}. Any error that isn't a*runtime.Error(or doesn't wrap one) becomes a genericinternal500 — the real cause is logged, never serialized.
(Verbatim from docs/SPEC.md §8 — these also constrain
what the framework may demand of you.)
- Your interface is your API contract. Treat changes to
api/with the same rigor as a protobuf schema change: additive when possible, reviewed always. - Never edit
zz_*.gen.go. If generated code is wrong, the definition or the framework is wrong. Regenerate; CI diffsframework genoutput to enforce this. - Business logic lives behind the handler interface. The generated layer does binding, validation, and transport; your implementation should be testable with no HTTP in sight.
- Return
runtime.Errorvalues deliberately. The error code you choose is API surface — it becomes a status code and a client-visible contract. An unclassified error is a 500 by design, not an accident. - Test against
OrdersCaller, not the concrete client. Your tests should not care whether calls cross the network. - Commit generated code; regenerate in CI. The build must never depend on a contributor's local tool version silently.
- One service per interface, one interface per file. Keep definitions
small enough to read in one screen; compose services at the
runtime.Newcall, not in the definition.
Generated code (zz_*.gen.go) and the runtime/runtime/httptransport
packages it imports depend on nothing but the Go standard library. No
framework-specific runtime magic, no reflection at request time, no
third-party dependencies anywhere in the generated-code or runtime
dependency graph.
That means you can always stop using the tool: vendor runtime/ (and
runtime/httptransport/) into your project, delete the framework CLI
from your toolchain, and keep a working, maintainable service — the
generated code reads like Go a careful engineer would write by hand,
because that's the bar it's held to.
This is a proof of concept. HTTP is the only transport implemented;
the codegen-emitter and runtime-transport seams exist so a second
transport (gRPC, Connect, NATS) can be added later without touching the
parser or the IR. Streaming, OpenAPI emission, mock generation, and a
config/env framework are explicitly out of scope for this milestone (see
docs/SPEC.md §3 for the full non-goals list).
For the complete design — IR shape, directive/convention rules, acceptance
criteria, and the milestone plan this repo was built against — see
docs/SPEC.md.
Tagged releases follow Semantic Versioning; the current
release is v0.1.0. See CHANGELOG.md for what each version
adds. Pre-1.0, the public API may change without a major-version bump.
Add the runtime to a consumer module:
$ go get github.com/pavelpascari/framework@v0.1.0Generated code depends only on the standard library plus the stdlib-only
runtime packages — importing them does not pull the parser's go/tools
dependency into your build (see §7, Ejectability).
Released under the MIT License.