framework
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.md for the full design — architecture,
IR, acceptance criteria, and milestones.
1. What it is
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.
2. Architecture
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 (with go/types) loads the user's api
package, resolves the service interface and its request/response structs,
and rejects anything that doesn't fit the supported shape with a precise
file:line diagnostic.
- 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/format so 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.Server just 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.
3. Quickstart
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/framework
Scaffold a new project:
$ framework new demo
$ cd demo
This 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.mod requires github.com/pavelpascari/framework, but nothing can
resolve that on a clean machine until it's published. demo/go.mod
ships with a commented-out replace and instructions right above it:
// replace github.com/pavelpascari/framework => /path/to/framework
Uncomment 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 gen works
without this step — it only loads your stdlib-only api package — but
go 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
4. Using the generated code
- 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 source Orders/Demo/… interface — no framework
types leak into your business logic.
- Return
runtime.Error values deliberately. Build one with a
constructor — runtime.NotFound(...), runtime.InvalidArgument(...),
runtime.AlreadyExists(...), runtime.PermissionDenied(...),
runtime.Internal(...), runtime.Unavailable(...) — each returns an
*runtime.Error carrying a Code (runtime.CodeNotFound, etc.). That
Code is the API surface — it becomes an HTTP status and a
client-visible contract. Any error that isn't a *runtime.Error becomes
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
generated zz_caller.gen.go declares an interface (e.g. OrdersCaller)
satisfied by both *OrdersClient and 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.go files. 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.yml does exactly this: run
framework gen, then git diff --exit-code). The build must never
depend on a contributor's local tool version silently.
5. Runtime
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.Run installs a SIGINT/SIGTERM handler.
On signal, every transport is told to drain within Config.ShutdownTimeout
(default 15s): in-flight requests complete, new ones are refused, and
Run returns the first fatal error (or nil on a clean stop).
-
Health endpoints. The HTTP transport always serves GET /healthz
(200 once the listener is up) and GET /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 structured log/slog record with
the method ("Orders.GetOrder" form), duration, and error, if any. Wire
it in with api.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_found |
404 |
invalid_argument |
400 |
already_exists |
409 |
permission_denied |
403 |
internal |
500 |
unavailable |
503 |
The wire envelope is {"error": {"code": "...", "message": "..."}}. Any
error that isn't a *runtime.Error (or doesn't wrap one) becomes a
generic internal 500 — the real cause is logged, never serialized.
6. Guiding principles for framework users
(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 diffs
framework gen output 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.Error values 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.New call, not in the definition.
7. Ejectability
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.
8. Status
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.
9. Releases
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.0
Generated 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).
10. License
Released under the MIT License.