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) andResponseBody(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),RequestCtxandSetRequestCtx(the standardcontext.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:
- Acquires your adapter context for the request.
- Runs the composed chain inside a
recover. A panic is logged server-side with its stack and becomescore.ErrInternalServer("Internal Server Error")— the client never sees the panic value. - If the chain returned an error, invokes the error handler (
config.ErrorHandler, orcore.DefaultErrorHandlerwhen 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. - 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...)), andnilonce streaming has started.Clone()deep-copies method, path, params, query, headers, body, and context values, and derives itsRequestCtx()withcontext.WithoutCancelso 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=20rather than re-running until it passes. - Skip only what the engine truly cannot do, with a reason in
Options.Skipand 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
SendStreamhas to serve. - Production Guide — every
core.Configfield an adapter consumes.