Chapter 2

Core Concepts

The Application, the Router, the pooled Context and handlers chained into a middleware pipeline. How a single request travels through all four and back.

Every Iris program is built from four cooperating parts: an Application that owns the server lifecycle, a Router that maps requests to code, a pooled Context that carries one request from start to finish, and plain handlers chained into a middleware pipeline. This chapter builds the mental model of how those parts fit together, so that the rest of the book reads as detail rather than surprise. Everything shown here is the v14 API, verified against the framework source.

Background

Chapter 1 showed a request and a response from a distance. The parts of Iris this chapter describes exist to serve that exchange, so it is worth pinning down the vocabulary first.

The anatomy of an HTTP exchange. Each request names a method, a path, headers and possibly a body. The method is the verb: GET reads, POST creates or submits, PUT replaces, PATCH partially updates, DELETE removes. The path (/users/42) names the thing the verb acts on. Headers are key-value metadata lines: who is asking (Authorization), what format the client wants back (Accept), what it is sending (Content-Type). The body carries the payload itself: form fields, a JSON document, an uploaded file. The response mirrors this with a status code, headers and a body. Status codes come in classes: 2xx means success, 3xx redirects the client elsewhere, 4xx means the client made a mistake (404 unknown path, 400 malformed input), and 5xx means your server failed (500 is the generic failure). You will choose methods and status codes constantly, so this small vocabulary pays for itself quickly.

Router and handler. A handler is a function you write that serves one request: it reads what it needs and writes a response. The router is the dispatcher in front of all your handlers: it looks at each request’s method and path and decides which handler runs. “Routing” is that decision; a “route” is one registered rule, such as GET /users/{id} runs getUser. Middleware (detailed later in this chapter) is a handler that runs before or after other handlers to do shared work such as logging or authentication.

Why object pooling. A busy server may handle tens of thousands of requests per second, and Go serves each one on its own goroutine, a lightweight thread that Go can run by the million. If the framework allocated a fresh context object for every request, it would create garbage at the same rate, and Go’s garbage collector would burn CPU cleaning it up. Object pooling is the fix: keep a bin of used context objects, wipe one clean for each new request, and drop it back in the bin afterwards, the way a restaurant washes plates instead of buying new ones for every meal. The only cost is a rule you must respect: never keep a context after your handler returns, because the same object will be reused for another request. The Context section below restates that rule where it matters.

The Big Picture

+---------------------------------------------------+
|  net/http server            app.Listen / app.Run  |
+---------------------------------------------------+
|  Application                iris.go               |
|    embeds the root Router builder                 |
+---------------------------------------------------+
|  Router                     core/router           |
|    routes, groups (Router/Child), the mux         |
+---------------------------------------------------+
|  Middleware pipeline        UseRouter / Use       |
+---------------------------------------------------+
|  Handlers                   func(ctx iris.Context)|
|    one pooled Context per request                 |
+---------------------------------------------------+

A request enters through a standard net/http server, is bound to a Context taken from a pool, travels through the router’s middleware and the matched route’s handler chain, and leaves as a written response. The Context is then reset and returned to the pool. There is no reflection and no magic in the hot path: a handler is a plain function, a chain is a slice of functions, and ctx.Next() moves an index forward.

The Application

iris.New returns a *iris.Application, a concrete struct defined in iris.go. Its routing surface is not its own: the struct embeds the root router builder, so every routing, middleware, and dependency-injection method you call on app comes from the embedded *router.RouterBuilder.

Go
type Application struct {
    *router.RouterBuilder // routing, middleware, DI methods

    // unexported: options, context pool, logger, closers...
}

What the Application itself adds is lifecycle management:

MethodPurpose
app.Listen(addr)Build and serve on a TCP address with sane defaults; a failed start is logged and exits with status 1
app.Run(iris.ServerOptions{...})Build and serve with explicit server options; same exit rule as Listen
app.Build()Construct the router and internal state manually
app.Shutdown(ctx)Gracefully stop the server and registered closers
app.Logger()The application’s structured logger
app.EditOptions(fn)Modify options before the application is built
app.RegisterOnBuild(before, after)Hooks around the build step

app.Listen(":8080") is a shortcut for app.Run with a TCP network, keep-alive, and socket sharding preconfigured. After Build, the application is a standard http.Handler, which is why it composes cleanly with the Go ecosystem and with httptest (see Chapter 16).

A minimal but complete program:

Go
package main

import (
    "github.com/kataras/iris/v14"
)

func main() {
    app := iris.New(iris.Options{
        Name:     "hello",
        LogLevel: "debug",
    })

    app.Get("/", func(ctx iris.Context) {
        ctx.Text("Hello, Iris!")
    })

    app.Listen(":8080")
}

iris.Options configures the application (Name, LogLevel, Host, plus nested Router and Context option structs). A partial literal merges with the defaults: unset fields are filled in, never zeroed. Configuration is covered in depth in Chapter 11.

For production applications the recommended entry point is the compile-time checked iris.NewBuilder chain rather than bare iris.New; that is the subject of Chapter 3. Everything in this chapter applies to both, because the builder produces the same *iris.Application.

The Context

iris.Context is an alias for *context.Context, the object that represents one request-response cycle. Contexts are pooled: at the start of a request one is acquired from a sync.Pool, wired to the incoming *http.Request and a wrapped response writer, and after the last handler returns it is reset and released back to the pool. This is why you must not keep a reference to ctx in a goroutine that outlives the handler: by the time it runs, the same object may be serving another request.

It is a context.Context

iris.Context implements the standard context.Context interface (Deadline, Done, Err, Value). You pass it directly into services, database drivers, and HTTP clients, and request cancellation propagates all the way down:

Go
func (api *BooksAPI) list(ctx iris.Context) {
    // ctx satisfies context.Context: pass it straight down.
    books, err := api.svc.Books(ctx)
    ctx.List(books, err)
}

For background work that must survive the response, derive a detached context with ctx.WithoutCancel(): it is no longer canceled when the request ends, but keeps the request’s values.

Method families at a glance

The Context API is large but regular. Almost everything falls into three families:

FamilyRepresentative methods
Request readingMethod, Path, Host, RemoteAddr, Header(name), ContentType(), MediaType(), Query(), Params(), Body(), ReadJSON, ReadForm, ReadQuery, ReadBody, Cookie, CookieValue
Response writingStatusCode, SetHeader, AddHeader, SetContentType, JSON, Text, HTML, Writef, Write, Redirect, SendFile, ServeFile, SetCookie, ResponseHeader, ResponseWriter()
Flow controlNext, Skip, StopExecution, IsStopped, StopWithStatus, StopWithError, StopWithJSON, StopWithText, Values(), SetErr, GetErr

Two naming rules keep the request and response sides apart:

  • ctx.Header(name) reads a request header. ctx.SetHeader writes a response header, and ctx.ResponseHeader(name) reads one back.
  • ctx.ContentType() and ctx.MediaType() describe the request body. The response-side counterparts are ctx.ResponseContentType() and ctx.ResponseMediaType().

Path and query values come through typed accessors that return a value directly, with a type-specific default on failure instead of a tuple: GetString returns "", GetInt returns -1, GetBool returns false.

Go
package main

import (
    "github.com/kataras/iris/v14"
)

func search(ctx iris.Context) {
    term := ctx.Params().GetString("term") // "" when missing
    page := ctx.Query().GetInt("page")     // -1 when missing or invalid
    if page <= 0 {
        page = 1
    }

    ctx.SetHeader("X-Page-Size", "20") // response header
    ctx.JSON(iris.Map{"term": term, "page": page})
}

func main() {
    app := iris.New()
    app.Get("/search/{term}", search)
    app.Listen(":8080")
}

Request-scoped values

Middleware communicates with later handlers through ctx.Values(), a per-request key-value store, freed together with the context:

Go
func requireShelf(ctx iris.Context) {
    shelf := loadShelf(ctx) // ... 404 and return when not found
    ctx.Values().Set("shelf", shelf)
    ctx.Next()
}

func listBooks(ctx iris.Context) {
    // Type-safe retrieval with the generic method.
    shelf, ok := ctx.ValueAs[*Shelf]("shelf")
    if !ok {
        // The route is not behind requireShelf: a wiring bug, not a client one.
        errors.Internal.Log(ctx, "shelf value missing from the request store")
        return
    }
    ctx.List(shelf.Books, nil)
}

Reading request bodies (ReadJSON, ReadForm, and the ctx.BindJSON[T]() method with automatic validation) is covered in Chapter 5; response rendering in Chapter 6.

The Router

The router has two lives. At design time it is a builder: you register routes, groups, and middleware on it. At build time (app.Build, called by Listen/Run) it compiles everything into a mux: the internal ServeMux that performs the actual matching of method, subdomain, and path for every request. Registration cost therefore never shows up at serve time.

(“Mux” is short for multiplexer: the single component every request enters, which fans each one out to the correct handler chain. Go’s standard library uses the same word for its http.ServeMux.)

Routes

Routes are registered with HTTP-verb methods (Get, Post, Put, Delete, Patch, Head, Options), with Handle for explicit or multiple methods, with Any for all methods, or with Resource for GET+HEAD:

Go
app.Get("/about", about)
app.Post("/contact", contact)
app.Handle("GET,POST", "/form", form)

Path segments can be dynamic, typed parameters called macros:

Go
app.Get("/users/{id:uuid}", getUser)      // validated UUID
app.Get("/posts/{page:int}", listPosts)   // integer
app.Get("/files/{p:path}", serveTree)     // wildcard, may contain /
app.Get("/tags/{name}", tagged)           // untyped = string

// Macro functions refine a type:
app.Get("/products/{id:int min(1)}", product)

A parameter that fails its type check does not match the route; in builder-built applications a malformed typed parameter renders a 400 INVALID_ARGUMENT response. The full macro catalog and custom macro registration live in Chapter 4.

Groups: Router and Child

Router(relativePath, ...) creates a child router mounted under a path prefix. A child inherits its parent’s middleware, context options, and dependency container; new routes it registers land in the one shared route registry. Three call styles cover most needs:

Go
// 1. Plain group, optionally with middleware handlers.
api := app.Router("/api/v1")
api.Get("/health", health)

users := api.Router("/users") // nested: /api/v1/users
users.Get("/", listUsers)
users.Get("/{id:uuid}", getUser)

// 2. Function form: configure the group in one place.
app.Router("/admin", func(r iris.Router) {
    r.Use(requireAdmin)
    r.Get("/stats", stats)
})

// 3. Controller form: a constructor resolved by dependency injection
//    (see the last section of this chapter).
app.Router("/books", NewBooksAPI)

Child(relativePath, handlers...) is the underlying primitive that clones the builder for a sub-path; Router builds on it and additionally accepts functions and controllers. Subdomain groups exist too: app.Subdomain("admin") returns a router whose routes match only admin.<host> requests.

When nothing matches

If the mux finds no route, the request runs the no-route chain (default: 404 Not Found). If the path exists but the method does not, the request runs the no-method chain (default: 405 Method Not Allowed), but only when you have asked for it. Router.FireMethodNotAllowed defaults to false, and until you set it a wrong-method request lands in the no-route chain like any other miss. Both chains are replaceable:

Go
app.HandleNoRoute(func(ctx iris.Context) {
    errors.NotFound.Message(ctx, "no route matches this path")
})
app.HandleNoMethod(func(ctx iris.Context) {
    errors.MethodNotAllowed.Message(ctx, "this path does not accept that method")
})

Those defaults are the plain status text on an iris.New() application. An application built with iris.NewBuilder() fills both chains with errors.Handler from rest/errors, so a miss answers a JSON NOT_FOUND or METHOD_NOT_ALLOWED instead. It fills them when the application is built and only when they are empty, so on a builder application your own handlers have to be registered from a Configurator passed to iris.NewBuilder, or on the unbuilt value iris.Default() returns; a HandleNoRoute called on the built application does not replace the JSON answer. Structured error responses via the rest/errors package are the topic of Chapter 9.

Handlers and the Middleware Pipeline

A handler is any func(ctx iris.Context), the iris.Handler type. There is exactly one signature; middleware and final handlers are the same kind of function, distinguished only by position in the chain. A handler either finishes the response or calls ctx.Next() to hand over to the next handler. Code after ctx.Next() runs on the way back out, which is how timing, logging, and cleanup middleware work:

Go
package main

import (
    "time"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/rest/errors"
)

func timing(ctx iris.Context) {
    start := time.Now()
    ctx.Next() // run the rest of the chain
    ctx.Application().Logger().Debugf("%s %s took %s",
        ctx.Method(), ctx.Path(), time.Since(start))
}

func requireToken(ctx iris.Context) {
    if ctx.Header("Authorization") == "" {
        errors.Unauthenticated.Message(ctx, "missing Authorization header")
        return // no Next: the chain ends here
    }
    ctx.Next()
}

func main() {
    app := iris.New()
    app.Use(timing)
    app.Get("/private", requireToken, func(ctx iris.Context) {
        ctx.Text("secret")
    })
    app.Listen(":8080")
}
request --> timing --> requireToken --> handler
                                          |
response <-- timing <------------------ (return)

Not calling ctx.Next() ends the chain quietly; ctx.StopExecution() (and the StopWith* shortcuts) additionally marks the context stopped so that any later Next calls are ignored. ctx.IsStopped() reports that state.

Use, UseRouter, UseRelevant

Where you attach middleware determines which requests it sees. The three registration methods, verified against core/router/router_builder.go:

MethodOn the root appOn a child router
UseRouterBefore routing: every request, including 404/405Same, guarded by the child’s path prefix (and subdomain)
UseAll matched routes, plus the 404 and 405 chainsMatched routes of that child and its children only
UseRelevantMatched routes only, never 404/405Same as Use

The root-versus-child difference in Use is the one that surprises people: app.Use(m) also runs m for requests that match no route at all, because the root’s Use handlers are prepended to the no-route and no-method chains. On a child router, Use delegates to UseRelevant, so it affects only routes registered under that child. When you want root middleware that skips 404s, say so explicitly with app.UseRelevant(m).

UseRouter runs even earlier: before the mux looks at the path. That makes it the right place for access logging, request IDs, rate limiting, and anything that must observe every request. From a child, UseRouter still registers globally but wraps the handler in a prefix check, so it fires only for requests under that child’s path.

Two more variants exist: UseOnce and UseRelevantOnce insert a handler or replace an already-registered instance of it, which keeps middleware idempotent when setup code may run twice. And below all of this, app.WrapRouter wraps the entire router at the net/http level for integrations that need raw http.Handler semantics.

Order matters: a route’s chain is assembled when the route is registered, so register middleware before the routes it should affect.

The Request Lifecycle

From accepted connection to released context:

The request lifecycle A request passes the router wrappers, borrows a Context from the pool, runs the UseRouter chain into the mux, and either branches to a redirect or the 404 and 405 chains or enters the matched route's frozen chain before the Context is reset and returned to the pool. 1. net/http server accepts the connection core/server 2. Router wrappers WrapRouter, AddRouterWrapper: net/http level, no Context yet next(w, r) 3. Context and response writer from their pools contextPool.Acquire() and AcquireResponseWriter(w) 4. UseRouter handlers one global list, wrapped in a path test when it came from a child router. The mux is its last handler, so a handler here that never calls ctx.Next() ends the request. ctx.Next() 5. Mux: path correction, then the match the trie matches SHAPE only and fills ctx.Params() by index. No type is checked here. 301 or 307 redirect trailing slash, unless DisablePathCorrection 404 chain root app.Use handlers, then HandleNoRoute. Also where a wrong method lands, by default 405 chain only when Router.FireMethod- NotAllowed is true; it sets the Allow header route found 6. MATCHED ROUTE CHAIN, FROZEN WHEN THE ROUTE WAS REGISTERED macro evaluator typed path parameters, handler 0 root app.Use handlers snapshotted when the route was registered child r.Use / UseRelevant handlers that child and its children only route handlers positional guards, then the main handler Steps 3 and 8 are the pooling boundary. Between them the Context is exclusively yours; outside them it does not exist. 7. Handlers write the response the pooled writer holds the status line back until the first body byte 8. Writer released, Context back to the pool ReleaseResponseWriter and contextPool.Release(ctx)
A request passes the router wrappers, borrows a Context from the pool, runs the UseRouter chain into the mux, and either branches to a redirect or the 404 and 405 chains or enters the matched route's frozen chain before the Context is reset and returned to the pool.

Step 3 and step 8 are the pooling boundary. Between them the Context is exclusively yours; outside them it does not exist from your code’s point of view. Step 5 is pure matching against the compiled mux: the trie compares segment count and static segments, and a typed parameter such as {id:int} is not checked here. That check is a handler the router prepends to the matched route’s own chain, which is why it appears as handler 0 of step 6. No route’s handlers run before the router has decided, which is what makes the UseRouter versus Use distinction meaningful.

Graceful shutdown belongs to the same lifecycle at application scale: app.Shutdown(ctx) stops the server and runs everything registered with app.AddCloser. Deployment patterns around it are covered in Chapter 19.

Where Dependency Injection Sits

Handlers keep the single func(ctx iris.Context) signature; dependencies do not appear as handler parameters. Instead, constructors declare what they need, and the router resolves those constructors from registered services when routes are set up: at startup, never per request.

The application-level primitives:

Go
package main

import (
    "log"

    "github.com/kataras/iris/v14"
)

type Greeter struct{ Prefix string }

func main() {
    app := iris.New()

    if err := app.RegisterDependency(&Greeter{Prefix: "Hello, "}); err != nil {
        log.Fatal(err)
    }

    // HandlerFunc resolves the function's parameters from the
    // registered dependencies and returns a plain iris.Handler.
    app.Get("/{name}", app.HandlerFunc(func(g *Greeter) iris.Handler {
        return func(ctx iris.Context) {
            ctx.Text(g.Prefix + ctx.Params().GetString("name"))
        }
    }))

    app.Listen(":8080")
}

The same mechanism powers the controller style you saw in the router section: app.Router("/books", NewBooksAPI) calls the constructor with arguments resolved from the registered services, then invokes the returned struct’s Routing(r iris.Router) method once to register its routes:

Go
type BooksAPI struct {
    svc *library.Service
}

func NewBooksAPI(svc *library.Service) *BooksAPI {
    return &BooksAPI{svc: svc}
}

func (api *BooksAPI) Routing(r iris.Router) {
    r.Get("/", api.list)
    r.Get("/{id:uuid}", api.get)
}

In builder-based applications the services are registered through the Services(...) step of the iris.NewBuilder chain, and any service implementing Init(ctx context.Context) error is initialized once inside Build(), after injection. The full resolution rules (constructors, struct field binding, per-router containers) are the subject of Chapter 8.

Summary

  • The Application (iris.go) owns lifecycle: build, serve, shutdown. Its routing API comes from the embedded root *router.RouterBuilder, and after Build it is a standard http.Handler.
  • The Context is pooled per request and implements context.Context, so it flows into services and database calls. Its methods split into request reading, response writing, and flow control; Header reads the request while SetHeader/ResponseHeader handle the response.
  • The Router is a builder at design time and a compiled mux at serve time. Groups come from Router/Child, unmatched requests run the HandleNoRoute/HandleNoMethod chains.
  • Middleware placement is semantic: UseRouter runs before routing for every request, root Use covers all routes plus 404/405, child Use (and UseRelevant) covers matched routes only.
  • Dependency injection happens at startup: constructors such as NewBooksAPI(svc) are resolved from registered services, and handlers keep their plain signature.

With the model in place, the next chapter introduces the recommended way to assemble production applications: the compile-time checked builder chain.

Further Reading

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.