Zen

A Minimalist HTTP Library for Go

Written by

Andreas Thomas

Published on

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 = api.V2RatelimitSetOverrideRequestBody
7type Response = api.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.

Protect your API.
Start today.

150,000 requests per month. No CC required.