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:

  1. Transportserver.go / StartConfig wraps net/http.Server for lifecycle
  2. DispatchEcho.ServeHTTP handles context acquisition, pre/post middleware chains, and routing
  3. ContextContext struct carries the request/response pair and all per-request state to handlers
  4. Extension points — swappable interfaces (Binder, Renderer, etc.) and the middleware MiddlewareFunc type

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 goroutine

Core 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.Pool of Context instances. Implements http.Handler via ServeHTTP.
  • 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.Request and http.ResponseWriter and exposes convenience methods for path params, query params, JSON/XML/form binding, response writing, cookie management, and per-request key-value store. Pooled via sync.Pool for 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 Context with matched handler and path parameters. Swappable via the Router interface.
  • Key types: Router (interface), DefaultRouter, RouterConfig, node (radix tree node), Route, RouteInfo
  • Dependencies: Context (populated during Route()), 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 Add calls back to the parent Echo instance 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 MiddlewareFunc contract: func(next HandlerFunc) HandlerFunc.
  • Key types: *Config structs (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.Server creation, TLS setup, listener management, and graceful shutdown. Separates the “how to listen” concern from the Echo dispatch 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 NewRequest and NewResponseRecorder so 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.Pool

Concrete 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 shutdown

echo.New() sequence:

  1. os.Getwd() → sets e.Filesystem to NewDefaultFS(dir) (a thin fs.FS wrapper)
  2. Creates slog.Logger with JSON handler to stdout
  3. Sets DefaultBinder{} and DefaultJSONSerializer{}
  4. e.serveHTTPFunc = e.serveHTTP (indirection allows tests to swap the serve function)
  5. NewRouter(RouterConfig{}) — creates root radix-tree node
  6. DefaultHTTPErrorHandler(false) assigned
  7. contextPool.New set to newContext(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:

  1. Creates StartConfig{Address: addr}
  2. signal.NotifyContext(Background(), SIGINT, SIGTERM) — shutdown triggered by OS signal
  3. StartConfig.start(ctx, e):
    • Creates http.Server{Handler: e, ReadTimeout: 30s}
    • Creates TCP listener
    • Spawns gracefulShutdown goroutine (waits for ctx.Done(), then calls server.Shutdown with 10s timeout)
    • server.Serve(listener) — blocks until closed

Configuration#

Echo has two layers of configuration:

  1. Framework config (Config struct / NewWithConfig): Passed at construction time. Fields include Router, Binder, Renderer, Validator, JSONSerializer, IPExtractor, Logger, HTTPErrorHandler, Filesystem, FormParseMaxMemory. All are optional; defaults are applied by New().

  2. Middleware config (per-*Config structs): Each middleware in the middleware/ package has its own *Config struct (e.g. CORSConfig, RateLimiterConfig). Middleware is constructed via middleware.CORSWithConfig(cfg) or the zero-config convenience wrapper middleware.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#

  1. Context as a concrete struct (v5 change from v4’s interface): v4 used a Context interface, which complicated custom context embedding. v5 replaced it with a concrete *Context struct 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’s any type assertion.

  2. 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.

  3. sync.Pool for Context recycling: Every request reuses a pooled Context. c.Reset() zeroes fields in-place (reuses the PathValues backing array at its allocated capacity). This eliminates per-request allocation pressure and is central to Echo’s benchmark performance.

  4. Router as a swappable interface: Router is an interface in v5. The DefaultRouter (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.

  5. StartConfig decouples server lifecycle from routing: Server startup (TLS, listener, graceful shutdown) is handled by a separate StartConfig value, not by methods on Echo. e.Start(addr) is a convenience wrapper; production code uses StartConfig.Start(ctx, e) for full control. This means Echo itself is testable without starting a TCP listener, and the server config is declarative rather than spread across setter methods.