Skip to content

Canonical Style Guide

Scope: core, router, middleware, official docs, code generation, AI-agent workflows.

  • stdlib first — stay close to http.Handler, *http.Request, http.ResponseWriter, httptest
  • one obvious way — one bootstrap, one route style, one handler shape, one decode path, one error shape, one test style
  • explicit over implicit — no hidden binding, no context service-locator, no magical response wrappers, no import-order behavior
  • small-step refactorability — narrow boundaries, stable interfaces, shallow call paths, minimal indirection
  • single canonical path — the reference app defines structure

When convenience conflicts with predictability, choose predictability.

App construction, lifecycle, route registration entry points, middleware attachment, server startup. Must stay a kernel and must not become a feature catalog or generic plugin container.

Route matching, params, grouping, route tree/lookup, static mounting. Not allowed: repositories, validators, JSON writers, business response wrappers, service construction.

Transport-layer cross-cutting only: logging, recovery, timeout, request-id, CORS, auth adapters, rate-limiting, tracing, metrics. Not allowed: service injection, ORM lookup, business DTO assembly, hidden request binding, domain-policy branching.

x/ai, x/observability/ops, x/tenant, x/websocket, x/messaging/webhook, x/messaging/scheduler, and sibling x/* packages are capability layers, not the core learning path. They must not define the primary coding style.

reference/standard-service is the only canonical application layout.

cmd/myservice/main.go
internal/httpapp/app.go
internal/httpapp/routes.go
internal/httpapp/handlers/health.go
internal/httpapp/handlers/user_create.go
internal/httpapp/middleware/logging.go
internal/domain/user/service.go
internal/domain/user/repository.go
  • cmd/ — startup only
  • internal/httpapp/ — HTTP wiring only
  • internal/domain/ — business logic
  • internal/platform/ — optional app-local infra adapters only when the behavior does not already belong to a stable Plumego package
  • Success and error writes go directly through contract.WriteResponse / contract.WriteError from handlers.
  • Do not mix routing, domain logic, persistence, and transport helper policy in one package.
func main() {
cfg := core.DefaultConfig()
app := core.New(cfg, core.AppDependencies{})
if err := app.Use(RequestID(), Recovery(), RequestLogger()); err != nil {
log.Fatal(err)
}
if err := registerRoutes(app); err != nil {
log.Fatal(err)
}
if err := app.Prepare(); err != nil {
log.Fatal(err)
}
srv, err := app.Server()
if err != nil {
log.Fatal(err)
}
defer func() {
if err := app.Shutdown(context.Background()); err != nil {
log.Printf("shutdown server: %v", err)
}
}()
if err := srv.ListenAndServe(); err != nil {
log.Fatal(err)
}
}

Rules:

  • One visible construction site
  • Global middleware attached explicitly near startup
  • One registerRoutes call per bounded area
  • No init() side-effect registration, no hidden auto-registration
func registerRoutes(app *core.App) error {
if err := app.Get("/healthz", healthHandler); err != nil {
return err
}
if err := app.Post("/users", createUserHandler); err != nil {
return err
}
return nil
}
  • One method + one path + one handler per line
  • Route registration errors must be returned to the caller
  • Static registration; no reflection or discovery
  • Must remain grep-friendly — path and handler discoverable by search
  • Groups: path prefix and shared middleware only

The inline if err != nil form is canonical for very small route tables. Once a service has more than a few routes, wrap a routeAdder in a small accumulator that retains the first error, so the route map stays one line per route:

func (a *App) RegisterRoutes() error {
v1 := newRouteReg(a.Core.Group("/api/v1"))
v1.get("/items", http.HandlerFunc(items.List))
v1.post("/items", writeGuard(http.HandlerFunc(items.Create)))
v1.get("/items/:id", http.HandlerFunc(items.GetByID))
return v1.err // first registration error, or nil
}

This is a sanctioned canonical pattern, not a forbidden registration idiom: it keeps one method + one path + one handler per line, returns the first error to the caller, and adds no reflection, discovery, or hidden policy. reference/standard-service (internal/app/routes.go) is the canonical implementation.

Canonical signature:

func(w http.ResponseWriter, r *http.Request)

Handler responsibilities (transport only):

  1. Read request inputs
  2. Validate transport-level shape
  3. Call a service
  4. Translate outcome to transport response

Not in handlers: raw SQL, repo construction, context service-lookup, config loading, transaction orchestration, response envelope invention.

type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
func createUserHandler(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
_ = contract.WriteError(w, r, contract.NewErrorBuilder().
Type(contract.TypeValidation).
Code("invalid_json").
Message("invalid request body").
Build())
return
}
}

Not canonical: middleware-first binding into context, mixed-source auto-binding, magic key DTO retrieval.

// Route param
id := Param(r, "id")
// Query param
r.URL.Query().Get("page")
// Header
r.Header.Get("Authorization")

Data source must be visible at the read site.

type Middleware func(http.Handler) http.Handler

next must be called exactly once. Not canonical: business DTO construction, service injection into context, domain success/failure decisions.

  • If construction cannot fail: Middleware(...) middleware.Middleware
  • If dependencies or config can be invalid: MiddlewareE(...) (middleware.Middleware, error)
  • Do not add new panic-only middleware constructors.
contract.WriteError(w, r, contract.NewErrorBuilder().
Type(contract.TypeValidation).
Code(contract.CodeInvalidJSON).
Message("invalid request body").
Build())

Rules:

  • Structured errors only (no ad hoc http.Error in JSON APIs)
  • Explicit or predictably derived status codes
  • Stable machine-readable error codes
  • Identical error class → identical shape across modules
contract.WriteResponse(w, r, http.StatusCreated, CreateUserResponse{ID: id}, nil)
  • One response helper, used consistently
  • Meaningful HTTP status codes set explicitly
type UserHandler struct {
Service user.Service
}
func (h UserHandler) Create(w http.ResponseWriter, r *http.Request) { ... }

Route wiring file must make clear: who constructs the handler, what its dependencies are, which routes use it.

func TestHealth(t *testing.T) {
app := core.New(core.DefaultConfig(), core.AppDependencies{})
if err := app.Get("/healthz", healthHandler); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
}

Rules:

  • httptest first
  • Table-driven for pure transport cases
  • Domain mocks behind interfaces
  • No full-framework bootstrap for simple route tests
  • No hidden global state between tests

contract owns transport primitives only: request/response envelopes, error types, HTTP writing helpers, context key accessors, and binding helpers.

ConcernCorrect home
Tracing infrastructurex/observability
Session lifecyclex/tenant or x/security
Metrics collectionx/observability
Business-domain validation rulescaller / domain package

All context accessor pairs use the With/From pattern:

func WithFoo(ctx context.Context, v Foo) context.Context
func FooFromContext(ctx context.Context) Foo

Context key types are always unexported zero-value structs inlined at the call site:

type fooContextKey struct{}
context.WithValue(ctx, fooContextKey{}, v)
err := contract.NewErrorBuilder().
Type(contract.TypeValidation).
Message("validation failed").
Build()
_ = contract.WriteError(w, r, err)
contract.WriteResponse(w, r, http.StatusOK, data, meta)

WriteJSON is a low-level raw-payload writer, not the success path.

  • Must not appear in canonical docs
  • Must be labeled clearly
  • New features must not build on compatibility-only surfaces
  • Any retained compatibility, alias, deprecated, or TODO marker must be registered in specs/deprecation-inventory.yaml
package handlers
import (
"encoding/json"
"net/http"
"github.com/spcent/plumego/contract"
)
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
type CreateUserResponse struct {
ID string `json:"id"`
}
type UserService interface {
Create(name, email string) (string, error)
}
type UserHandler struct {
Service UserService
}
func (h UserHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
_ = contract.WriteError(w, r, contract.NewErrorBuilder().
Type(contract.TypeValidation).
Code("invalid_json").
Message("invalid request body").
Build())
return
}
if req.Name == "" {
_ = contract.WriteError(w, r, contract.NewErrorBuilder().
Type(contract.TypeRequired).
Code("missing_name").
Message("name is required").
Build())
return
}
id, err := h.Service.Create(req.Name, req.Email)
if err != nil {
_ = contract.WriteError(w, r, contract.NewErrorBuilder().
Type(contract.TypeInternal).
Code("create_user_failed").
Message("failed to create user").
Build())
return
}
_ = contract.WriteResponse(w, r, http.StatusCreated, CreateUserResponse{ID: id}, nil)
}
  • Mixing Get, GetCtx, GetHandler styles in one example
  • Binding request DTOs in middleware, reading from context in CRUD handlers
  • Retrieving services from request context maps
  • Introducing new response helper families for a single feature
  • Teaching multiple equally valid bootstraps in first-party docs
  • Hiding route registration behind import side effects
  • Placing business logic in middleware

If a reviewer cannot understand how a request is handled within a few minutes by reading only the route registration, middleware, and handler file — the code is not canonical enough.