Skip to content

Runtime ​

The craftgo runtime is a thin wrapper around net/http. There is no custom router, no custom middleware shape, and no reflection-driven dependency injection. This page is the HTTP half; the event half is pkg/events, covered in Events. Architecture shows where both sit.

At a glance ​

go
srv := server.New(svcCtx)              // wraps *http.ServeMux
srv.Use(loggingMiddleware)             // standard func(http.Handler) http.Handler
wiring.Register(ctx, srv, svcCtx)      // the one generated call that attaches the design
srv.Start(":8080")                     // ListenAndServe

Three things matter:

  1. Server wraps the standard library mux and accepts the standard middleware shape
  2. Generated routes register through srv.Handle("VERB /path", handlerFn, mws...) using Go 1.22+ pattern syntax; a root route, get Home / with no @prefix, registers GET /{$} (GET /api/{$} under the base path /api), so it answers its one path, the documented /, and not every path below it
  3. Binding and validation are generated Go, JSON is encoding/json behind a swappable codec, and the logic is yours - no reflection-driven dispatch in the hot path

If you can name a net/http concept, the craftgo equivalent uses it directly.

The Server ​

go
import "github.com/craftgodotdev/craftgo/pkg/server"

srv := server.New(svcCtx)
srv.Use(loggingMiddleware)
srv.Handle("GET /ping", pingHandler)
srv.Start(":8080")

Server wraps *http.ServeMux. Routes register through Handle and HandleFunc using Go 1.22+ pattern syntax (GET /users/{id}). Middleware is plain func(http.Handler) http.Handler.

Handle is variadic - Handle(pattern, h, mws...) - so a route can carry per-route middleware that wraps the handler outermost-first (the first middleware argument is the outermost frame, hit first on the way in). For composing a reusable stack, server.Chain folds a middleware list in the same order:

go
chain := server.NewChain(server.BodyLimit(1 << 20), server.AccessLog(logger))
srv.Handle("GET /ping", chain.Then(pingHandler))

NewChain(...).Append(...) returns a new chain (value semantics, safe to share a base), and .Then(h) / .ThenFunc(fn) produce the wrapped handler. Nil entries are skipped, so an optional middleware can drop into the slice without a guard.

Built-in middleware ​

Out of the box:

  • Recovery(logger) - converts a panic to 500 {"message":"internal server error"} and logs it with its stack; Handler() installs it itself, inside the WithTelemetry middleware and outside every srv.Use middleware
  • AccessLog(logger) - one http access line per request (method, path, status - 499 for a client that left before anything was written - latency, plus the trace ids on the context); a request whose handler panics gets none. AccessLogSkipPaths(...) keeps chosen routes out, AccessLogFields(...) adds fields of your own
  • BodyLimit(maxBytes) - caps request bodies; a body over the cap answers 413 {"message":"request entity too large"}
  • Timeout(d) - deprecated; use srv.SetDefaultHandlerTimeout(d) or @timeout, which put the deadline on the request context
  • CORSPermissive() / CORSStrict(origin) - build a CORSOptions preset, then attach with srv.SetCORS(opts) - preflight + headers
  • Compress(opts) - gzip / deflate response compression; a partial (206) response, whose Content-Range counts the plain bytes, a status without a body, a HEAD response and an already encoded one go out as they are, and a body without a Content-Type keeps the one net/http would sniff from its plain bytes. Compress panics on a Level outside -2 to 9, which CompressOptions.Validate reports first

The writers Recovery, AccessLog and Compress hand a handler keep what net/http's offers: http.Hijacker, so a websocket upgrade takes the connection, http.Flusher, and io.ReaderFrom, so http.ServeFile still sends with sendfile. Each unwraps for http.ResponseController too.

You wire them in main.go:

go
srv := server.New(svcCtx, server.WithTelemetry(tel.HTTPMiddleware()))
srv.Use(server.AccessLog(log.Follow()))
srv.Use(server.BodyLimit(1 << 20))

Standard middleware works ​

Because middleware is func(http.Handler) http.Handler, anything from the wider Go ecosystem plugs in:

go
import (
    chiMW "github.com/go-chi/chi/v5/middleware"
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

srv.Use(chiMW.Recoverer)
srv.Use(otelhttp.NewMiddleware("api"))

No adapter, no shim. craftgo handlers are http.HandlerFunc.

Handlers ​

Generated handlers look like this:

go
// CreateUser returns the POST CreateUser handler.
func CreateUser(svcCtx *svccontext.ServiceContext) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req types.CreateUserReq
		if err := server.JSON().Decode(r.Body, &req); err != nil {
			server.WriteValidationError(w, r, err)
			return
		}
		if err := req.Validate(); err != nil {
			server.WriteValidationError(w, r, err)
			return
		}
		l := service.NewCreateUserService(r.Context(), svcCtx)
		resp, err := l.CreateUser(&req)
		if err != nil {
			server.WriteError(w, r, err)
			return
		}
		server.WriteResponse(w, r, http.StatusCreated, resp)
	}
}

This is exactly what you would write by hand: one http.HandlerFunc, stdlib status codes, stdlib responses. Two details to note:

  • server.JSON() is the swappable codec accessor - it defaults to encoding/json but lets you drop in another, such as sonic or goccy/go-json from v0.11.1, process-wide. The codec must honour omitzero, or a nil optional list, map or bytes member goes out as null (see Runtime API). The decode/encode shape is otherwise standard.
  • The logic call is l.CreateUser(&req) - the request context is captured when the per-method service is constructed (service.NewCreateUserService(r.Context(), svcCtx)), so it isn't threaded through the method call.

Raw sides ​

@rawResponse keeps the bind + validate steps and hands the writer to logic; @rawRequest skips them and hands the request over unread. The generated handlers are just as plain:

go
// @rawResponse get Download /download/{file} { request DownloadReq }
func Download(svcCtx *svccontext.ServiceContext) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var req types.DownloadReq
        req.File = r.PathValue("file")
        if err := req.Validate(); err != nil {
            server.WriteValidationError(w, r, err)
            return
        }
        l := service.NewDownloadService(r.Context(), svcCtx)
        if err := l.Download(w, r, &req); err != nil {
            server.WriteError(w, r, err)
            return
        }
    }
}

// @rawRequest post Ingest /ingest { response IngestResult }
func Ingest(svcCtx *svccontext.ServiceContext) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        l := service.NewIngestService(r.Context(), svcCtx)
        resp, err := l.Ingest(r)
        if err != nil {
            server.WriteError(w, r, err)
            return
        }
        server.WriteResponse(w, r, http.StatusCreated, resp)
    }
}

server.WriteBytes, server.WritePrecompressed and server.AcceptsEncoding are the helpers a raw-response handler reaches for (see Runtime API); server.WriteError never splices an error envelope into a response the handler has already committed.

Health endpoints ​

Server mounts /healthz (liveness) and /readyz (readiness) by default. Add custom checks:

go
srv.RegisterHealthCheck("db", 2*time.Second, func(ctx context.Context) error {
    return db.PingContext(ctx)
})

The second argument is a per-check timeout: each run gets a context that expires after it, so a check that honours its context fails with context deadline exceeded, while one that ignores its context runs to completion and is judged by what it returns. A zero or negative timeout sets no deadline, leaving the probe request's own context.

The probes are answered before the middleware chain: they are not access-logged, traced or counted in the HTTP metrics, and no srv.Use middleware (CORS, auth, rate limits) runs for them. Disable them with server.WithoutDefaultHealth() and register your own route when you want observed probes.

API reference docs ​

A freshly generated project serves its OpenAPI document and a rendered docs page out of the box, configured under docs in config.yaml:

yaml
docs:
  enabled: true            # off → no docs/spec routes
  ui: redoc                # redoc | swagger | scalar (assets load from a CDN)
  path: /docs              # HTML docs page
  specPath: /openapi.yaml  # raw OpenAPI document

main.go embeds the generated openapi.yaml and wires it with srv.RegisterDocs(...) when the design has HTTP routes and, as main.go is first written, the document is on disk in or below main.go's directory. To add it to a hand-written server, or to a gen-once main.go that does not serve it:

go
import _ "embed"

//go:embed docs/openapi.yaml
var openapiSpec []byte

srv.ServeDocs(server.DocsOptions{Spec: openapiSpec, UI: "redoc"})

This registers GET /openapi.yaml (the spec) and GET /docs (the UI page). It is a no-op when Spec is empty. srv.RegisterDocs(opts) registers the same and returns an error, registering neither, for a Path or SpecPath that does not start with / or that http.ServeMux refuses, for the two being equal, and for one a health probe or a registered route already serves; ServeDocs panics with that error, and the generated main.go logs it and exits. The page loads its UI from the jsDelivr CDN at a pinned version (Redoc 2.5.4, Swagger UI 5.33.0, Scalar 1.71.0), each script and stylesheet carrying its Subresource Integrity hash, so a browser refuses a file the CDN serves changed.

Graceful shutdown ​

go
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)

lis, err := net.Listen("tcp", ":8080")
if err != nil {
    log.Default().Error("server start", log.Err(err))
    os.Exit(1)
}
log.Default().Info("listening", log.String("addr", lis.Addr().String()))
go srv.Serve(lis)
<-stop

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Stop(ctx); err != nil {
    log.Default().Warn("http drain cut off requests", log.Err(err))
}

Stop closes the listener, waits for in-flight requests, then returns; when ctx ends first it returns ctx's error, and the requests still running end with the process. A Stop that comes before Start or Serve makes them return nil at once, as http.Server.Shutdown does, so a signal that arrives while the listener starts cannot leave it running. Serve takes a listener you bound, so the log names the address it holds - the real port of a :0.

The generated main.go does the same in a goroutine per listener: a listener that cannot bind, or stops serving, shuts the rest down as a signal does, and the process then exits 1. With routes and RPCs, a signal stops both listeners at once - the gRPC health service turns NOT_SERVING as HTTP stops accepting - and they drain side by side within one 5s budget, each followed by its wiring shutdown; the telemetry flush comes last.

Logging ​

pkg/log provides a small structured logger. Generated logic carries a logger pre-bound to the request context:

go
func (l *GetUserService) GetUser(req *types.GetUserReq) (*types.User, error) {
	l.Info("fetching user", log.String("id", req.ID))
	// ...
}

trace_id and span_id flow into every log line automatically while tracing is on, which the generated config.yaml does by default (otel.exporter: none keeps the spans in-process); the response carries the matching traceparent header. log.SetContextFields(fn) adds fields of your own from the request context the same way - a tenant or user id a middleware stored there reaches every line, the framework's and your logic's alike:

go
log.SetContextFields(func(ctx context.Context) []log.Field {
    if u, ok := auth.UserFrom(ctx); ok {
        return []log.Field{log.String("user_id", u.ID)}
    }
    return nil
})

Log level ​

The level is process-wide, not per-logger. The server and the generated logic both write through log.Default(), which srv.SetLogger installs, and New/NewConsole build that logger over a shared zap.AtomicLevel, so a single call retunes the server and the generated logic layer together:

go
log.SetLevel(log.LevelDebug)   // LevelDebug / LevelInfo / LevelWarn / LevelError
current := log.GetLevel()

The default is LevelInfo. The swap is atomic and takes effect on the next log call - no logger replacement needed, so it is safe to wire to a /debug/loglevel endpoint or a config reload. Loggers you build yourself and pass to log.NewZap keep their own level and ignore SetLevel.

log.New, the default, writes JSON lines to stderr and writes every one: zap's sampling, which keeps few repeats of one message within a second, is off, so no access, error or panic line is lost under load. To sample, build a zap logger with Sampling set and install it with log.SetDefault(log.NewZap(z)).

Tracing and metrics ​

Both signals are set up by one call, and the middleware that emits them is a method on what it returns:

go
tel, err := telemetry.Init(ctx, cfg.Config)
defer tel.Shutdown(ctx)
srv := server.New(svc, server.WithTelemetry(tel.HTTPMiddleware()))

tel owns everything: both providers, the Prometheus registry, and the scrape listener. Shutdown closes the listener and flushes any pending OTLP push batch, so main.go has one teardown line rather than three that must stay in step. server.WithTelemetry installs the middleware outside Recovery and every srv.Use middleware, so the access and panic lines carry its trace ids; adding it with srv.Use as well records every request twice.

The middleware records spans for every request and stamps trace IDs onto the context. Metrics ride the same wrapper, which is why otel.enabled: false stops the spans but not the http.server.* series - those follow metrics.enabled.

What gets emitted ​

craftgo follows OTel semantic conventions, so the instruments are the semconv ones - not the names other Go frameworks use. Three HTTP instruments; the Prometheus scrape also carries the Go runtime and process collectors (go_*, process_*) and target_info, plus whatever your own code records:

OTel instrumentPrometheus familyUnit
http.server.request.durationhttp_server_request_duration_secondsseconds
http.server.request.body.sizehttp_server_request_body_size_bytesbytes
http.server.response.body.sizehttp_server_response_body_size_bytesbytes

Each carries http_request_method, http_response_status_code, http_route, network_protocol_name, network_protocol_version, server_address, url_scheme, plus the otel_scope_* identity labels. service.name rides on target_info, not on the series.

http_route is the route pattern, not the request path - /api/todos/{id}, never /api/todos/42 - so grouping by it cannot blow up cardinality. It comes from the pattern Go's ServeMux records on the matched request, which is exactly what generated routes register - as long as no srv.Use middleware hands next a copy of the request (r.WithContext(…)): the mux then records the pattern on the copy, and the series carry no http_route.

A p95-by-route panel therefore reads:

promql
histogram_quantile(0.95,
  sum by (http_route, le) (rate(http_server_request_duration_seconds_bucket[5m])))

Migrating a dashboard from a framework that used the older http.server.duration convention (milliseconds, a path label) means three edits per panel: the metric name, path → http_route, and the panel unit from ms to seconds.

service.name comes from the top-level serviceName in config.yaml and rides on target_info, so both signals report one identity without configuring it twice.

See Configuration for the YAML knobs.

ServiceContext ​

ServiceContext is the dependency container. craftgo writes it once as a struct holding the loaded config and, embedded, one field per declared middleware; you add the rest:

go
type ServiceContext struct {
    Config *config.Config
    Middlewares           // generated, embedded

    DB    *sql.DB         // your fields
    Cache *redis.Client
}

wiring.Register(ctx, srv, svc) hands it to every generated handler, and each handler to its logic (service.NewCreateUserService(r.Context(), svcCtx)); server.New ignores the argument it takes.

Concurrency ​

ServiceContext is shared across every concurrent request - craftgo does NOT auto-lock its fields. Long-lived dependencies (DB pools, Redis clients, gRPC channels, …) handle their own locking internally and are safe to keep as bare fields. Mutable in-process state (maps, slices, counters) is your responsibility: either guard it with sync.Mutex / sync.RWMutex / sync.Map, use atomic types, or make the state per-request and pass it through context.Context. The upload example keeps its state in a MediaStore (svccontext/store.go) whose methods take a sync.RWMutex themselves, so handlers call them without locking.

What is not in craftgo ​

  • No DI container with reflection
  • No struct-tag binding or validation: parameters bind and values validate in generated code
  • No custom HTTP method dispatcher
  • No interceptor chain that hides the request lifecycle
  • No process-wide state beyond what a setter names: the JSON codec, the error hooks, the default logger and its level

If you can name a net/http concept, the craftgo equivalent uses it directly.

Released under the MIT License.