Why Gin Became Our Go-To HTTP Framework for High Performance Go Microservices

Software Engineer Dublin

Software Engineer Dublin

Engineer Built To Scale

Beyond The Code

What They Say

Let's Build

Message Received

Privacy Policy

Terms Of Use

Cookie Policy

Disclaimer

Latest Updates

Selected Work

Showcase

What We Offer

Industries We Serve

Beyond The Screen

Built Together

View View
Nben Malla
Nben Malla

Software Engineer

A software engineer who builds systems that scale, modernizes platforms that matter and ships code that holds up long after the project ends.

Based in Dublin, Ireland, with a presence across Bristol, Groningen and Kathmandu, the work spans FinTech platforms, SaaS products and open source contributions, collaborating with engineering teams across Nepal, Ireland, the Netherlands, New Zealand and the United States.

From leading legacy modernization for global banking clients to architecting microservices in Go, Java and Python, the focus has always been the same. Understand the problem deeply, build it right and make sure the people depending on it never have to think about it failing.

  • Read Article Read Article

    Packages 13 mins

    Why Gin Became Our Go-To HTTP Framework for High Performance Go Microservices

    Nben M. 06 Oct, 2025 13 mins

    Why Gin Became Our Go-To HTTP Framework for High Performance Go Microservices

    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.

    go
    // 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.

    markdown
    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.

    go
    // 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.

    go
    // 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.

    go
    // 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.

    go
    // 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"`
    }
    go
    // 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.

    go
    // 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.

    go
    // 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.

    go
    // 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.