← All Posts

Lessons from Scaling Go-Gin Web Services: The Things Nobody Tells You

Navigating the challenges of building production-grade APIs with type-safe code generation

Published by Subrat Prasad on 2025-12-05

When you're building backend systems that need to serve millions of requests, frameworks don't behave the way tutorials make them look. Along the way, the happy paths disappear, the undocumented edges show up, and the stack that felt clean in a Pets-service example suddenly needs guardrails, patterns, and a lot of debugging discipline.

This article captures the real-world issues we hit, how we solved them, and the patterns we now rely on.

Our Stack

We have standard open-source technologies from the Go ecosystem:

  • Go with Gin web framework
  • OpenAPI spec
  • oapi-codegen for OpenAPI specification to generate go-gin framework scaffolding
  • Strict server mode for type-safe handlers

1. Strict Middleware Cannot Access HTTP Status Codes

When using strict mode from oapi-codegen, middleware executes before the handler and long before the response is written.

Here's a strict middleware that tries exactly what you're thinking:

func StatusLogger(f v1.StrictHandlerFunc, _ string) v1.StrictHandlerFunc {
    return func(c *gin.Context, req interface{}) (interface{}, error) {
        resp, err := f(c, req)
        fmt.Println("Status after handler:", c.Writer.Status())
        return resp, err
    }
}

You would expect this to print 200, right? Because the handler has executed:

func (s *Server) GetHealth(ctx context.Context, _ v1.GetHealthRequestObject) (v1.GetHealthResponseObject, error) {
    return v1.GetHealth200Response{}, nil
}

But the actual output is still:

Status after handler: 0

Why? The generated wrapper writes the response after your strict middleware returns.

Here's the sequence:

Execution Order

  1. Strict middleware (your code)
  2. Call handler f()
  3. Strict middleware finishes and returns
  4. oapi-codegen generated code writes: Status code, Headers, JSON body

So at the moment your strict middleware executes — even after calling f() — Gin still hasn't written:

  • c.Writer.WriteHeader(...)
  • c.JSON(...)
  • or even set c.Writer.Status() internally

Thus c.Writer.Status() == 0.

This isn't a bug — it's simply how the generated strict server pipeline works. Many developers hit this the moment they try to introduce:

  • request/response logging
  • telemetry
  • latency measurements
  • endpoint-level metrics
  • structured access logs

All of these require the actual response status code — which strict middleware cannot see.

Our approach

We used strict middleware only for request shaping, and Gin middleware for anything dependent on the response.

Gin middleware (can access the status code):

func Telemetry() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        c.Next()
        status := c.Writer.Status() // works correctly
        latency := time.Since(start)
        // record logs or metrics here
        _, _ = status, latency
    }
}

Strict middleware (request transformation only):

func RequestID(f v1.StrictHandlerFunc, op string) v1.StrictHandlerFunc {
    return func(c *gin.Context, req interface{}) (interface{}, error) {
        c.Request = c.Request.WithContext(
            context.WithValue(c.Request.Context(), RequestIDKey, uuid.NewString()),
        )
        return f(c, req)
    }
}

Takeaway: Strict middleware cannot read response information. If you need status codes, use Gin middleware.

2. The Gin Context vs Go Context Confusion

OpenAPI-generated strict handlers receive gin.Context as the context.Context parameter. This creates confusion because:

  1. Most Go libraries expect context.Context, not gin.Context
  2. gin.Context embeds context.Context, but they're not the same
  3. Request-scoped values stored in gin.Context aren't accessible via standard context.Context methods

The Solution

We created a helper function to extract the underlying context.Context from gin.Context:

// GetRequestContext extracts context.Context from gin.Context
// This is required because most standard tools and libraries expect
// context.Context and not gin.Context
func GetRequestContext(ctx context.Context) context.Context {
    gctx, ok := ctx.(*gin.Context)
    if !ok {
        return ctx
    }
    rctx := gctx.Request.Context()
    if rctx == nil {
        return ctx
    }
    return rctx
}

First thing in the handler: call the helper to extract the request context. Example:

func (s *Service) GetOrder(ctx context.Context, request v1.GetOrderRequestObject) (v1.GetOrderResponseObject, error) {
    reqCtx := GetRequestContext(ctx)

    userID := GetStringValue(reqCtx, UserIDKey)

    order, err := s.db.GetOrder(reqCtx, request.OrderID)
    // ... rest of handler
}

3. Type Aliases vs New Types in Generated Code

Before we get into the problem with oapi-codegen, it's important to understand a Go language detail that often confuses even experienced developers.

Go has two different ways to define types — and they look almost identical:

1. Defining a new type

type ProductID string

This creates a distinct type. ProductID is not the same as string and cannot be used interchangeably without explicit conversion.

2. Defining a type alias

type ProductID = string

This means: ProductID is literally the same type as string. No new type is created. No additional type safety is added. No conversion is needed.

The Problem: oapi-codegen Generates Type Aliases, Not New Types

When you define IDs in OpenAPI:

ProductID:
  type: string

oapi-codegen produces:

type ProductID = string

This is a type alias, not a real type.

What this means in practice

func GetProductID(id ProductID) { ... }
GetProduct("this-is-a-user-id") // compiles just fine

But "this-is-a-user-id" is not a ProductID — it's just a plain string being passed where an ID type was expected. No compile-time protection. No domain modeling. No safety. No accidental misuse prevention.

This is especially problematic in real systems where you may have many domain identifiers: UserID, CatalogueID, MerchantID. All of these collapse into just string if generated as aliases.

If Go generated:

type ProductID string
type UserID string

Now you get real safeguards:

GetProduct(UserID("u123"))
// compile error: cannot use UserID as ProductID

Which is exactly what you want. This forces correct use, reduces accidental bugs, and makes your domain model explicit.

4. Middleware Execution Order

Gin executes middleware in reverse order of registration. This can be counterintuitive:

r.Use(middleware.A)  // Executes third
r.Use(middleware.B)  // Executes second
r.Use(middleware.C)  // Executes first

For strict middleware, the order is forward:

mw := []v1.StrictMiddlewareFunc{
    middleware.A,  // Executes first
    middleware.B,  // Executes second
    middleware.C,  // Executes third
}

Solution: Document the execution order clearly and test middleware interactions thoroughly.

Conclusion

None of these issues are "bugs" — they're just behaviors you only notice when you start making a Go service truly production-ready. My hope with sharing this is simple: if you're using Go, Gin and oapi-codegen, you shouldn't have to rediscover these details the hard way.

If you've hit other quirks in this stack or learned patterns that make your services more robust, share them — I'd love to learn from your experience as well.