Echo — Architecture#
Architectural style#
Micro-framework / Library (Layered, Interface-driven)
Echo is a single-binary-free HTTP micro-framework. Its architecture is deliberately thin: a central Echo struct acts as a composition root that wires together a small set of collaborating interfaces (Router, Binder, Renderer, Validator, JSONSerializer, IPExtractor) and delegates every request to a three-stage middleware pipeline. There is no IoC container, no code generation, no multi-process boundary — just composable function values and interface slots.
The style is closest to a layered library where the layers are:
- Transport —
server.go/StartConfigwrapsnet/http.Serverfor lifecycle - Dispatch —
Echo.ServeHTTPhandles context acquisition, pre/post middleware chains, and routing - Context —
Contextstruct carries the request/response pair and all per-request state to handlers - Extension points — swappable interfaces (Binder, Renderer, etc.) and the middleware
MiddlewareFunctype
Component diagram (textual)#
Consumer code
│
│ echo.New() / echo.NewWithConfig(Config{...})
▼
┌─────────────────────────────────────────────────────────────┐
│ Echo struct │
│ │
│ ┌───────────┐ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Router │ │ Binder │ │ Renderer │ │Validator │ │
│ │(interface)│ │(interface)│ │(iface) │ │(iface) │ │
│ └─────┬─────┘ └───────────┘ └──────────┘ └──────────┘ │
│ │ DefaultRouter │
│ │ (radix tree) JSONSerializer IPExtractor │
│ │ (interface) (func type) │
│ ┌─────▼──────────────────────────────────────────────┐ │
│ │ serveHTTP() │ │
│ │ premiddleware[] → Route(c) → middleware[] → h(c) │ │
│ └───────────────────────────────────────────────────── │
│ │
│ contextPool (sync.Pool) │
│ HTTPErrorHandler (func) │
└─────────────────────────────────────────────────────────────┘
│ │
▼ ▼
StartConfig Group struct
(server lifecycle) (prefix + own middleware[])
│
▼
http.Server (stdlib)
net.Listener
gracefulShutdown goroutineCore components#
Echo struct#
- Package:
github.com/labstack/echo/v5 - Responsibility: Top-level composition root. Holds all framework configuration, the Router, middleware stacks, and the
sync.Poolof Context instances. Implementshttp.HandlerviaServeHTTP. - Key types:
Echo,Config,HandlerFunc(func alias),MiddlewareFunc(func alias),HTTPErrorHandler(func alias) - Dependencies:
Router(interface),Binder(interface),JSONSerializer(interface),Renderer(interface),Validator(interface),IPExtractor(func type),slog.Logger
Context struct#
- Package:
github.com/labstack/echo/v5 - Responsibility: Per-request value container. Wraps
*http.Requestandhttp.ResponseWriterand exposes convenience methods for path params, query params, JSON/XML/form binding, response writing, cookie management, and per-request key-value store. Pooled viasync.Poolfor zero-allocation reuse. - Key types:
Context,PathValues,Response(ResponseWriter wrapper),RouteInfo - Dependencies:
Echo(back-pointer for config),Binder,JSONSerializer,Renderer,Validator,slog.Logger
Router / DefaultRouter#
- Package:
github.com/labstack/echo/v5 - Responsibility: URL-to-handler matching via a radix (compressed prefix) tree. One tree per HTTP method. Populates
Contextwith matched handler and path parameters. Swappable via theRouterinterface. - Key types:
Router(interface),DefaultRouter,RouterConfig,node(radix tree node),Route,RouteInfo - Dependencies:
Context(populated duringRoute()),HandlerFunc
Group#
- Package:
github.com/labstack/echo/v5 - Responsibility: Logical grouping of routes under a shared path prefix and/or shared middleware chain. A thin wrapper that delegates all
Addcalls back to the parentEchoinstance with prefix prepended and group-level middlewares appended. - Key types:
Group - Dependencies:
Echo(parent instance)
Middleware package#
- Package:
github.com/labstack/echo/v5/middleware - Responsibility: 24 production-ready middleware implementations (CORS, CSRF, rate limiting, auth, compression, logging, proxy, etc.). Each follows the
MiddlewareFunccontract:func(next HandlerFunc) HandlerFunc. - Key types:
*Configstructs (one per middleware),MiddlewareConfigurator(interface for error-returning factory) - Dependencies: Root package (imports
echo.HandlerFunc,echo.Context, etc.)
Server lifecycle (StartConfig)#
- Package:
github.com/labstack/echo/v5 - Responsibility: Wraps
net/http.Servercreation, TLS setup, listener management, and graceful shutdown. Separates the “how to listen” concern from theEchodispatch logic. - Key types:
StartConfig - Dependencies:
http.Server,net.Listener,tls.Config
echotest#
- Package:
github.com/labstack/echo/v5/echotest - Responsibility: First-class testing utilities for consumers. Provides
NewRequestandNewResponseRecorderso that handler unit tests do not need a running server. - Dependencies: Root package
Data flow#
A typical HTTP request flows through Echo as follows:
1. net/http calls Echo.ServeHTTP(w, r)
2. Context acquired from sync.Pool; Reset(r, w) called
3. If premiddleware present:
h = applyMiddleware(routingFunc, premiddleware...)
Else:
h = applyMiddleware(router.Route(c), middleware...)
(premiddleware runs BEFORE routing; regular middleware AFTER routing)
4. h(c) invoked — premiddleware → [routing] → middleware → handler
5. router.Route(c):
a. Traverses radix tree to find matching node
b. Calls c.InitializeRoute(routeInfo, pathValues)
c. Returns the route's HandlerFunc (with route-level middleware already wrapped)
6. middleware chain wraps the route handler (applied in reverse order for correct execution order)
7. handler(c) executes — reads params via c.Param(), binds body via c.Bind(), writes response via c.JSON() etc.
8. If handler returns non-nil error → e.HTTPErrorHandler(c, err)
9. Context returned to sync.PoolConcrete example — GET /users/:id:
ServeHTTP → pool.Get() → c.Reset()
→ premiddleware (e.g. RequestID injects header)
→ router.Route(c) finds /users/:id node, sets c.pathValues=["id":"42"], returns handler
→ middleware chain (e.g. Logger wraps, then CORS wraps)
→ userHandler(c): c.Param("id") → "42", c.JSON(200, user)
→ Logger middleware logs after return
→ pool.Put(c)Initialization / Bootstrap#
// Minimal bootstrap (consumer code)
e := echo.New() // or echo.NewWithConfig(config)
e.Use(middleware.Recover())
e.Use(middleware.Logger())
api := e.Group("/api/v1")
api.GET("/users/:id", getUserHandler)
e.Start(":8080") // blocks; SIGINT triggers graceful shutdownecho.New() sequence:
os.Getwd()→ setse.FilesystemtoNewDefaultFS(dir)(a thinfs.FSwrapper)- Creates
slog.Loggerwith JSON handler to stdout - Sets
DefaultBinder{}andDefaultJSONSerializer{} e.serveHTTPFunc = e.serveHTTP(indirection allows tests to swap the serve function)NewRouter(RouterConfig{})— creates root radix-tree nodeDefaultHTTPErrorHandler(false)assignedcontextPool.Newset tonewContext(nil, nil, e)factory
NewWithConfig(Config{}) sequence: calls New() then selectively overwrites non-nil config fields. This is a classic config-struct + optional-overrides pattern, not functional options.
No dependency injection framework is used. All wiring is manual: the Config struct carries every replaceable collaborator, and New() fills in defaults.
e.Start(addr) sequence:
- Creates
StartConfig{Address: addr} signal.NotifyContext(Background(), SIGINT, SIGTERM)— shutdown triggered by OS signalStartConfig.start(ctx, e):- Creates
http.Server{Handler: e, ReadTimeout: 30s} - Creates TCP listener
- Spawns
gracefulShutdowngoroutine (waits for ctx.Done(), then callsserver.Shutdownwith 10s timeout) server.Serve(listener)— blocks until closed
- Creates
Configuration#
Echo has two layers of configuration:
Framework config (
Configstruct /NewWithConfig): Passed at construction time. Fields includeRouter,Binder,Renderer,Validator,JSONSerializer,IPExtractor,Logger,HTTPErrorHandler,Filesystem,FormParseMaxMemory. All are optional; defaults are applied byNew().Middleware config (per-
*Configstructs): Each middleware in themiddleware/package has its own*Configstruct (e.g.CORSConfig,RateLimiterConfig). Middleware is constructed viamiddleware.CORSWithConfig(cfg)or the zero-config convenience wrappermiddleware.CORS(). Configuration is entirely code-based; there is no file-based or environment-variable config system in the framework itself.
There is no Viper integration, no env-var auto-binding, and no YAML/TOML config loading in Echo core. This is by design — Echo is a framework, not an application; config loading is the consumer’s responsibility.
Key design decisions#
Contextas a concrete struct (v5 change from v4’s interface): v4 used aContextinterface, which complicated custom context embedding. v5 replaced it with a concrete*Contextstruct plus a key-value store (c.Set/c.Get). This eliminates the interface-satisfaction overhead and removes a common source of confusion while retaining extensibility via the store. The trade-off: consumers can no longer embed custom fields in a type-safe way without the store’sanytype assertion.Two-phase middleware: pre vs. post-routing:
e.Pre(m)runs middleware before routing (useful for URL rewriting, real-IP extraction that affects routing).e.Use(m)runs after routing (for auth, logging, CORS — which only apply when a route matched). This distinction is architecturally clean but adds a subtlety that trips up newcomers.sync.Poolfor Context recycling: Every request reuses a pooledContext.c.Reset()zeroes fields in-place (reuses thePathValuesbacking array at its allocated capacity). This eliminates per-request allocation pressure and is central to Echo’s benchmark performance.Router as a swappable interface:
Routeris an interface in v5. TheDefaultRouter(radix tree) is used by default, but consumers can inject an alternative implementation. This is forward-looking — it allows, e.g., a concurrent-safe router wrapper (router_concurrent.go) to be plugged in without touching the dispatch path.StartConfigdecouples server lifecycle from routing: Server startup (TLS, listener, graceful shutdown) is handled by a separateStartConfigvalue, not by methods onEcho.e.Start(addr)is a convenience wrapper; production code usesStartConfig.Start(ctx, e)for full control. This meansEchoitself is testable without starting a TCP listener, and the server config is declarative rather than spread across setter methods.