Zen
A Minimalist HTTP Library for Go

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:
- Duplicate error handling logic across every endpoint
- 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:
- Simplicity: Focus on core HTTP handling with minimal abstractions
- Clarity: Maintain Go's idioms and stay close to net/http's mental model
- 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:
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:
Middleware
At the core of Zen, middleware is just a function:
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:
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:
The WithDesc function is crucial here - it maintains two separate messages:
- An internal message with technical details for logging and debugging
- 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:
Server
The Server type manages HTTP server configuration, lifecycle, and route registration:
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:
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:
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:
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.
