Gin and Fiber Adapters: Swap Web Frameworks in Go with One Line

NestGo keeps your Golang application code independent of any HTTP engine. Handlers, guards, interceptors, pipes, and middleware are written against small core interfaces, and an adapter — the Gin adapter or the Fiber adapter — plugs a real web framework in underneath.

How the adapter pattern works in Go

The core package of NestGo has zero third-party dependencies. It defines three interfaces that every adapter must implement:

Core interface What it abstracts Gin implementation Fiber implementation
core.Server Start/StartTLS/Shutdown, Name, Underlying + full Router GinServer FiberServer
core.Router GET/POST/PUT/DELETE/PATCH/OPTIONS/HEAD/ANY, Group, Use, Static, StaticFile GinRouter FiberRouter
core.Context Request data, responses, per-request storage, Clone, Underlying GinContext FiberContext

Your controllers and middleware only ever see core.Context — never gin.Context or fiber.Ctx directly. That is what makes the engine swappable.

Each adapter exposes a constructor with the same signature:

func New(config *core.Config) core.Server

This matches di.ServerProvider, so the DI container never imports either adapter — you pass the constructor in from main.

The one-line swap with di.NewApp

Switching your Go web framework is a one-line change in main:

// Gin:
app := di.NewApp(config, ginadapter.New,
    fx.Provide(di.AsController(NewUserController)),
)

// Fiber — same app, different engine:
app := di.NewApp(config, fiberadapter.New,
    fx.Provide(di.AsController(NewUserController)),
)

Everything else — controllers, guards, interceptors, pipes, filters, middleware — runs unchanged on both adapters.

Conformance: both adapters prove the same contract

Parity is verified, not assumed. The nestgo module ships an adapter conformance suite — conformance.Run(t, factory) — of 22 named behavioral checks covering routing, request and response semantics, middleware composition, error mapping, context safety, streaming, body limits, and graceful shutdown. Both the Gin adapter and the Fiber adapter run it in their own test suites and pass every check under the race detector, with no skips. If you are building a third adapter, Writing an Adapter explains the contract and how to run the suite.

How to install each adapter

The adapters live in separate Go modules so your build only pulls in the engine you actually use.

Gin adapter:

go get github.com/ashrafAli23/nestgo-gin-adapter

Fiber adapter (Fiber v3):

go get github.com/ashrafAli23/nestgo-fiber-adapter

Both depend on the core module:

go get github.com/ashrafAli23/nestgo

Minimal server without DI

You can also use an adapter directly, without the fx container:

package main

import (
    ginadapter "github.com/ashrafAli23/nestgo-gin-adapter"
    "github.com/ashrafAli23/nestgo/core"
    "github.com/ashrafAli23/nestgo/middleware"
)

func main() {
    server := ginadapter.New(core.DefaultConfig())

    server.Use(middleware.Recovery())
    server.Use(middleware.CORS())

    server.GET("/hello", func(c core.Context) error {
        return c.JSON(200, map[string]string{"message": "Hello from Gin!"})
    })

    server.Start(":3000")
}

Replace ginadapter with fiberadapter (import github.com/ashrafAli23/nestgo-fiber-adapter) and the same program runs on Fiber.

Gin adapter in depth

The Gin adapter (package ginadapter) bridges NestGo to gin-gonic/gin, which runs on Go’s standard net/http stack.

Standard net/http server

ginadapter.New(config) builds a *gin.Engine and, on Start/StartTLS, wraps it in a standard *http.Server with ReadTimeout, WriteTimeout, ReadHeaderTimeout, and IdleTimeout taken from core.Config (in seconds) plus MaxHeaderBytes. config.BodyLimit is enforced with http.MaxBytesReader — an oversized request body surfaces as a 413 from Body()/Bind(). Graceful shutdown is Go’s built-in http.Server.Shutdown, safe to call concurrently with Start (a Shutdown that lands first makes Start return http.ErrServerClosed instead of binding the port):

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server.Shutdown(ctx)

config.Debug selects Gin’s debug vs release mode, and config.DisableLogger swaps gin.Default() (logger + recovery) for gin.New() with recovery only.

Request body caching

Gin’s GetRawData() drains the request body — a second call would return empty bytes. GinContext.Body() fixes this body-read-once problem: it reads once, caches the bytes, and restores the body reader. So this works:

server.POST("/orders", func(c core.Context) error {
    raw, _ := c.Body()   // first read — cached
    raw2, _ := c.Body()  // same cached bytes
    _ = raw
    _ = raw2

    var dto CreateOrderDTO
    return c.Bind(&dto)  // still works — body was restored
})

The adapter also wraps Gin’s response writer in a single buffered response recorder shared by the whole middleware chain: c.ResponseBody() returns a copy of what the handler wrote (useful in interceptors for response logging — because it is a copy, a body kept by a cache such as the Idempotency middleware can never be overwritten when the pooled recorder serves a later request), headers can still be changed after the handler runs, and the buffered response is flushed to the client exactly once. SendStream, SendFile, and Download bypass the buffer and stream directly with a flush per chunk, so SSE and large downloads never accumulate in memory (ResponseBody() returns nil once a response has been streamed). SendStream flushes the status and headers to the wire before it reads the first chunk, so a client that waits for headers before producing data — every browser EventSource — never deadlocks.

Clone: a fully detached snapshot

GinContext.Clone() returns a snapshot that is fully detached from the pooled context: the request body is pre-read and copied, context values and route params are copied (via gin.Context.Copy), and the request is detached with context.WithoutCancel so the clone’s RequestCtx() keeps working after the handler completes — safe to hand to a goroutine:

server.GET("/async", func(c core.Context) error {
    cloned := c.Clone()
    go func() {
        ip := cloned.ClientIP()   // safe
        method := cloned.Method() // safe
        _, _ = ip, method
    }()
    return c.JSON(202, map[string]string{"status": "accepted"})
})

Note that a Gin clone is detached from the real response, so treat it as read-only request data — response methods on the clone succeed but write nothing to the client. And like the Fiber adapter, using the original pooled context after the handler returns panics with a clear use-after-release message instead of racing.

Underlying(): the Gin escape hatch

When you need Gin-specific features, Underlying() hands you the raw objects:

// Server level → *gin.Engine
engine := server.Underlying().(*gin.Engine)
engine.SetTrustedProxies([]string{"192.168.1.0/24"})
engine.LoadHTMLGlob("templates/*")

// Handler level → *gin.Context
server.GET("/raw", func(c core.Context) error {
    gc := c.Underlying().(*gin.Context)
    _ = gc // use Gin-specific APIs
    return c.JSON(200, nil)
})

Use this sparingly — every Underlying() call is code that will not survive a framework swap.

Fiber adapter in depth

The Fiber adapter (package fiberadapter) bridges NestGo to Fiber v3, which is built on fasthttp rather than net/http.

fiberadapter.New(config) maps core.Config onto fiber.Config: AppName, BodyLimit, ReadTimeout, WriteTimeout, and IdleTimeout are applied directly (ReadHeaderTimeout and MaxHeaderBytes have no Fiber v3 equivalent and are not mapped), your config.ErrorHandler (or core.DefaultErrorHandler) becomes Fiber’s error handler (with Fiber’s own errors translated first — see below), and config.Debug controls the startup banner and route printing. The adapter installs panic recovery at two levels — its own recover around composed handlers and middleware, plus Fiber’s recover middleware for native handlers like static files — so a panicking handler becomes a logged 500 instead of killing the process. Shutdown uses Fiber’s ShutdownWithContext.

Errors raised by Fiber itself keep their status

Fiber and fasthttp raise their own *fiber.Error values for transport-level conditions: an oversized body (413 from fiber.Config.BodyLimit), an unmatched route (404), or a wrong method (405). The adapter translates any *fiber.Error into a core.HTTPError with the same code and message before it reaches the error handler — both in Fiber’s app-level error handler and in the per-route error dispatch — so core.DefaultErrorHandler (or your own config.ErrorHandler) answers with the real status instead of genericizing it into a 500. Errors that are not a *fiber.Error pass through unchanged, so the security rule “unknown, non-HTTP errors become a generic 500” still holds. One consequence: a handler that returns a hand-rolled fiber.NewError(code, msg) now surfaces msg to the client with that code. Prefer core.NewHTTPError in handlers, and treat a raw Fiber error’s message as client-visible.

Streaming and ResponseBody()

SendStream sets fasthttp’s ImmediateHeaderFlush on the response, so the status and headers reach the client as soon as streaming starts rather than waiting for the first body chunk — without it, an SSE client that waits for headers before sending data would deadlock. Because fasthttp’s Response.Body() drains a body stream when one is set, ResponseBody() returns nil for a streamed response instead of touching the stream — the same contract as the Gin adapter. An error returned after SendStream has begun is logged rather than dispatched, and never consumes the stream.

Per-request contexts and use-after-release protection

Fiber recycles its context objects (and their underlying fasthttp buffers) after every request. The adapter deliberately does not pool its own FiberContext wrapper: each request gets a fresh, tiny three-field struct whose atomic.Bool released flag is set once and never cleared. Every method checks that flag first, so using the context after the handler returned panics deterministically with a clear message instead of silently reading another request’s data:

[NestGo] use-after-release: FiberContext used after handler returned.
Fiber contexts are recycled. Use c.Clone() before passing to goroutines.

The check is a single atomic load — roughly a nanosecond per call.

Clone returns a FiberContextSnapshot

Because fiber.Ctx cannot outlive its handler, FiberContext.Clone() deep-copies the essential request data into a standalone, read-only FiberContextSnapshot: method, matched route path, client IP, full URL (scheme://host/path?query), body bytes, headers, route params, and query params, plus a context.Context derived with context.WithoutCancel so background work is not cancelled when the request ends. Header lookups on a snapshot are case-insensitive, matching the live context.

import fiberadapter "github.com/ashrafAli23/nestgo-fiber-adapter"

server.GET("/async", func(c core.Context) error {
    snapshot := c.Clone()
    go func() {
        defer fiberadapter.ReleaseSnapshot(snapshot)
        ip := snapshot.ClientIP() // safe — reads copied data
        _ = ip
    }()
    return c.JSON(202, map[string]string{"status": "accepted"})
})

Snapshots are read-only. Response methods (JSON, String, Redirect, …) and Bind/FormValue/FormFile/Cookie are not supported on a snapshot — they return errors or empty values. Do all response writing before the handler returns, on the original context.

Snapshots are pooled too: calling fiberadapter.ReleaseSnapshot(snapshot) when the goroutine finishes returns the struct (and its maps) to the pool, reducing GC pressure under heavy fan-out. It is optional — if you skip it, the GC collects the snapshot normally.

Underlying(): the Fiber escape hatch

// Server level → *fiber.App
app := server.Underlying().(*fiber.App)

// Handler level → fiber.Ctx (an interface in Fiber v3, not a pointer)
server.GET("/raw", func(c core.Context) error {
    fc := c.Underlying().(fiber.Ctx)
    _ = fc // use Fiber-specific APIs
    return c.JSON(200, nil)
})

A cloned snapshot’s Underlying() returns nil — the original fiber.Ctx is already recycled.

RequestCtx() on the Fiber adapter returns a small wrapper around fasthttp’s request context rather than the *fasthttp.RequestCtx itself. The wrapper captures the Done() channel once when the request starts, which keeps the cancellation signal identical (same channel, same close on shutdown) while avoiding a data race between goroutines that poll Done() and Shutdown. It is therefore not type-assertable to *fasthttp.RequestCtx. When you need the raw fasthttp context, go through the escape hatch: c.Underlying().(fiber.Ctx).RequestCtx().

Context safety in goroutines: always Clone

This rule applies to both adapters, because both recycle per-request data:

// WRONG — context is recycled after the handler returns:
go func() { doWork(c) }()

// CORRECT — clone first:
go func(ctx core.Context) { doWork(ctx) }(c.Clone())

What differs is the failure mode and what the clone can do:

  Gin adapter Fiber adapter
Unsafe use after handler returns Panics with a clear use-after-release message Panics with a clear use-after-release message
Clone() returns Detached GinContext snapshot (body copied, request detached) Read-only FiberContextSnapshot
Clone can write responses No — calls succeed but write nothing to the client No — response methods return errors
Release the clone Not needed (GC) Optional fiberadapter.ReleaseSnapshot() for pooling

Middleware composition and ordering

Both adapters compose middleware the same way: at route registration, the group chain (outermost), then route middleware, then the handler are combined into a single native handler. Two guarantees follow:

  • Errors returned by a handler flow back through every middleware — exception filters and interceptors see them — and the configured error handler runs exactly once, and only if nothing has been written to the response yet. No double-written responses.
  • Because composition happens at registration time, middleware added via Use() after a route is registered does not apply to that route. Register global middleware before routes — di.NewApp already registers global middleware before controllers, so DI-based apps are unaffected.

Gin vs Fiber: how to choose an adapter

Both adapters are first-class; the honest trade-off is ecosystem versus raw speed.

Choose the Gin adapter when:

  • You want full net/http compatibility — standard http.Server, standard middleware and instrumentation from the wider Go ecosystem, familiar timeout and TLS behavior.
  • You rely on tooling built around net/http (profilers, tracing libraries, httptest-style integration tests).
  • You value Gin’s maturity and its very large ecosystem of examples and answers.

Choose the Fiber adapter when:

  • Raw throughput and low allocation counts matter most — Fiber runs on fasthttp, which trades net/http compatibility for speed.
  • Your workload is many small, fast requests (APIs, proxies) rather than long-lived streaming over standard interfaces.
  • You are comfortable with fasthttp’s constraints, including strict context lifetimes (which this adapter turns into loud panics rather than silent corruption).

For most Go web applications, either is more than fast enough — the bottleneck is usually your database, not the router. Because swapping is a one-line change, you can start with one and benchmark the other with your real workload later.

Behavior parity notes

Handlers behave identically across adapters for the core API, with a few honest differences to know about:

Area Gin adapter Fiber adapter
Path() Matched route pattern via FullPath() Matched route pattern via Route().Path
Body() Cached; repeatable; Bind still works after Copied once into an adapter-owned cache; stable after the handler returns
Bind() ShouldBind — auto-detects Content-Type Bind().Body()
ResponseBody() Copy of the buffered response recorder (nil once streamed) Copy of Fiber’s response buffer (nil once streamed)
Config fields used Addr, BodyLimit, ReadTimeout, WriteTimeout, ReadHeaderTimeout, IdleTimeout, MaxHeaderBytes, Debug, DisableLogger, ErrorHandler Addr, AppName, BodyLimit, ReadTimeout, WriteTimeout, IdleTimeout, Debug, ErrorHandler
AppName Ignored Applied to fiber.Config.AppName
ReadHeaderTimeout / MaxHeaderBytes Mapped onto the http.Server Not supported — Fiber v3 exposes no equivalent
BodyLimit Enforced with http.MaxBytesReader → 413 Enforced by Fiber via fiber.Config.BodyLimit → 413
Clone semantics Detached snapshot (body copied; response calls write nowhere) Read-only snapshot; Cookie/FormValue return "", Bind/response methods return errors
Request cancellation Per-request via net/http Server-shutdown only — fasthttp cannot signal per-request client disconnects; RequestCtx() is a wrapper, not a *fasthttp.RequestCtx
Shutdown http.Server.Shutdown(ctx) fiber.App.ShutdownWithContext(ctx)

FullURL(), QueryDefault() (default only when the key is absent), String() (verbatim when no format values), and SendBytes() (defaults to application/octet-stream) behave identically on both adapters — each is pinned by a named check in the conformance suite. If a difference here matters to your app (for example, needing ReadHeaderTimeout on Fiber), reach for Underlying() in one well-isolated place.

Source for both adapters lives in the NestGo repository.

Next steps

  • Production Guide — every core.Config field the adapters consume.
  • Type-Safe Handlers — typed extractors and handle builders over the core.Context API on either engine.
  • Middleware — adapter-agnostic middleware like Recovery, CORS, and RateLimit.
  • Writing an Adapter — the contract rules and the 22-check conformance suite for building a third adapter.