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
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") // ListenAndServeThree things matter:
Serverwraps the standard library mux and accepts the standard middleware shape- Generated routes register through
srv.Handle("VERB /path", handlerFn, mws...)using Go 1.22+ pattern syntax; a root route,get Home /with no@prefix, registersGET /{$}(GET /api/{$}under the base path/api), so it answers its one path, the documented/, and not every path below it - Binding and validation are generated Go, JSON is
encoding/jsonbehind 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
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:
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 theWithTelemetrymiddleware and outside everysrv.UsemiddlewareAccessLog(logger)- onehttp accessline 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 ownBodyLimit(maxBytes)- caps request bodies; a body over the cap answers 413{"message":"request entity too large"}Timeout(d)- deprecated; usesrv.SetDefaultHandlerTimeout(d)or@timeout, which put the deadline on the request contextCORSPermissive()/CORSStrict(origin)- build aCORSOptionspreset, then attach withsrv.SetCORS(opts)- preflight + headersCompress(opts)- gzip / deflate response compression; a partial (206) response, whoseContent-Rangecounts 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 aContent-Typekeeps the onenet/httpwould sniff from its plain bytes.Compresspanics on aLeveloutside -2 to 9, whichCompressOptions.Validatereports 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:
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:
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:
// 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 toencoding/jsonbut lets you drop in another, such assonicorgoccy/go-jsonfrom v0.11.1, process-wide. The codec must honouromitzero, or a nil optional list, map orbytesmember goes out asnull(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:
// @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:
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:
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 documentmain.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:
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
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:
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:
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:
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:
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 instrument | Prometheus family | Unit |
|---|---|---|
http.server.request.duration | http_server_request_duration_seconds | seconds |
http.server.request.body.size | http_server_request_body_size_bytes | bytes |
http.server.response.body.size | http_server_response_body_size_bytes | bytes |
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:
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:
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.