Kern

Recipes

Production-focused Kern recipes for Go API servers, including health checks, graceful shutdown, SPA serving, JSON errors, observability, and middleware patterns.

Recipes

This page collects practical patterns for building real Go services with Kern. Use it when you need copy-pasteable solutions for API behavior, operations, observability, and project structure.

Before diving in, you can scaffold a new project with the CLI tool:

go install github.com/mobentum/kern-cli@latest
kern-cli new myapp

Recipe Index

Custom 404 Handler

Since Kern uses http.ServeMux internally, 404s are handled by default. However, you can create a custom catch-all route if you need specific 404 logic (like returning a JSON error instead of text).

To handle 404s within a group (e.g., API), use a wildcard at the end of your group definitions.

app.GET("/{path...}", func(c *kern.Context) {
    c.JSON(404, map[string]string{
        "error": "resource not found",
    })
})

Graceful Shutdown

Kern supports graceful shutdown configuration, allowing ongoing requests to complete before the server stops.

// 10 seconds timeout for graceful shutdown
app.Run(":8080", kern.WithGracefulShutdown(10*time.Second))

Server Timeouts

Protect your server from slow clients by configuring read and write timeouts.

app.Run(":8080",
    kern.WithReadTimeout(5*time.Second),
    kern.WithWriteTimeout(10*time.Second),
)

Serving Single Page Applications (SPA)

To serve an SPA (like React, Vue, Svelte) where all non-file routes should redirect to index.html:

// Serve static assets from build directory
app.Static("/assets/", "./dist/assets")

// Catch-all route to serve index.html for client-side routing
app.GET("/{path...}", func(c *kern.Context) {
    http.ServeFile(c.Response, c.Request, "./dist/index.html")
})

Structuring Large Projects

For larger applications, separation of concerns is key.

cmd/
  api/
    main.go
internal/
  handlers/
    user.go
    auth.go
  middleware/
    auth.go
  models/
    user.go

In main.go:

func main() {
    app := kern.Default()

    // Register routes using handlers from internal packages
    handlers.RegisterUserRoutes(app.Group("/users"))
    handlers.RegisterAuthRoutes(app.Group("/auth"))

    app.Run(":8080")
}

Error Handling

Structured JSON Errors

Return consistent error responses across your API:

type ErrorResponse struct {
    Error   string `json:"error"`
    Code    string `json:"code,omitempty"`
    Details any    `json:"details,omitempty"`
}

func handleError(c *kern.Context, status int, message string) {
    c.JSON(status, ErrorResponse{Error: message})
}

// Usage
if user == nil {
    handleError(c, 404, "User not found")
    return
}

Panic Recovery with Custom Handler

Beyond the built-in Recovery middleware, handle panics gracefully:

app.Use(func(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("Panic recovered: %v", err)
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusInternalServerError)
                json.NewEncoder(w).Encode(map[string]string{
                    "error": "Internal server error",
                })
            }
        }()
        next.ServeHTTP(w, r)
    })
})

Rate Limiting

Simple in-memory rate limiting middleware:

func RateLimiter(requests int, window time.Duration) kern.MiddlewareFunc {
    type client struct {
        count     int
        resetTime time.Time
    }
    
    var (
        clients = make(map[string]*client)
        mu      sync.Mutex
    )
    
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ip := r.RemoteAddr
            
            mu.Lock()
            c, exists := clients[ip]
            now := time.Now()
            
            if !exists || now.After(c.resetTime) {
                clients[ip] = &client{1, now.Add(window)}
                mu.Unlock()
                next.ServeHTTP(w, r)
                return
            }
            
            if c.count >= requests {
                mu.Unlock()
                http.Error(w, "Too many requests", http.StatusTooManyRequests)
                return
            }
            
            c.count++
            mu.Unlock()
            next.ServeHTTP(w, r)
        })
    }
}

// Usage
app.Use(RateLimiter(100, time.Minute))

Request Logging

JSON Structured Logging

import (
    "encoding/json"
    "time"
)

type logEntry struct {
    Method   string        `json:"method"`
    Path     string        `json:"path"`
    Duration time.Duration `json:"duration_ms"`
}

func JSONLogger() kern.MiddlewareFunc {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            
            next.ServeHTTP(w, r)
            
            entry := logEntry{
                Method:   r.Method,
                Path:     r.URL.Path,
                Duration: time.Since(start),
            }
            
            if data, err := json.Marshal(entry); err == nil {
                log.Println(string(data))
            }
        })
    }
}

Observability for Request Guards and Sessions

Track RequestGuard deny path

When a RequestGuard rejects a request, it returns early with the configured status and message. Wrap guarded routes with a tiny status recorder to count rejects in logs or metrics.

type statusRecorder struct {
    http.ResponseWriter
    status int
}

func (r *statusRecorder) WriteHeader(code int) {
    r.status = code
    r.ResponseWriter.WriteHeader(code)
}

func ObserveGuardDenies(route string) kern.MiddlewareFunc {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
            rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
            next.ServeHTTP(rec, req)
            if rec.status == http.StatusBadRequest {
                log.Printf("guard_deny route=%s method=%s path=%s", route, req.Method, req.URL.Path)
            }
        })
    }
}

app.AddConstraints("POST", "/upload", kern.Constraints{
    Validate: func(next http.Handler) http.Handler {
        return ObserveGuardDenies("upload")(
            middleware.RequestGuard(middleware.RequestGuardConfig{
                RequireBody:       true,
                RequireHeaders:    []string{"X-Tenant"},
                AllowContentTypes: []string{"application/json"},
            })(next),
        )
    },
}, uploadHandler)

If a session cookie is malformed, has an invalid signature, or cannot be decrypted with configured keys, the middleware starts a fresh session instead of failing the request. Emit a signal when a session cookie is present but expected state is missing.

app.Use(middleware.Session(middleware.SessionConfig{
    SigningKey:    []byte("new-signing-key"),
    VerifyKeys:    [][]byte{[]byte("old-signing-key")},
    EncryptionKey: []byte("0123456789abcdef0123456789abcdef"),
    DecryptKeys:   [][]byte{[]byte("abcdef0123456789abcdef0123456789")},
}))

app.GET("/me", func(c *kern.Context) {
    rawCookie, _ := c.Cookie("_session")
    session, ok := middleware.GetSession(c.Context())
    if !ok {
        c.NoContent(http.StatusInternalServerError)
        return
    }

    userID, hasUser := session.Get("user_id")
    if rawCookie != nil && !hasUser {
        log.Printf("session_cookie_rejected path=%s", c.Request.URL.Path)
        c.JSON(http.StatusUnauthorized, map[string]string{"error": "session expired"})
        return
    }

    c.JSON(http.StatusOK, map[string]interface{}{"user_id": userID})
})

Use this signal to alert on spikes after key rotation deploys.

See runnable example: examples/observability/main.go

Health Checks

Simple health and readiness endpoints:

app.GET("/health", func(c *kern.Context) {
    c.JSON(200, map[string]string{"status": "ok"})
})

app.GET("/ready", func(c *kern.Context) {
    // Add database, cache checks here
    c.JSON(200, map[string]bool{
        "database": true,
        "cache":    true,
    })
})

Configuration

Environment-Based Config

type Config struct {
    Port         string
    ReadTimeout  time.Duration
    WriteTimeout time.Duration
    EnableDebug bool
}

func LoadConfig() *Config {
    return &Config{
        Port:         getEnv("PORT", "8080"),
        ReadTimeout:  getDurationEnv("READ_TIMEOUT", 30*time.Second),
        WriteTimeout: getDurationEnv("WRITE_TIMEOUT", 30*time.Second),
        EnableDebug:  getEnv("DEBUG", "false") == "true",
    }
}

func getEnv(key, defaultValue string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    return defaultValue
}

For more complete examples, check out the examples folder in the repository.

Next Step

Start with Production API Blueprint, then pick the focused recipe page for your current concern.