Chapter 4

Routing & Controllers

Route registration, typed path parameters such as {id:uuid}, custom macros, route groups, subdomains, and controllers resolved by the container.

An Iris route maps an HTTP method and a URL path to a chain of handlers. This chapter covers the registration API, typed path parameters, grouping with child routers, the constructor-based controller pattern that scales from a single file to a full service, error-returning handlers through rest/errors, subdomains, and route naming and introspection.

Background

The shape of a URL. A URL such as https://example.com/api/v1/users/42?page=2&sort=name splits into parts the router cares about differently. The scheme (https) and host (example.com) select the server. The path (/api/v1/users/42) is a list of segments separated by slashes and names the resource being acted on. The query string (everything after the ?, here page=2&sort=name) is a set of optional key-value pairs, conventionally used for filtering, paging and sorting rather than for identity. Routing decides on method and path only; the query string never chooses the handler, it only tunes what the handler does.

Static and dynamic segments. /users/42 and /users/97 should run the same code, so a route pattern mixes fixed segments (users) with placeholders ({id}) that match whatever appears in that position. The placeholder’s value is handed to your handler as a parameter.

What typed parameters buy you. Without them, every handler opens with the same ritual: pull the raw string out of the path, convert it to a number or date, check the conversion error, answer 400 or 404, and only then do the real work. A typed parameter such as {id:int} moves that ritual into the route table: the router validates and converts before any handler of yours runs, so a handler that runs can trust its input completely. Multiplied across every parameter of every route, the win is a whole class of “forgot to check” bugs that can no longer be written, and validation rules that sit in one visible place (the route) instead of scattered through function bodies.

What grouping and controllers organize. Real APIs have shared structure: everything under /api/v1 shares that prefix, everything under /admin requires an administrator, every route about books needs the book service. A group captures the path-level sharing once (a common prefix plus common middleware), so individual routes stay one line each. A controller goes a step further and organizes the code: one struct owns a family of related handlers together with the dependencies they share, and declares its routes in one method. Groups organize paths; controllers organize code around those paths.

Route Registration

Every router (the application itself or any child created with Router) exposes one function per HTTP method. A handler is a func(ctx iris.Context) (the iris.Handler type); when you pass more than one, they run in order and the last is the main handler.

Go
package main

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

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

    app.Get("/", index)                    // GET /
    app.Post("/users", createUser)         // POST /users
    app.Put("/users/{id:int}", updateUser) // PUT /users/42
    app.Delete("/users/{id:int}", deleteUser)

    // One handler for several methods, space or comma separated.
    app.Handle("GET POST", "/contact", contact)

    // Every HTTP method.
    app.Any("/ping", ping)

    app.Listen(":8080")
}

func index(ctx iris.Context) {
    ctx.JSON(iris.Map{"message": "hello"})
}

The method functions (Get, Post, Put, Delete, Patch, Head, Options, Connect, Trace, Query) return a *Route, so registration can be chained with route configuration such as SetName. Handle and Any create one route per method and return a *RouteRegistry holding all of them.

Query is HTTP QUERY (RFC 10008): a safe, idempotent read that carries a body, the natural method for a search or a filtered list. iris.MethodQuery is its name ("QUERY") wherever a method string is expected, as in app.Handle("POST QUERY", "/list", list), and Any includes it. When the router redirects a request to its trailing-slash twin, POST, PUT, PATCH and QUERY get a 307 so the body survives the redirect.

There is no optional-parameter syntax. When a path segment may be absent, register two routes that share the same handler:

Go
app.Get("/search", search)
app.Get("/search/{term}", search)

Path Parameters

A dynamic path segment is written as {name} or {name:type}. The type is a macro: it validates the raw segment before your handler runs and converts it to a Go value. Without a type, {name} means {name:string}.

One type in the table deserves a definition: a UUID (universally unique identifier) is a 128-bit identifier written as 36 hexadecimal characters, such as 9f86d081-8292-4c4c-812f-0a1b2c3d4e5f. Because a freshly generated UUID is unique for all practical purposes, services use them as record IDs that any machine can mint without coordinating with a central counter, which is why they appear in this book’s examples wherever a database ID crosses a URL.

Built-in Parameter Types

TypeSyntaxGo typeMatches
string{name} or {name:string}stringany single path segment (default)
int{id:int}intintegers (alias: number)
int8..int64{n:int64}int8..int64sized integers (int64 alias: long)
uint..uint64{n:uint64}uint..uint64unsigned integers
bool{v:bool}boolstrconv.ParseBool values: 1, 0, t, f, true, false, … (alias: boolean)
alphabetical{name:alphabetical}stringletters and spaces only
file{name:file}stringletters, digits, underscore, dash, dot; no spaces
path{rest:path}stringmulti-segment wildcard; must be the last segment
uuid{id:uuid}uuid.UUIDany RFC 9562 UUID, whatever its version (alias kept for compatibility: uuidv4)
mail{addr:mail}stringe-mail address, syntax check only
email{addr:email}stringe-mail address, syntax plus MX domain lookup
date{d:date}time.Timedate in YYYY/MM/DD format
weekday{day:weekday}time.Weekdayday name or 0-6

Constraints

Constraints follow the type, separated by spaces. A trailing else N sets the HTTP status code the router responds with when validation fails.

ConstraintApplies toExampleMeaning
min(n)string, numbers{name:string min(3)}minimum length (string) or value (numbers)
max(n)string, numbers{name:string max(20)}maximum length (string) or value (numbers)
range(a,b)numbers{age:int range(1,120)}value between a and b, inclusive
regexp(expr)string{code:string regexp(^[A-Z]{3}$)}regular expression match
prefix(s)string{name:string prefix(user_)}must start with s
suffix(s)string{name:string suffix(.pdf)}must end with s
contains(s)string{name:string contains(admin)}must contain s
eq(v)string{state:string eq(active)}exact equality
eqor(v1,v2,...)string{flag:string eqor(on,off)}equals any listed value
else Nall{id:int else 400}status code on failure

The string macro is the master type: when another type has no function of the requested name, the string version is used. It compares against the value’s text, so {id:int prefix(4)} and {id:uuid prefix(a6)} work as they read.

Go
app.Get("/page/{page:int min(1) max(100) else 400}", pageHandler)
app.Get("/users/{name:string min(3) max(20) else 400}", profileHandler)
app.Get("/files/{filepath:path}", fileHandler)
app.Get("/shelves/{id:uuid}", shelfHandler)

When a typed parameter fails validation and no else code is given, the request never reaches your handler. Applications assembled with iris.NewBuilder (whose RouterMiddlewares step installs the macro error handler) render a 400 INVALID_ARGUMENT response with the message “invalid path parameter”.

Where that check happens is worth being precise about, because it is not where most readers assume. The router’s trie matches shape: segment count and static segments. /orders/abc does match the route /orders/{id:int}. The type check is a handler that route registration prepends to the route’s own chain, so it runs as that route’s handler zero: after any UseRouter middleware, and before the root app.Use middleware that would otherwise see the request.

Typed path parameters: registration, matching, evaluation The macro handler is prepended to the route at registration, the trie matches shape only, and the macro evaluator runs as handler zero of the matched chain, so a bad value is rejected before any handler of yours runs. STARTUP TIME 1. Route registration macro.Parse("/orders/{id:int min(1)}") reads the pattern. Because the parameter can be evaluated, handler.MakeHandler(tmpl) is PREPENDED to the route's middleware list: it becomes handler 0 of that route. 2. Build the pattern becomes a node path with positional placeholders. The trie stores shape, not types. PER REQUEST 3. GET /orders/abc trie match: segment count and static segments only. It MATCHES, and "abc" is stored in ctx.Params() by index. No type is consulted here. 4. Macro evaluator, handler 0 of the matched chain p.Eval(value) for each parameter, in template order: the type check first, then its min / max / regexp functions. every parameter passed a parameter failed ctx.Next() the entry is re-keyed to "id" and re-typed to int, which is why ctx.Params().GetInt("id") is safe ctx.StatusCode(p.ErrCode) then ctx.StopExecution(). Builder: 400 INVALID_ARGUMENT, invalid path parameter. Plain iris.New: else, or 404. Validation is not part of matching. It is the first handler of the matched route's chain, so it runs after UseRouter middleware and before root app.Use middleware.
The macro handler is prepended to the route at registration, the trie matches shape only, and the macro evaluator runs as handler zero of the matched chain, so a bad value is rejected before any handler of yours runs.

Accessing Parameters

Typed accessors return the value directly, with no (value, error) pairs. Each has a fixed failure default:

Go
func handler(ctx iris.Context) {
    name := ctx.Params().GetString("name")   // "" if missing
    id := ctx.Params().GetInt("id")          // -1 on failure
    uid := ctx.Params().GetUint64("id")      // 0 on failure
    active := ctx.Params().GetBool("active") // false on failure
    when := ctx.Params().GetDate("d")        // zero time.Time on failure
    sid := ctx.Params().GetUUID("sid")       // zero uuid.UUID on failure

    if v, ok := ctx.Params().Lookup("name"); ok {
        _ = v // the evaluated value as any
    }
}

Custom Macros

Add functions to an existing type or register a new type. Both must happen before the server starts:

Go
// A new constraint on the string type:
// usage: /status/{status:string equals(active)}
app.Macros().Get("string").RegisterFunc("equals",
    func(expected string) func(string) bool {
        return func(paramValue string) bool {
            return paramValue == expected
        }
    })

// A completely new parameter type:
// usage: /events/{when:datetime}
app.Macros().Register("datetime", "", time.Time{}, false, false,
    func(paramValue string) (any, bool) {
        t, err := time.Parse("2006-01-02T15:04:05", paramValue)
        if err != nil {
            return nil, false
        }
        return t, true
    })

Route Groups

Router(relativePath, ...) creates a child router that inherits its parent’s prefix and middleware. Groups nest to any depth, and any iris.Handler passed alongside the path becomes middleware of the group’s matched routes.

Go
package main

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

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

    api := app.Router("/api")
    api.Get("/status", status)  // GET /api/status

    v1 := api.Router("/v1")
    v1.Get("/users", listUsers) // GET /api/v1/users

    // Closure form: configure the child router in place.
    app.Router("/reports", func(r iris.Router) {
        r.Use(requireAuth)
        r.Get("/", listReports)
        r.Get("/{id:int}", getReport)
    })

    // Handlers passed to Router become the group's middleware.
    admin := app.Router("/admin", requireAuth, requireAdmin)
    admin.Get("/stats", stats)

    app.Listen(":8080")
}

Three registration points control where middleware runs; they matter most on groups:

  • UseRouter(handlers...) runs before the routing decision itself: on every request, including not-found (404) and method-not-allowed (405).
  • Use(handlers...) on the root application also covers 404 and 405 responses; on a child router it runs only for that group’s matched routes.
  • Handlers listed per route run last, in the order given, before the main handler.

The middleware contract is plain: call ctx.Next() to continue the chain, or write a response and return to stop it. The built-in middleware catalogue is covered in Chapter 12.

Controllers

The primary way to organize routes in Iris is a small convention rather than a base class: a controller is a plain struct with a constructor that declares its dependencies and a Routing(r iris.Router) method that declares its routes. The router calls Routing once at startup.

Go
// api/shelves.go
package api

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

    "bookshelf/library"
)

// ShelvesAPI groups the /shelves handlers around their dependencies.
type ShelvesAPI struct {
    svc *library.Service
}

// NewShelvesAPI is the controller's dependency declaration: the DI
// container calls it with the registered *library.Service.
func NewShelvesAPI(svc *library.Service) *ShelvesAPI {
    return &ShelvesAPI{svc: svc}
}

// Routing is the controller's route table.
func (api *ShelvesAPI) Routing(r iris.Router) {
    r.Get("/", api.list)
    r.Post("/", api.create)
    r.Get("/{id:uuid}", api.get)
    r.Delete("/{id:uuid}", api.delete)

    // A shelf's books live in their own nested controller; its
    // constructor is resolved by the container exactly like this one.
    r.Router("/{shelf_id:uuid}/books", NewBooksAPI)
}

func (api *ShelvesAPI) list(ctx iris.Context) {
    shelves, err := api.svc.Shelves(ctx)
    ctx.OK(shelves, err)
}

func (api *ShelvesAPI) get(ctx iris.Context) {
    id := ctx.Params().GetUUID("id")

    shelf, err := api.svc.Shelf(ctx, id)
    ctx.OK(shelf, err)
}

The ctx.OK(value, err) helpers render the value or hand the error to the central error pipeline; they are covered in Chapter 6 and Chapter 9.

Registering Controllers

Pass the constructor to Router. Its parameters are resolved from the dependencies you registered:

Go
app := iris.New()
app.RegisterDependency(library.NewMemStore(), library.NewService)
app.Router("/shelves", api.NewShelvesAPI)

In a builder-assembled application the API step does the same after Services has populated the container. This is the recommended setup, shown in Chapter 3:

Go
iris.NewBuilder().
    // ... Services(repo, library.NewService), middleware steps ...
    API("/shelves", api.NewShelvesAPI).
    Build().
    Listen(":8080")

Accepted Controller Shapes

Router(path, c ...any) accepts, in any combination:

  • a func(r iris.Router) closure;
  • an iris.Handler (or a slice of handlers), registered as the group’s middleware;
  • a pointer to a struct implementing Routing(r iris.Router), whose exported fields are bound from the registered dependencies;
  • a constructor function whose parameters are resolved from the dependencies and which returns such a struct, the NewXxxAPI form used above;
  • the helpers iris.Middleware(handlers...) and iris.Dependencies(deps...), which add group middleware and controller-only dependency overrides positionally:
Go
app.Router("/api",
    iris.Middleware(authMiddleware), // group middleware
    iris.Dependencies(specialDep),   // extra deps for what follows
    api.NewShelvesAPI,
    api.NewAdminAPI,
)

Guards and Positional Middleware

A guard is middleware with a yes/no job: it checks one precondition (the record exists, the user is allowed to write) and either lets the chain continue or ends the request with the right error. Putting the check in a guard means the handlers behind it never restate it.

Because guards are methods on the controller struct, they see the same injected dependencies as the handlers. Two idioms cover most cases: a group guard registered with r.Use runs before every route of the controller, and a positional guard placed between path and handler protects individual routes.

Go
// api/books.go
package api

import (
    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/rest/errors"

    "bookshelf/library"
)

// shelfContextKey caches the guard-loaded shelf on the request.
const shelfContextKey = "bookshelf.shelf"

// BooksAPI serves /shelves/{shelf_id:uuid}/books.
type BooksAPI struct {
    svc *library.Service
}

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

func (api *BooksAPI) Routing(r iris.Router) {
    // Group guard: every book route needs an existing shelf.
    r.Use(api.requireShelf)

    r.Get("/", api.list)
    // Positional guard: writes also need an unlocked shelf.
    r.Post("/", api.requireUnlocked, api.create)
    r.Put("/{id:uuid}", api.requireUnlocked, api.update)
    r.Delete("/{id:uuid}", api.requireUnlocked, api.delete)
}

// requireShelf loads the shelf once, replies 404 when it does not
// exist and caches it for the handlers that follow.
func (api *BooksAPI) requireShelf(ctx iris.Context) {
    shelfID := ctx.Params().GetUUID("shelf_id")

    shelf, err := api.svc.Shelf(ctx, shelfID)
    if errors.HandleError(ctx, err) {
        return // mapped 404, request stopped.
    }

    ctx.Values().Set(shelfContextKey, shelf)
    ctx.Next()
}

// requireUnlocked rejects writes against a locked shelf.
func (api *BooksAPI) requireUnlocked(ctx iris.Context) {
    shelf, _ := ctx.ValueAs[library.Shelf](shelfContextKey)
    if shelf.Locked {
        errors.PermissionDenied.Detail(ctx, "shelf is locked",
            "unlock the shelf before modifying its books")
        return
    }
    ctx.Next()
}

The handlers then read the cached shelf with ctx.ValueAs[library.Shelf](shelfContextKey) instead of loading it again. The full controller, with its list, create, update and delete handlers, is developed step by step in the framework’s TUTORIAL.md.

Error-Returning Handlers

Plain handlers return nothing; error handling happens inside them. The rest/errors package offers an alternative: errors.NewRouter wraps any iris.Router so that routes accept func(ctx iris.Context) error. A non-nil return value flows through the central error map: sql.ErrNoRows becomes 404 by default, and your own mappings come from errors.NewOptions() as shown in Chapter 9.

Go
package main

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

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

    r := errors.NewRouter(app.Router("/api"))

    r.UseRouter(func(ctx iris.Context) error {
        // Runs before routing, even for 404/405 requests.
        // Return nil to continue; a non-nil error is rendered
        // by the error pipeline and stops the request.
        return nil
    })

    r.Get("/books/{id:uuid}", func(ctx iris.Context) error {
        book, err := findBook(ctx.Params().GetString("id"))
        if err != nil {
            return err // mapped centrally.
        }
        return ctx.JSON(book)
    })

    app.Listen(":8080")
}

The wrapper mirrors the core router: Get, Post, Put, Delete and the rest register error-returning functions, Use and UseRouter accept error-returning middleware, and Subdomain returns another wrapped router. The embedded core router remains accessible for anything else.

Subdomains

A subdomain is the label in front of your domain: in admin.example.com it is admin. DNS usually points every subdomain at the same server (a wildcard record), and the server tells them apart by the Host header each request carries, which lets one Iris application serve example.com and admin.example.com with entirely different route tables.

Subdomain is available on every router and returns a child router bound to a host label instead of a path prefix. Calling it on a child prepends the label, so subdomains nest naturally.

Go
admin := app.Subdomain("admin")
admin.Get("/", dashboard) // admin.example.com/

// Nested: registers "panel.admin."
panel := app.Subdomain("admin").Subdomain("panel")
panel.Get("/", panelIndex) // panel.admin.example.com/

// Wildcard: any subdomain not matched by a static one.
wild := app.WildcardSubdomain()
wild.Get("/", func(ctx iris.Context) {
    ctx.Writef("subdomain: %s", ctx.Subdomain())
})

WildcardSubdomain is a method of the root application. Matching enforces dot boundaries: for a registered admin subdomain, requests to admin and staff.admin match, while badmin does not.

Route Names and Introspection

Every route gets a unique-per-application name, defaulting to method + subdomain + path (for example "GET/users/{id:uuid}"). Override it with SetName, look routes up by name, and build concrete paths from a route’s template:

Go
route := app.Get("/users/{id:uuid}", getUser)
route.SetName("user.get")

r := app.Routes().GetByName("user.get")
path := r.ResolvePath("42") // "/users/42"

None registers an offline route under the virtual method iris.MethodNone; it is not reachable over HTTP but can be inspected and toggled online by swapping its exported Method field. Before the server starts, app.RemoveRoute(name) deletes a route by name and reports whether it was found.

Registration order rarely matters, because routes are sorted at build time: static paths sort before parameterized paths of equal depth, and more specific parameter types (int, uuid, …) are evaluated before the string and path fallbacks when several routes share a position. Parameterized routes of equal priority keep their registration order.

Generic MVC Controllers

Two acronyms first. MVC (Model-View-Controller) is a long-standing way to organize applications: the model holds data and rules, the view renders output, and the controller connects requests to both. In an HTTP API, “controller” is the request-handling struct. CRUD names the four operations nearly every resource needs: create, read, update, delete.

For classic resource CRUD, the controller/mvc package generates the route table for you: a controller implements a generic interface and mvc.UseController mounts it, with With nesting further controllers under the same path.

Go
import "github.com/kataras/iris/v14/controller/mvc"

app.Router("/stores", mvc.UseController[*StoreController]().
    With("/books", mvc.UseController[*BookController]()))

This chapter’s Routing convention remains the general-purpose tool; the generic MVC controllers and their extensions are covered in Chapter 24.

Summary

  • Method functions (Get, Post, …) return a *Route; Handle and Any register one route per method and return a *RouteRegistry.
  • Path parameters are typed macros such as {id:uuid} and {n:int min(1)}, validated before the handler runs, with else N controlling the failure status and ctx.Params().GetX reading the values.
  • Router("/prefix") groups routes; handlers passed to it become group middleware, and closures configure the child in place.
  • A controller is a struct with a NewXxxAPI constructor resolved by dependency injection and a Routing(r iris.Router) route table; controllers nest through r.Router(path, NewChildAPI).
  • Guards are controller methods: r.Use for the whole group, a positional handler for single routes.
  • errors.NewRouter wraps any router so handlers return error into the central error pipeline.
  • Subdomain and WildcardSubdomain route by host label; names, offline routes and ResolvePath cover introspection needs.

Further Reading

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