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.NewAppalready 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/httpcompatibility — standardhttp.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 tradesnet/httpcompatibility 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.Configfield the adapters consume. - Type-Safe Handlers — typed extractors and handle builders over the
core.ContextAPI 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.