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.
type Application struct {
*router.RouterBuilder // routing, middleware, DI methods
// unexported: options, context pool, logger, closers...
}What the Application itself adds is lifecycle management:
| Method | Purpose |
|---|---|
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:
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:
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:
| Family | Representative methods |
|---|---|
| Request reading | Method, Path, Host, RemoteAddr, Header(name), ContentType(), MediaType(), Query(), Params(), Body(), ReadJSON, ReadForm, ReadQuery, ReadBody, Cookie, CookieValue |
| Response writing | StatusCode, SetHeader, AddHeader, SetContentType, JSON, Text, HTML, Writef, Write, Redirect, SendFile, ServeFile, SetCookie, ResponseHeader, ResponseWriter() |
| Flow control | Next, 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.SetHeaderwrites a response header, andctx.ResponseHeader(name)reads one back.ctx.ContentType()andctx.MediaType()describe the request body. The response-side counterparts arectx.ResponseContentType()andctx.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.
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:
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:
app.Get("/about", about)
app.Post("/contact", contact)
app.Handle("GET,POST", "/form", form)Path segments can be dynamic, typed parameters called macros:
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:
// 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:
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:
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:
| Method | On the root app | On a child router |
|---|---|---|
UseRouter | Before routing: every request, including 404/405 | Same, guarded by the child’s path prefix (and subdomain) |
Use | All matched routes, plus the 404 and 405 chains | Matched routes of that child and its children only |
UseRelevant | Matched routes only, never 404/405 | Same 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:
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:
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:
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 afterBuildit is a standardhttp.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;Headerreads the request whileSetHeader/ResponseHeaderhandle 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 theHandleNoRoute/HandleNoMethodchains. - Middleware placement is semantic:
UseRouterruns before routing for every request, rootUsecovers all routes plus 404/405, childUse(andUseRelevant) 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
- MDN: HTTP request methods: what each verb means and which are safe to repeat.
- MDN: HTTP response status codes: the full status-code catalog with plain-English meanings.
- MDN: HTTP headers: reference for the metadata lines on both sides of the exchange.
- RFC 9110: HTTP Semantics: the authoritative specification behind methods, headers and status codes.
- net/http (pkg.go.dev): the standard
library server Iris builds on; after
Buildthe application is one of itshttp.Handlers. - sync.Pool (pkg.go.dev): the Go primitive behind context pooling.