Writing a NestGo Adapter in Go: Implement the Contract, Prove It with the Conformance Suite

NestGo ships two official adapters — Gin and Fiber — but the core package is interfaces only, so any Go HTTP engine can sit underneath it. An adapter is the bridge: it implements the core interfaces on top of an engine, and everything written against core.Context (controllers, guards, interceptors, pipes, middleware) runs on it unchanged. This page covers what an adapter has to implement, the contract rules the two official adapters follow, and the conformance suite that tells you whether your adapter follows them too.

What an adapter implements

An adapter is a separate Go module (the official ones are nestgo-gin-adapter and nestgo-fiber-adapter) that exports one constructor:

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

That signature is di.ServerProvider, so di.NewApp(config, myadapter.New, ...) works without the DI container ever importing your module. Behind the constructor sit two interfaces and an optional set of capabilities.

core.Server embeds core.Router

type Server interface {
    Router
    Start(addr string) error
    StartTLS(addr, certFile, keyFile string) error
    Shutdown(ctx context.Context) error
    Name() string
    Underlying() interface{}
}

core.Router is route registration: GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, and ANY, each taking a core.HandlerFunc plus optional route middleware; Group(prefix, middleware...) returning a nested Router; Use(middleware...); and Static/StaticFile. Start blocks until the server stops, Shutdown must drain in-flight requests before returning, and Underlying() exposes the engine itself (*gin.Engine, *fiber.App) as the escape hatch.

The constructor receives a *core.Config. These are the fields an adapter is expected to honor:

core.Config field What the adapter does with it
BodyLimit Enforce at the transport so an oversized request body is answered with 413 (Gin: http.MaxBytesReader; Fiber: fiber.Config.BodyLimit)
ReadTimeout, WriteTimeout, IdleTimeout, ReadHeaderTimeout, MaxHeaderBytes Map onto the engine’s server (seconds and bytes). Document any field the engine cannot express — Fiber v3 has no ReadHeaderTimeout/MaxHeaderBytes
ErrorHandler Invoke it for errors returned by handlers; fall back to core.DefaultErrorHandler when nil
Debug, DisableLogger, AppName Engine debug/release mode, the engine’s own access logging, and the engine’s application name where it has one

core.Context

core.Context is the only thing handlers and middleware see, so it is the larger of the two interfaces. Grouped the way core/context.go groups them:

  • Request data — Method, Path (the matched route pattern, not the URL), Param, Query, QueryDefault, GetHeader, Cookie, Body, Bind, FormValue, FormFile, ContentType, IsWebSocket.
  • Response — Status, JSON, XML, String, SendBytes, SendStream, SendFile, Download, NoContent, Redirect, SetHeader, SetCookie.
  • Response introspection — ResponseStatus (0 until something is written) and ResponseBody (a copy of what has been written, nil if nothing has).
  • Request metadata — ClientIP, FullURL (scheme://host/path?query).
  • Per-request storage — Set, Get.
  • Flow control — Next.
  • Safe concurrency — Clone, a snapshot that stays valid after the handler returns.
  • Escape hatches — Underlying (the engine’s own context), RequestCtx and SetRequestCtx (the standard context.Context).

The doc comments in core/context.go are the specification. Several of them have sharp edges that the conformance suite pins down explicitly: Body() must be repeatable, QueryDefault falls back only when the key is absent (a present-but-empty value is returned as ""), String() without format arguments is sent verbatim (no Sprintf), and SendBytes defaults Content-Type to application/octet-stream.

Optional capability interfaces

core/respintrospect.go declares three interfaces an adapter context may implement in addition to core.Context. Middleware type-asserts for them and degrades gracefully when they are absent:

// Discard a buffered, not-yet-sent response so middleware can replace it —
// the ETag middleware turning a buffered 200 into a 304 Not Modified.
type ResponseResetter interface {
    ResetResponse()
}

// Read back a response header set during handler execution — the
// Idempotency middleware capturing Content-Type for faithful replays.
type ResponseHeaderReader interface {
    ResponseHeader(key string) string
}

// Set a cookie with an explicit SameSite attribute ("Lax", "Strict", "None") —
// the CSRF middleware hardening its double-submit cookie.
type SameSiteCookieSetter interface {
    SetCookieSameSite(name, value string, maxAge int, path, domain string, secure, httpOnly bool, sameSite string)
}

Both official adapters implement all three. Implement them if your engine can, and pin every interface with compile-time assertions so a signature drift fails the build instead of a user’s request:

var _ core.Server               = (*MyServer)(nil)
var _ core.Router               = (*MyRouter)(nil)
var _ core.Context              = (*MyContext)(nil)
var _ core.ResponseResetter     = (*MyContext)(nil)
var _ core.ResponseHeaderReader = (*MyContext)(nil)
var _ core.SameSiteCookieSetter = (*MyContext)(nil)

Contract rules the official adapters follow

The interfaces say what to implement; these four rules say how, and each one is where a real bug has lived in a real adapter.

1. Compose middleware per route, at registration time

When a route is registered, build one native engine handler for it: the group chain outermost, then the route middleware, then the handler, folded as mws[0](mws[1](...(handler))). That single native handler:

  1. Acquires your adapter context for the request.
  2. Runs the composed chain inside a recover. A panic is logged server-side with its stack and becomes core.ErrInternalServer("Internal Server Error") — the client never sees the panic value.
  3. If the chain returned an error, invokes the error handler (config.ErrorHandler, or core.DefaultErrorHandler when nil) exactly once, and only if nothing has been written to the response yet. If something has, log the error instead — never write a second response.
  4. Tells the engine the request is handled, so the engine’s own error handler cannot run a second time for the same error.

Three guarantees fall out of this, and middleware relies on all of them. Errors returned by a handler flow back through every middleware, so interceptors and exception filters (which compile to core.MiddlewareFunc) observe them as the return value of next(c). Middleware added with Use() after a route is registered does not apply to that route (di.NewApp registers global middleware before controllers, so DI-based apps never notice). And the error handler runs at most once per request, so a client never receives two JSON documents in one body.

Do not route NestGo middleware through the engine’s own middleware chain — use the engine’s chain only for native handlers such as static files, and wrap those with the same acquire/recover/dispatch-once path.

2. Copy any transport-owned memory that escapes the handler

Engines recycle memory: fasthttp reuses per-connection buffers, and the Gin adapter pools its response recorder. Anything a handler or middleware can legitimately hold onto after the request must therefore be an adapter-owned copy:

  • Body() reads the transport once, caches an owned copy, and returns it on every call — Bind() still works afterwards.
  • ResponseBody() returns a copy of the buffered body (append([]byte(nil), buf...)), and nil once streaming has started.
  • Clone() deep-copies method, path, params, query, headers, body, and context values, and derives its RequestCtx() with context.WithoutCancel so it stays usable after the handler returns.

The cost of skipping this is silent and delayed: the Gin adapter once returned its live recorder buffer from ResponseBody(), and the Idempotency middleware’s cache could replay another request’s bytes hours later. The suite catches exactly that.

3. Flush headers at stream start and after every chunk

SendStream must push the status and headers to the wire before it reads the first chunk, then write and flush after every chunk. The reason is a deadlock: a browser EventSource — and any client that waits for a response before producing data — blocks until the headers arrive, while an adapter that waits for the first body chunk before sending headers blocks on the client. Both sides wait forever. core.SSE() is built on SendStream (it sets the text/event-stream headers and hands your SendStream an io.Reader over the event channel), so getting this right is what makes server-sent events work on your adapter.

How to flush depends on the engine: the Gin adapter calls Flush() on the response writer up front (a bare WriteHeaderNow marks the header as written without reaching the socket), and the Fiber adapter sets Response().ImmediateHeaderFlush = true. Once streaming has started, never touch the response body again — fasthttp’s Response.Body() drains a body stream — so ResponseBody() returns nil for a streamed response, and the error-dispatch path must check “is this streaming?” before “was a body written?”.

4. Use-after-release must panic, never race

Contexts are recycled after the handler returns. A handler that hands c to a goroutine without Clone() must hit a deterministic panic on its next method call — with a message that names the fix — rather than silently reading another request’s data. Both official adapters do this with an atomic.Bool released flag that every method checks first (a single atomic load), and both panic with a message of the form:

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

In the same spirit, RequestCtx() must be non-nil and not yet canceled while the handler runs, and it should carry real cancellation: per-request on net/http, server-shutdown on fasthttp (which cannot observe individual client disconnects).

Two more expectations round out the contract and have their own checks: Config.BodyLimit is enforced at the transport (an over-limit body is a 413), and Shutdown(ctx) drains in-flight requests to completion instead of dropping them.

Run the conformance suite

The github.com/ashrafAli23/nestgo/conformance package (in the nestgo module from v1.5.0; it imports only core and the standard library) is a reusable behavioral test suite for adapters. An adapter passes the suite if and only if it implements the NestGo core contract. Run it from your adapter’s tests — this is the code from the package documentation, and it is all that is needed:

func TestConformance(t *testing.T) {
    conformance.Run(t, func(cfg *core.Config) core.Server {
        return myadapter.New(cfg)
    })
}

The factory is conformance.Factory, func(cfg *core.Config) core.Server, and must return a fresh, unstarted server each time it is called. Run executes every check as a named subtest against its own server: the harness builds a server from your factory (with the check’s config, or core.DefaultConfig(), and DisableLogger forced on), lets the check register routes and middleware, starts it on a free 127.0.0.1 port, waits up to five seconds for it to accept connections, and registers Shutdown as a t.Cleanup. Requests go over a real socket with net/http’s client, not through a fake — the suite tests what your users will see.

Run it the way CI does, and narrow it while you work:

go test -race -count=1 ./...                                      # whole adapter, including the suite
go test -race -run 'TestConformance/streaming' .                  # one area
go test -race -count=20 -run 'TestConformance/response/response-body-stable-copy' .   # hunt an intermittent failure

Skipping a check with Options.Skip

Run accepts an optional conformance.Options. Its Skip map takes a check name and the reason it does not apply to this adapter; the check is then reported via t.Skipf("skipped for this adapter: <reason>") so the reason is visible in -v output:

func TestConformance(t *testing.T) {
    conformance.Run(t, func(cfg *core.Config) core.Server {
        return myadapter.New(cfg)
    }, conformance.Options{
        Skip: map[string]string{
            "lifecycle/shutdown-drains-inflight": "engine has no graceful shutdown; Shutdown closes listeners immediately (tracked in #12)",
        },
    })
}

Skips are for genuine platform gaps only — a behavior the underlying engine cannot express — never for hiding a bug or a missing implementation. Every skip should be documented in your adapter’s README alongside its parity notes so users know what they are giving up. Neither official adapter skips anything: the suite passes on both under the race detector with zero skips.

The 22 checks, by area

Check names are what appear in go test -v output and what Options.Skip keys on.

Routing

Check What it proves
routing/method-param-query Method(), Param("id"), and Query("q") on GET /users/:id return the request’s values

Request

Check What it proves
request/query-default-absent-vs-empty QueryDefault falls back only when the key is absent; ?q= yields "", not the default
request/headers-and-content-type GetHeader and ContentType read the request’s headers
request/full-url-absolute FullURL() returns scheme://host/path?query
request/body-double-read-and-stability Body() returns equal bytes on two reads, and those bytes stay stable after a later request reuses the connection — no aliased transport buffer

Response

Check What it proves
response/string-verbatim-without-args String(200, "Sale: 50% off") with no format arguments is sent verbatim, not through Sprintf
response/sendbytes-default-content-type SendBytes defaults Content-Type to application/octet-stream
response/no-content-204 NoContent(204) sends a 204 with an empty body
response/post-handler-header-mutation A header set by middleware after next(c) returns still reaches the client
response/response-body-stable-copy ResponseBody() in post-handler middleware returns the body, and the returned slice is not mutated by a later request — a copy, not a pooled buffer

Middleware

Check What it proves
middleware/error-propagation-single-response A core.ErrNotFound from the handler reaches Use() middleware as the return of next(c), the client gets a 404, and the body is exactly one JSON document — the error handler ran once
middleware/execution-order-outermost-first Server Use → group middleware → route middleware → handler
middleware/use-only-applies-to-later-routes Use() after a route is registered does not affect that route; routes registered afterwards get the middleware

Errors

Check What it proves
errors/http-error-status-mapping core.NewHTTPError(418, msg) is answered with status 418 and msg in the body
errors/panic-generic-500-and-survival A panicking handler is a 500 that does not leak the panic value, and the server still serves the next request

Context safety

Check What it proves
context/clone-detached-snapshot A Clone() read from a goroutine 150 ms after the handler returned still reports the original Method() and Param("id")
context/use-after-release-panics Calling a method on the original context after the handler returned panics
context/request-context-present RequestCtx() is non-nil and not canceled while the handler runs

Streaming

Check What it proves
streaming/sendstream-incremental Chunks written to an io.Pipe arrive at the client one at a time (headers flushed before the first read, a flush per chunk), with no trailing bytes after EOF
streaming/sse-format-and-disconnect core.SSE responds with text/event-stream, a data: frame arrives live, and the handler returns after the client disconnects

Limits

Check What it proves
limits/body-limit-413 With Config.BodyLimit = 1024, a 100-byte body is a 200 and a 4 KB body is a 413

Lifecycle

Check What it proves
lifecycle/shutdown-drains-inflight Shutdown called while a 400 ms handler is in flight returns nil, and that request completes with a 200

Reading a failing check

Every failure message names the contract clause it enforces, in the same words as this page — “Use() middleware received nil from next(c) — handler errors must propagate through the chain”, “timed out reading first chunk — the adapter is buffering instead of flushing per chunk”, “captured ResponseBody mutated after a later request — adapter returned an aliased buffer”. Read it as a statement about your adapter, and then:

  • Fix the adapter, never the check. The suite is shared by every adapter, and a check is a promise that handlers rely on when they swap engines; loosening it to pass weakens that promise for everyone. The three bugs the suite found in the official adapters — headers never flushed before streaming on both, ResponseBody() aliasing a pooled buffer on Gin, a 413 coming out as a 500 on Fiber — were each fixed in the adapter with the check left untouched.
  • Treat an intermittent failure as a real one. Pool-reuse and aliasing bugs surface only when memory happens to be recycled; the Gin ResponseBody() bug failed roughly one run in five. Reproduce with -race -count=20 rather than re-running until it passes.
  • Skip only what the engine truly cannot do, with a reason in Options.Skip and a line in your README’s parity notes.
  • Passing is necessary, not sufficient. The suite proves the contract; it does not replace go vet, the race detector across your own tests, or an honest parity table for your engine’s remaining differences.

Next steps

  • Adapters: Gin & Fiber — how the two official adapters implement each rule above, and their behavior parity table.
  • Middleware — the middleware that will run on your adapter, including the ETag, Idempotency, and CSRF middleware that use the optional capability interfaces.
  • Server-Sent Events — the SSE API your SendStream has to serve.
  • Production Guide — every core.Config field an adapter consumes.