We did not start with Gin. Our first Go microservices used the standard library with Chi for routing, a combination that worked well and stayed close to net/http conventions. The tradeoff we accepted with that approach was writing a significant amount of supporting infrastructure ourselves: input binding, validation integration, response helpers, and middleware that handled concerns like request tracing and panic recovery in a consistent way across services.
That infrastructure cost was acceptable for two services. By the fifth service, we were copying the same middleware files, the same response helpers, and the same error handling patterns between repositories. The code was not complex. It was repetitive. Every new service required the same bootstrapping work before any business logic could be written.
Gin addressed that directly. It ships with input binding, a validation integration, a response helper layer, and a middleware ecosystem that covers the common cases. The framework makes opinionated choices about those concerns so that services do not have to make them independently.
The decision was not Gin versus the standard library as a philosophical preference. It was a question of whether Gin's conventions were stable enough to build on and whether the performance profile fit our requirements. Both answers were yes. This article describes what we found, the patterns we settled on, and where Gin's abstractions required discipline to avoid misuse.
The Performance Case
Gin's headline claim is that it is the fastest full-featured HTTP framework in the Go ecosystem. That claim is based on benchmark comparisons with other frameworks, not with the standard library, and benchmarks measure throughput under synthetic conditions that rarely match production workloads precisely.
What the performance profile actually means in practice is that Gin's routing overhead per request is negligible. The httprouter-based radix tree that Gin uses for routing is faster than a linear scan through routes or a hash map lookup for most path patterns. Under our production load, the routing overhead measured below 2 microseconds per request. That number is irrelevant in isolation. It matters because it means the framework contributes no meaningful overhead to request handling, and optimisation effort belongs in the application logic rather than the infrastructure.
The more meaningful performance characteristic is Gin's approach to memory allocation. The framework reuses gin.Context objects across requests via a sync pool. Each request retrieves a context from the pool, uses it, and returns it rather than allocating a new struct for every request. Under high request rates, the reduction in garbage collector pressure from pool reuse is measurable.
// Gin's context pooling, simplified
type Engine struct {
pool sync.Pool
}
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
c := engine.pool.Get().(*Context)
c.writermem.reset(w)
c.Request = req
c.reset()
engine.handleHTTPRequest(c)
engine.pool.Put(c)
}
This is internal Gin code rather than something we write, but understanding it matters for how you use the framework. A *gin.Context must not be stored beyond the request lifecycle. Storing a context reference in a goroutine that outlives the request handler will produce a data race when the context is returned to the pool and reused by another request. We enforce this through code review: if a goroutine is launched from a handler, it receives copies of the values it needs, not the context itself.
Project Structure
We standardised on a project structure that separates Gin-specific code from business logic. The same structure applies across every service in the platform.
orders-service/
cmd/
api/
main.go
internal/
api/
router.go
middleware/
handler/
binding/ # Request binding structs
presenter/ # Response shaping
domain/
service/
store/
config/
config.go
Dockerfile
Makefile
internal/api is the Gin layer. Handlers know about *gin.Context. Everything below the handler layer, the services and stores, knows nothing about Gin. A service method that takes a context and returns a domain object or an error is testable without Gin, deployable behind a different framework, and readable without understanding how the HTTP layer works.
Engine Initialisation
Gin's default engine includes a logger and a recovery middleware. Both are useful in development and counterproductive in production as configured. We replace them with structured alternatives.
// internal/api/router.go
package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"orders/internal/api/handler"
"orders/internal/api/middleware"
"orders/internal/service"
)
func NewRouter(orderService service.OrderService) http.Handler {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// Replace Gin's default logger and recovery with structured versions
r.Use(middleware.StructuredLogger())
r.Use(middleware.Recovery())
r.Use(middleware.RequestID())
r.Use(middleware.Timeout(30 * time.Second))
r.GET("/health", handler.Health)
r.GET("/ready", handler.Ready)
v1 := r.Group("/v1")
v1.Use(middleware.Authenticate())
{
orders := v1.Group("/orders")
{
oh := handler.NewOrderHandler(orderService)
orders.GET("", oh.List)
orders.POST("", oh.Create)
orders.GET("/:orderID", oh.GetByID)
orders.POST("/:orderID/cancel", oh.Cancel)
orders.POST("/:orderID/fulfil", oh.Fulfil)
}
}
return r
}
gin.SetMode(gin.ReleaseMode) disables debug output and reduces per-request overhead from the debug logging Gin emits in development mode. This is not optional in production. Debug mode logs every registered route on startup and emits additional output per request.
gin.New() rather than gin.Default() gives explicit control over which middleware the engine uses. gin.Default() registers Gin's built-in logger and recovery, which write unstructured output. We register structured replacements explicitly.
Route groups use the braces convention to make the scope of each group visually clear. The braces are syntactically optional in Go but communicates intent. Routes defined inside a group's braces belong to that group.
Middleware
Gin middleware receives *gin.Context and calls c.Next() to pass control to the next handler in the chain. The pattern is idiomatic and straightforward.
// internal/api/middleware/logger.go
package middleware
import (
"log/slog"
"time"
"github.com/gin-gonic/gin"
)
func StructuredLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
slog.InfoContext(c.Request.Context(),
"request",
"method", c.Request.Method,
"path", c.FullPath(),
"status", c.Writer.Status(),
"bytes", c.Writer.Size(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", c.GetString("request_id"),
"client_ip", c.ClientIP(),
"errors", c.Errors.ByType(gin.ErrorTypePrivate).String(),
)
}
}
c.FullPath() returns the registered route pattern rather than the actual request path. Logging /v1/orders/:orderID rather than /v1/orders/ord-8f3a21 makes it possible to group log lines by route in your log aggregator without normalising dynamic path segments. This is a Gin-specific advantage over standard library middleware where you access r.URL.Path and get the actual values.
c.Errors.ByType(gin.ErrorTypePrivate).String() logs any errors that handlers attached to the context without aborting the request. Gin allows handlers to annotate the context with errors that are not immediately returned to the client, which is useful for reporting non-fatal conditions alongside successful responses.
// internal/api/middleware/recovery.go
package middleware
import (
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
)
func Recovery() gin.HandlerFunc {
return gin.CustomRecoveryWithWriter(nil, func(c *gin.Context, recovered any) {
slog.ErrorContext(c.Request.Context(),
"panic recovered",
"error", recovered,
"path", c.Request.URL.Path,
"method", c.Request.Method,
"request_id", c.GetString("request_id"),
)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"code": "INTERNAL_ERROR",
"message": "an internal error occurred",
})
})
}
gin.CustomRecoveryWithWriter(nil, ...) passes nil as the writer to disable Gin's default panic output to stderr. The custom recovery function logs the panic with structured fields and returns a consistent error response. Gin's default recovery writes the stack trace to stderr as unstructured text, which is not useful in a log aggregator.
Request Binding and Validation
Gin's binding package integrates with the go-playground/validator package. Struct tags define validation rules. ShouldBindJSON binds the request body and returns an error if binding or validation fails.
// internal/api/binding/order.go
package binding
import "github.com/google/uuid"
type CreateOrderRequest struct {
CustomerID uuid.UUID `json:"customer_id" binding:"required"`
Items []OrderItem `json:"items" binding:"required,min=1,dive"`
Currency string `json:"currency" binding:"required,len=3,uppercase"`
Notes string `json:"notes" binding:"omitempty,max=500"`
}
type OrderItem struct {
ProductID uuid.UUID `json:"product_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,min=1,max=1000"`
}
// internal/api/handler/order.go
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"orders/internal/api/binding"
"orders/internal/api/presenter"
"orders/internal/apierr"
)
type OrderHandler struct {
service service.OrderService
}
func NewOrderHandler(s service.OrderService) *OrderHandler {
return &OrderHandler{service: s}
}
func (h *OrderHandler) Create(c *gin.Context) {
var req binding.CreateOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": "INVALID_INPUT",
"message": "request validation failed",
"errors": formatValidationErrors(err),
})
return
}
order, err := h.service.CreateOrder(c.Request.Context(), service.CreateOrderInput{
CustomerID: req.CustomerID,
Items: mapOrderItems(req.Items),
Currency: req.Currency,
Notes: req.Notes,
})
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusCreated, presenter.OrderResponse(order))
}
c.Request.Context() is passed to the service layer rather than c. The service and store layers receive a standard context.Context, not a *gin.Context. This enforces the boundary between the HTTP layer and the business logic layer and prevents *gin.Context from leaking into code that should have no knowledge of the HTTP framework.
// internal/api/handler/errors.go
package handler
import (
"errors"
"net/http"
"github.com/gin-contrib/requestid"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"orders/internal/apierr"
)
func writeServiceError(c *gin.Context, err error) {
var appErr *apierr.AppError
if !errors.As(err, &appErr) {
c.JSON(http.StatusInternalServerError, gin.H{
"code": "INTERNAL_ERROR",
"message": "an internal error occurred",
})
return
}
c.JSON(appErr.Status, gin.H{
"code": appErr.Code,
"message": appErr.Message,
})
}
func formatValidationErrors(err error) []map[string]string {
var ve validator.ValidationErrors
if !errors.As(err, &ve) {
return []map[string]string{{"message": err.Error()}}
}
out := make([]map[string]string, len(ve))
for i, fe := range ve {
out[i] = map[string]string{
"field": fe.Field(),
"rule": fe.Tag(),
"message": formatFieldError(fe),
}
}
return out
}
formatValidationErrors converts validator.ValidationErrors into a structured slice that clients can iterate over and display per-field. The raw validator.ValidationErrors message is not client-safe. It exposes internal field names and validation rule names in a format designed for developer debugging rather than end-user display.
Presenters
Response shaping lives in a presenter package rather than in handlers. Handlers decide what to respond with. Presenters decide how to shape it.
// internal/api/presenter/order.go
package presenter
import (
"time"
"github.com/google/uuid"
"orders/internal/domain"
)
type OrderResponse struct {
ID uuid.UUID `json:"id"`
CustomerID uuid.UUID `json:"customer_id"`
Status string `json:"status"`
TotalCents int64 `json:"total_cents"`
Currency string `json:"currency"`
ItemCount int `json:"item_count"`
CreatedAt time.Time `json:"created_at"`
Items []OrderItemResponse `json:"items,omitempty"`
}
type OrderItemResponse struct {
ProductID uuid.UUID `json:"product_id"`
Quantity int `json:"quantity"`
UnitCents int64 `json:"unit_price_cents"`
TotalCents int64 `json:"total_cents"`
}
func Order(o *domain.Order) OrderResponse {
items := make([]OrderItemResponse, len(o.Items))
for i, item := range o.Items {
items[i] = OrderItemResponse{
ProductID: item.ProductID,
Quantity: item.Quantity,
UnitCents: item.UnitPriceCents,
TotalCents: item.TotalCents(),
}
}
return OrderResponse{
ID: o.ID,
CustomerID: o.CustomerID,
Status: string(o.Status),
TotalCents: o.TotalCents(),
Currency: o.Currency,
ItemCount: len(o.Items),
CreatedAt: o.CreatedAt,
Items: items,
}
}
Presenters decouple the JSON response shape from the domain model shape. A domain model can be refactored without changing the API contract, and an API contract can be updated without touching domain logic. TotalCents() is computed at presentation time from the domain object's method, not stored redundantly on the domain type.
Testing Handlers
Gin handlers are tested with httptest in the same way as standard library handlers. The test sets up a minimal Gin engine with the handler and sends requests through it.
// internal/api/handler/order_test.go
package handler_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"orders/internal/api/handler"
"orders/internal/domain"
)
func init() {
gin.SetMode(gin.TestMode)
}
func TestOrderHandler_Create(t *testing.T) {
customerID := uuid.New()
productID := uuid.New()
svc := &mockOrderService{
createResult: &domain.Order{
ID: uuid.New(),
CustomerID: customerID,
Status: domain.OrderStatusPending,
Currency: "EUR",
},
}
r := gin.New()
r.POST("/orders", handler.NewOrderHandler(svc).Create)
body, _ := json.Marshal(map[string]any{
"customer_id": customerID,
"currency": "EUR",
"items": []map[string]any{
{"product_id": productID, "quantity": 2},
},
})
req := httptest.NewRequest(http.MethodPost, "/orders", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
var resp map[string]any
err := json.NewDecoder(w.Body).Decode(&resp)
require.NoError(t, err)
assert.Equal(t, "EUR", resp["currency"])
}
func TestOrderHandler_Create_ValidationFails(t *testing.T) {
r := gin.New()
r.POST("/orders", handler.NewOrderHandler(&mockOrderService{}).Create)
// Missing required fields
body, _ := json.Marshal(map[string]any{"notes": "test"})
req := httptest.NewRequest(http.MethodPost, "/orders", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]any
json.NewDecoder(w.Body).Decode(&resp)
assert.Equal(t, "INVALID_INPUT", resp["code"])
assert.NotEmpty(t, resp["errors"])
}
gin.SetMode(gin.TestMode) in the test package's init function suppresses Gin's debug output during tests. Without it, every test run prints route registration logs to stdout.
Tests exercise the full handler path through a real Gin engine, which means binding and validation are tested as they run in production. Mocking ShouldBindJSON is not necessary and would produce tests that do not reflect actual behavior.
Conclusion
Gin earned its place in our stack not because of its benchmark numbers but because it eliminated the infrastructure repetition that was slowing down new service development. Binding, validation, response helpers and a usable middleware ecosystem are included without requiring us to build and maintain them ourselves.
The tradeoffs are real. Gin diverges from standard library conventions in ways that require discipline. *gin.Context must not escape the handler layer. The context pool means storing context references beyond the request lifecycle is a data race. gin.Default() must be replaced with gin.New() and explicit middleware in production.
None of those tradeoffs were disqualifying for our use case. The conventions are consistent and the escape hatches, like passing c.Request.Context() to service methods, are straightforward to enforce. The services that benefited most were the ones that came later in the platform's development, when the Gin patterns were already established and a new service could reach production-ready routing, middleware and validation in a day rather than a week.