Skip to content

pavelpascari/framework

Repository files navigation

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
  • Parsego/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:

  1. 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.
  2. 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.gogo 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.)

  1. Your interface is your API contract. Treat changes to api/ with the same rigor as a protobuf schema change: additive when possible, reviewed always.
  2. 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.
  3. 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.
  4. 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.
  5. Test against OrdersCaller, not the concrete client. Your tests should not care whether calls cross the network.
  6. Commit generated code; regenerate in CI. The build must never depend on a contributor's local tool version silently.
  7. 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.

About

A Go-first, codegen service framework: define a service as a Go interface, generate the HTTP server, typed client, and caller seam.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors