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.
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:
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.
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:
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.
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.
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.
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.
Rather than forcing an existing framework to fit our needs, we decided to build Zen with three core principles in mind:
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.
Zen consists of four primary components, each serving a specific purpose in the request lifecycle:
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.
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}
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:
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}
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.
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.
Creating Zen has provided us with several key advantages:
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.
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.
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.
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.
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.
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.
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 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:
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.
150,000 requests per month. No CC required.