Blog/engineering

# Zen

A Minimalist HTTP Library for Go

Written by

Andreas Thomas

Published on

Mar 13, 2025

When we started migrating our API services from TypeScript to Go, we were looking for an HTTP framework that would provide a clean developer experience, offer precise control over middleware execution, and integrate seamlessly with OpenAPI for our SDK generation. After evaluating the popular frameworks in the Go ecosystem, we found that none quite matched our specific requirements.

So, we did what engineers do: we built our own. Enter Zen, a lightweight HTTP framework built directly on top of Go's standard library.

## The Pain Points with Existing Frameworks

Our journey began with our TypeScript API using Hono, which offered a fantastic developer experience with Zod validations and first-class OpenAPI support. When migrating to Go, we faced several challenges with existing frameworks:

### Complex Middleware Execution Order

Most frameworks enforce a rigid middleware execution pattern that didn't allow for our specific needs. The critical limitation we encountered was the inability to capture post-error-handling response details—a fundamental requirement not just for our internal monitoring but also for our customer-facing analytics dashboard.

- We needed an error handling middleware that could parse returned errors and construct properly typed problem+json responses
- OpenAPI validation needed to run before our handler code but after error handling, to return nice validation responses
- Most importantly, we needed logging middleware that could run after error handling was complete, to capture the final HTTP status code and response body that was actually sent to the client

This last point is crucial for both debugging and customer visibility. We store these responses and make them available to our customers in our dashboard, allowing them to inspect exactly what their API clients received. When an error occurs, customers need to see the precise HTTP status code and response payload their systems encountered, not just that an error happened somewhere in the pipeline.

While we could have potentially achieved this with existing frameworks, doing so would have required embedding error handling and response logging logic directly into every handler function. This would mean handlers couldn't simply return Go errors—they would need to know how to translate those errors into HTTP responses and also handle logging those responses. This approach would:

1. Duplicate error handling logic across every endpoint
2. Make handlers responsible for concerns beyond their core business logic

Our goal was to keep handlers simple, allowing them to focus on business logic and return domain errors without worrying about HTTP status codes, response formatting, or logging..

By building Zen, we could ensure handlers remained clean and focused while still providing our customers with complete visibility into their API requests—including the exact error responses their systems encountered.

### Poor OpenAPI Integration

While frameworks like huma.rocks offered OpenAPI generation from Go code, we preferred a schema-first approach. This approach gives us complete control over the spec quality and annotations. With our SDKs generated via Speakeasy from this spec, we need to set the bar high to let them deliver the best SDK possible.

### Dependency Bloat

Many frameworks pull in dozens of dependencies, which adds maintenance, potential security risks and the possibility of supply chain attacks. We wanted something minimal that relied primarily on Go's standard library.

### Inflexible Error Handling

Go's error model is simple, but translating errors into HTTP responses (especially RFC 7807 problem+json ones) requires special handling. Existing frameworks made it surprisingly difficult to map our domain errors to appropriate HTTP responses.

## The Zen Philosophy

Rather than forcing an existing framework to fit our needs, we decided to build Zen with three core principles in mind:

1. Simplicity: Focus on core HTTP handling with minimal abstractions
2. Clarity: Maintain Go's idioms and stay close to net/http's mental model
3. Efficiency: No unnecessary dependencies, with low overhead

Put simply, Zen is a thin wrapper around Go's standard library that makes common HTTP tasks more ergonomic while providing precise control over request handling.

## The Core Components of Zen

Zen consists of four primary components, each serving a specific purpose in the request lifecycle:

### Sessions

The `Session` type encapsulates the HTTP request and response context, providing utility methods for common operations:

    1route := zen.NewRoute("GET", "/v2/liveness",
    2	func(ctx context.Context, s *zen.Session) error {
    3		res := Response{
    4			Message: "we're cooking",
    5		}
    6		return s.JSON(http.StatusOK, res)
    7	},
    8)

Sessions are pooled and reused between requests to reduce memory allocations and GC pressure, a common performance concern in high-throughput API servers.

### Routes

The `Route` interface represents an HTTP endpoint with its method, path, and handler function. Routes can be decorated with middleware chains:

    1func main(){
    2	// ...
    3
    4	// Create a route
    5	route := zen.NewRoute("POST", "/v2/ratelimit.limit", handler)
    6
    7	// Register with middleware
    8	server.RegisterRoute(
    9	    []zen.Middleware{
    10	      zen.WithTracing(),
    11	      zen.WithMetrics(eventBuffer),
    12	      zen.WithLogging(logger),
    13	      zen.WithErrorHandling(logger),
    14	      zen.WithValidation(validator),
    15	    },
    16	    route,
    17	)
    18}

### Middleware

At the core of Zen, middleware is just a function:

    1type Middleware func(handler HandleFunc) HandleFunc

But this simple definition makes it so powerful. Each middleware gets a handler and returns a wrapped handler - that's it. No complex interfaces or lifecycle hooks to learn.

What's special about this approach is that it lets us control exactly when each piece of middleware runs. For example, our logging middleware captures the final status code and response body:

    1func WithLogging(logger logging.Logger) Middleware {
    2    return func(next HandleFunc) HandleFunc {
    3        return func(ctx context.Context, s *Session) error {
    4            start := time.Now()
    5
    6            // Call the next handler in the chain
    7            err := next(ctx, s)
    8
    9            // Log after handling is complete
    10            logger.InfoContext(ctx, "request",
    11                slog.String("method", s.r.Method),
    12                slog.String("path", s.r.URL.Path),
    13                slog.Int("status", s.responseStatus), // Captured from response
    14                slog.String("latency", time.Since(start).String()),
    15            )
    16
    17            return err
    18        }
    19    }
    20}

To understand our error handling middleware, it's important to first know how we tag errors in our application. We use a custom `fault` package that enables adding metadata to errors, including tags that categorize the error type and separate internal details from user-facing messages.

In our handlers or services, we can return tagged errors like this:

    1// When a database query returns no results
    2if errors.Is(err, sql.ErrNoRows) {
    3    return fault.Wrap(err,
    4        fault.WithTag(fault.NOT_FOUND),
    5        fault.WithDesc(
    6            fmt.Sprintf("namespace '%s' not found in database", namespaceName),  // Internal details for logs
    7            "This namespace does not exist"                                      // User-facing message
    8        )
    9    )
    10}
    11
    12// When handling permission checks
    13if !permissions.Valid {
    14    return fault.New("insufficient permissions",
    15        fault.WithTag(fault.INSUFFICIENT_PERMISSIONS),
    16        fault.WithDesc(
    17            fmt.Sprintf("key '%s' lacks permission on resource '%s'", auth.KeyID, namespace.ID),
    18            permissions.Message  // User-friendly message from the permission system
    19        )
    20    )
    21}

The `WithDesc` function is crucial here - it maintains two separate messages:

1. An internal message with technical details for logging and debugging
2. A user-facing message that's safe to expose in API responses

This separation lets us provide detailed context for troubleshooting while ensuring we never leak sensitive implementation details to users.

Our error handling middleware then examines these tags to determine the appropriate HTTP response:

    1func WithErrorHandling(logger logging.Logger) Middleware {
    2    return func(next HandleFunc) HandleFunc {
    3        return func(ctx context.Context, s *Session) error {
    4            err := next(ctx, s)
    5            if err == nil {
    6                return nil
    7            }
    8
    9            // Convert domain errors to HTTP responses
    10            switch fault.GetTag(err) {
    11            case fault.NOT_FOUND:
    12                return s.JSON(http.StatusNotFound, api.NotFoundError{
    13                    Title:     "Not Found",
    14                    Type:      "https://unkey.com/docs/errors/not_found",
    15                    Detail:    fault.UserFacingMessage(err),
    16                    RequestId: s.requestID,
    17                    Status:    http.StatusNotFound,
    18                    Instance:  nil,
    19                })
    20            case fault.BAD_REQUEST:
    21                return s.JSON(http.StatusBadRequest, api.BadRequestError{
    22                    Title:     "Bad Request",
    23                    Type:      "https://unkey.com/docs/errors/bad_request",
    24                    Detail:    fault.UserFacingMessage(err),
    25                    RequestId: s.requestID,
    26                    Status:    http.StatusBadRequest,
    27                    Instance:  nil,
    28                    Errors:    []api.ValidationError{...},
    29                })
    30            // Additional cases...
    31            }
    32
    33            // Default to 500 Internal Server Error
    34            return s.JSON(http.StatusInternalServerError, api.InternalServerError{
    35                Title:     "Internal Server Error",
    36                Type:      "https://unkey.com/docs/errors/internal_server_error",
    37                Detail:    fault.UserFacingMessage(err),
    38                RequestId: s.requestID,
    39                Status:    http.StatusInternalServerError,
    40                Instance:  nil,
    41            })
    42        }
    43    }
    44}

### Server

The `Server` type manages HTTP server configuration, lifecycle, and route registration:

    1// Initialize a server
    2server, err := zen.New(zen.Config{
    3    Logger: logger,
    4    // ...
    5})
    6if err != nil {
    7    log.Fatalf("failed to create server: %v", err)
    8}
    9
    10// Register routes
    11server.RegisterRoute([]zen.Middleware{...}, route)
    12
    13// Start the server
    14err = server.Listen(ctx, ":8080")

The server handles graceful shutdown, goroutine management, and session pooling automatically.

## OpenAPI Integration the Right Way

Unlike frameworks that generate OpenAPI specs from code, we take a schema-first approach. Our OpenAPI spec is hand-crafted for precision and then used to generate Go types and validation logic:

    1// OpenAPI validation middleware
    2func WithValidation(validator *validation.Validator) Middleware {
    3    return func(next HandleFunc) HandleFunc {
    4        return func(ctx context.Context, s *Session) error {
    5            err, valid := validator.Validate(s.r)
    6            if !valid {
    7                err.RequestId = s.requestID
    8                return s.JSON(err.Status, err)
    9            }
    10            return next(ctx, s)
    11        }
    12    }
    13}

Our validation package uses `pb33f/libopenapi-validator` which provides structural and semantic validation based on our OpenAPI spec. In an ideal world we wouldn't use a dependency for this, but it's way too much and too error prone to implement ourselves at this stage.

## The Benefits of Building Zen

Creating Zen has provided us with several key advantages:

### Complete Middleware Control

We now have granular control over middleware execution, allowing us to capture metrics, logs, and errors exactly as needed. The middleware is simple to understand and compose, making it easy to add new functionality or modify existing behavior.

### Schema-First API Design

By taking a schema-first approach to OpenAPI, we maintain full control over our API contract while still getting Go type safety through generated types. This ensures consistency across our SDKs and reduces the likelihood of API-breaking changes.

### Minimal Dependencies

Zen relies almost entirely on the standard library, with only a few external dependencies for OpenAPI validation. This reduces our dependency footprint and makes the codebase easier to understand and maintain.

### Idiomatic Go

Zen follows Go conventions and idioms, making it feel natural to Go developers. Handler functions receive a context as the first parameter and return an error, following common Go patterns.

### Type Safety with Ergonomics

The Session methods for binding request bodies and query parameters into Go structs provide type safety without boilerplate. The error handling middleware gives structured, consistent error responses.

## Real-World Example: Rate Limiting API

Here's a complete handler from our rate-limiting API that shows how all these components work together:

    1package handler
    2
    3import (...)
    4
    5// Reexporting to reuse in tests
    6type Request = spec.V2RatelimitSetOverrideRequestBody
    7type Response = spec.V2RatelimitSetOverrideResponseBody
    8
    9
    10// Define the dependencies for this route. These are injected during route registration
    11type Services struct {
    12	Logger      logging.Logger
    13	DB          db.Database
    14	Keys        keys.KeyService
    15	Permissions permissions.PermissionService
    16}
    17
    18func New(svc Services) zen.Route {
    19	return zen.NewRoute("POST", "/v2/ratelimit.setOverride", func(ctx context.Context, s *zen.Session) error {
    20
    21		auth, err := svc.Keys.VerifyRootKey(ctx, s)
    22		if err != nil {
    23			return err
    24		}
    25
    26		req := Request{}
    27		err = s.BindBody(&req)
    28		if err != nil {
    29			return err // already tagged
    30		}
    31
    32		namespace, err := getNamespace(ctx, svc, auth.AuthorizedWorkspaceID, req)
    33		if err != nil {
    34			if errors.Is(err, sql.ErrNoRows) {
    35				return fault.Wrap(err,
    36					fault.WithTag(fault.NOT_FOUND),
    37					fault.WithDesc("namespace not found", "This namespace does not exist."),
    38				)
    39			}
    40			return err
    41		}
    42
    43		if namespace.WorkspaceID != auth.AuthorizedWorkspaceID {
    44			return fault.New("namespace not found",
    45				fault.WithTag(fault.NOT_FOUND),
    46				fault.WithDesc("wrong workspace, masking as 404", "This namespace does not exist."),
    47			)
    48		}
    49
    50		permissions, err := svc.Permissions.Check(
    51			ctx,
    52			auth.KeyID,
    53			rbac.Or(
    54				rbac.T(rbac.Tuple{
    55					ResourceType: rbac.Ratelimit,
    56					ResourceID:   namespace.ID,
    57					Action:       rbac.SetOverride,
    58				}),
    59				rbac.T(rbac.Tuple{
    60					ResourceType: rbac.Ratelimit,
    61					ResourceID:   "*",
    62					Action:       rbac.SetOverride,
    63				}),
    64			),
    65		)
    66		if err != nil {
    67			return fault.Wrap(err,
    68				fault.WithTag(fault.INTERNAL_SERVER_ERROR),
    69				fault.WithDesc("unable to check permissions", "We're unable to check the permissions of your key."),
    70			)
    71		}
    72
    73		if !permissions.Valid {
    74			return fault.New("insufficient permissions",
    75				fault.WithTag(fault.INSUFFICIENT_PERMISSIONS),
    76				fault.WithDesc(permissions.Message, permissions.Message),
    77			)
    78		}
    79
    80		overrideID := uid.New(uid.RatelimitOverridePrefix)
    81		err = db.Query.InsertRatelimitOverride(ctx, svc.DB.RW(), db.InsertRatelimitOverrideParams{
    82			ID:          overrideID,
    83			WorkspaceID: auth.AuthorizedWorkspaceID,
    84			NamespaceID: namespace.ID,
    85			Identifier:  req.Identifier,
    86			Limit:       int32(req.Limit),    // nolint:gosec
    87			Duration:    int32(req.Duration), //nolint:gosec
    88			CreatedAt:   time.Now().UnixMilli(),
    89		})
    90		if err != nil {
    91			return fault.Wrap(err,
    92				fault.WithTag(fault.DATABASE_ERROR),
    93				fault.WithDesc("database failed", "The database is unavailable."),
    94			)
    95		}
    96
    97		return s.JSON(http.StatusOK, Response{
    98			OverrideId: overrideID,
    99		})
    100	})
    101}
    102
    103func getNamespace(ctx context.Context, svc Services, workspaceID string, req Request) (db.RatelimitNamespace, error) {
    104
    105	switch {
    106	case req.NamespaceId != nil:
    107		{
    108			return db.Query.FindRatelimitNamespaceByID(ctx, svc.DB.RO(), *req.NamespaceId)
    109		}
    110	case req.NamespaceName != nil:
    111		{
    112			return db.Query.FindRatelimitNamespaceByName(ctx, svc.DB.RO(), db.FindRatelimitNamespaceByNameParams{
    113				WorkspaceID: workspaceID,
    114				Name:        *req.NamespaceName,
    115			})
    116		}
    117	}
    118
    119	return db.RatelimitNamespace{}, fault.New("missing namespace id or name",
    120		fault.WithTag(fault.BAD_REQUEST),
    121		fault.WithDesc("missing namespace id or name", "You must provide either a namespace ID or name."),
    122	)
    123
    124}

The handler is just a function that returns an error, making it easy to test and reason about. All the HTTP-specific logic (authentication, validation, error handling, response formatting) is handled by middleware or injected services.

## Testing Made Easy

Zen's simple design makes testing very easy, even our CEO loves it. Because routes are just functions that accept a context and session and return an error, they're easy to unit test:

    1package handler_test
    2
    3import (...)
    4
    5func TestRatelimitEndpoint(t *testing.T) {
    6    h := testutil.NewHarness(t)
    7
    8    route := handler.New(handler.Services{
    9        DB:          h.DB,
    10        Keys:        h.Keys,
    11        Logger:      h.Logger,
    12        Permissions: h.Permissions,
    13    })
    14
    15    h.Register(route)
    16
    17    rootKey := h.CreateRootKey(h.Resources.UserWorkspace.ID)
    18
    19    headers := http.Header{
    20        "Content-Type":  {"application/json"},
    21        "Authorization": {fmt.Sprintf("Bearer %s", rootKey)},
    22    }
    23
    24    req := handler.Request{
    25        Namespace:  "test_namespace",
    26        Identifier: "user_123",
    27        Limit:      100,
    28        Duration:   60000,
    29    }
    30
    31    res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req)
    32    require.Equal(t, 200, res.Status)
    33    require.NotNil(t, res.Body)
    34    require.True(t, res.Body.Success)
    35    require.Equal(t, int64(100), res.Body.Limit)
    36    require.Equal(t, int64(99), res.Body.Remaining)
    37}

We've built test utilities that make it easy to set up a test harness with database dependencies, register routes, and call them with typed requests and responses.

## Zen is Open Source

Zen lives in our open source mono repo, so you can explore or even use it in your own projects. The full source code is available in our GitHub repository at github.com/unkeyed/unkey/tree/main/go/pkg/zen.

While we built Zen specifically for our needs, we recognize that other teams might face similar challenges with Go HTTP frameworks. You're welcome to:

- Read through the implementation to understand our approach
- Fork the code and adapt it to your own requirements
- Use it directly in your projects if it fits your needs

## Conclusion

While the Go ecosystem offers many excellent HTTP frameworks, sometimes the best solution is a custom one tailored to your specific needs. A thin layer on top of Go's standard library can provide significant ergonomic benefits without sacrificing control or performance.

As our API continues to grow, the simplicity and extensibility of Zen will allow us to add new features and functionality without compromising on performance or developer experience. The best abstractions are those that solve real problems without introducing new ones, and by starting with Go's solid foundation and carefully adding only what we needed, we've created a framework that enables our team to build with confidence.

Written by

Andreas Thomas

Published on

Mar 13, 2025

Contents

- The Pain Points with Existing Frameworks
- Complex Middleware Execution Order
- Poor OpenAPI Integration
- Dependency Bloat
- Inflexible Error Handling
- The Zen Philosophy
- The Core Components of Zen
- Sessions
- Routes
- Middleware
- Server
- OpenAPI Integration the Right Way
- The Benefits of Building Zen
- Complete Middleware Control
- Schema-First API Design
- Minimal Dependencies
- Idiomatic Go
- Type Safety with Ergonomics
- Real-World Example: Rate Limiting API
- Testing Made Easy
- Zen is Open Source
- Conclusion

Suggested

Vncod — Hành trình định nghĩa lại tiêu chuẩn vận hành Game

Mar 26, 2026

How to ratelimit public pages

Dec 11, 2025

Building a flow editor

Dec 02, 2025

## Sẵn sàng bứt phá doanh thu.
Bắt đầu ngay hôm nay.

Liên hệ với chúng tôi
Server Game

Hỗ trợ cài đặt trọn gói. Cam kết vận hành ổn định 24/7.

We value your privacy
This site uses cookies to improve your browsing experience, analyze site traffic, and show personalized content.