framework

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 15, 2026 License: MIT

README

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.

Directories

Path Synopsis
cmd
framework command
Command framework is the code-generation CLI.
Command framework is the code-generation CLI.
Package emit defines the codegen emitter seam.
Package emit defines the codegen emitter seam.
httpemit
Package httpemit is the HTTP codegen emitter.
Package httpemit is the HTTP codegen emitter.
internal
textcodecrt
Package textcodecrt is a compiled fixture for issue #1 (part 1b): a local type ID implements both encoding.TextUnmarshaler and encoding.TextMarshaler and is used as a path param, a query param, and a JSON body field.
Package textcodecrt is a compiled fixture for issue #1 (part 1b): a local type ID implements both encoding.TextUnmarshaler and encoding.TextMarshaler and is used as a path param, a query param, and a JSON body field.
Package model defines the transport-neutral intermediate representation (IR).
Package model defines the transport-neutral intermediate representation (IR).
Package parser builds the IR from Go source via go/packages and go/types.
Package parser builds the IR from Go source via go/packages and go/types.
Package runtime is the stdlib-only service runtime library.
Package runtime is the stdlib-only service runtime library.
httptransport
Package httptransport hosts a service over net/http.
Package httptransport hosts a service over net/http.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL