Chapter 24

Advanced Topics

Generic MVC controllers, custom typed contexts, global patches, custom path macros, wrapping the router, multi-app composition and net/http interop.

Iris has no plugin system, and it does not need one: its extension model is plain Go. Every advanced capability in this chapter is an ordinary, verifiable API: generic controllers that generate REST routes from your types, typed contexts that replace iris.Context in your handler signatures, global patches that swap framework internals such as the JSON encoder, custom path parameter types, raw router wrappers, a multi-application registry, and interop with net/http. This final chapter walks through each extension point and closes with the built-in controllers that ship with the framework.

Background

What an extension point is

An extension point is a place where a framework lets you replace or extend its own machinery rather than merely use it: swap the JSON encoder behind ctx.JSON, teach the router a new path parameter type, run code before routing even begins. Reaching for one is justified in two situations: you keep repeating the same workaround in many handlers because a framework default does not fit your case, or you are integrating tooling that predates or lives outside Iris. Be clear about the flip side: most applications never need this chapter. The first twenty-two chapters cover everyday application work; if you are reading the book front to back, it is enough to remember what exists here and return when a concrete need appears.

What generics buy the MVC controllers

Go generics (type parameters, added to the language in Go 1.18) let one piece of code be written against placeholder types and specialized by the compiler for the concrete types you supply. The first section uses them as a code generator: from five typed method signatures the framework derives routes, request decoding, response encoding and error handling. The alternative in older frameworks is reflection (inspecting types while the program runs), which costs performance and turns a wrong method shape into a runtime surprise. With generics the same wiring is checked when you compile: a controller whose signatures do not match the contract fails the build, not a production request.

What process-global means

A process is one running instance of your compiled program, the unit the operating system starts and stops. State that is process-global is shared by everything inside that binary: every Iris application in it, every library it imports, every test in the same go test run. Two features below are process-global (iris.Patches() and the application registry behind the apps package), and such state demands caution for the reason globals always do: whoever writes last wins, and every reader everywhere is affected. The rules that keep it safe appear in their sections; the short version is mutate once, at startup, from main, never from a library.

Generic MVC Controllers

The routing controllers of Chapter 4 give you full control: a Routing(r iris.Router) method and any routes you like. The controller/mvc package sits one level higher. You implement five typed methods on a struct and the framework generates the REST routes, decodes requests, encodes responses, and wires the error pipeline of Chapter 9, all without reflection at serve time.

The Controller Interface

Go
// github.com/kataras/iris/v14/controller/mvc
type Controller[Context handler.ContextType, Request any, Response any,
    ID iris.IDType, Filter any, Total iris.CountType] interface {
    Create(Context, Request) (Response, error)
    Get(Context, ID) (Response, error)
    Update(Context, ID, Request, ...string) (*Response, bool, error)
    Delete(Context, ID) (bool, error)
    List(ctx Context, filter Filter, limit, offset int) (
        []Response, Total, error)
}

The six type parameters, honestly:

ParameterConstraintMeaning
Contexthandler.ContextTypeiris.Context or a standard context.Context; your methods may accept either
RequestanyThe input payload, decoded from the request
ResponseanyThe output model; also names the generated path parameter
IDiris.IDTypestring | int | int64 | uint64 | uuid.UUID
FilteranyThe List filter payload
Totaliris.CountTypeAny integer or float type for the list total

At the call site you write only the controller type; the compiler infers the rest from your method signatures. iris.CountType is defined in core/pkg/pagination as constraints.Number, and it and the other numeric bounds the framework’s generics use come from core/pkg/constraints, a six-interface package that mirrors the subset of golang.org/x/exp/constraints Iris needed, so the module carries no dependency on the experimental repository.

A Complete Controller

Go
package main

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

type Store struct {
    ID       string `json:"id"`
    Name     string `json:"name"`
    Location string `json:"location"`
}

type StoreFilter struct {
    Location string `json:"location"`
}

type StoreService struct{ /* database handle */ }

func NewStoreService() *StoreService { return &StoreService{} }

type StoreController struct {
    Service *StoreService // injected by the DI container.
}

func (c *StoreController) Create(ctx iris.Context, in Store) (Store, error) {
    in.ID = "generated"
    return in, nil
}

func (c *StoreController) Get(ctx iris.Context, id string) (Store, error) {
    return Store{ID: id, Name: "Bookstore", Location: "New York"}, nil
}

func (c *StoreController) Update(ctx iris.Context, id string, in Store,
    columns ...string) (*Store, bool, error) {
    return &in, true, nil
}

func (c *StoreController) Delete(ctx iris.Context, id string) (bool, error) {
    return true, nil
}

func (c *StoreController) List(ctx iris.Context, filter StoreFilter,
    limit, offset int) ([]Store, int, error) {
    stores := []Store{{ID: "1", Name: "Bookstore", Location: "New York"}}
    return stores, len(stores), nil
}

func main() {
    app := iris.New()
    app.RegisterDependency(NewStoreService)
    app.Router("/stores", mvc.UseController[*StoreController]())
    app.Listen(":8080")
}

UseController resolves the controller through the dependency injection container of Chapter 8, or constructs it and binds its exported fields, then registers these routes:

Method, pathController methodSuccess response
POST /storesCreate201, JSON body
GET /stores/{store_id}Get200, JSON body
PUT /stores/{store_id}?columns=a&columns=bUpdate200 if *Response non-nil, else 204 (true) or 304 (false)
DELETE /stores/{store_id}Delete204 (true) or 304 (false)
POST /stores/list, QUERY /stores/listList200, paginated JSON

The path parameter name derives from the response model (Store becomes store_id); its macro type derives from the ID type parameter: int, int64 and uint64 map to the matching macro, uuid.UUID maps to {...:uuid}, and a string ID stays a plain {...:string}. Declare the controller with uuid.UUID when you want the uuid macro’s validation. (iris.IDType permits only those five, so there is no uint case to reach.) Errors returned by any method flow through the rest/errors pipeline automatically: the controller installs errors.Handler on its child router for you.

What mvc.UseController generates The two type parameters of a controller decide one shared path parameter, and each of the five interface methods becomes one route wrapped in the decode, call and respond pipeline. ONE PATH PARAMETER, DERIVED FROM THE TWO TYPE PARAMETERS type parameter Response resolvePathParamName: ParamName() if implemented, else the short type name plus the first json field ending in _id, else <type>_id type parameter ID resolvePathParamType: int, int64 and uint64 map to their own macro; uuid.UUID maps to :uuid and a string stays :string. iris.IDType allows five. param.RoutePath = /{store_id:string} registered as a dependency, *mvc.PathParam[Store, string], and readable with mvc.GetPathParam(c) FIVE METHODS, ONE PIPELINE, FIVE ROUTES your method wrapped with route Create(ctx, Request) handler.Handler, WithStatusCode(201) POST / Get(ctx, ID) handler.Handler, WithPathParameter[ID] GET /{store_id:string} Update(ctx, ID, Request, ...string) handler.HandlerByColumns PUT /{store_id:string} Delete(ctx, ID) handler.Handler, WithPathParameter[ID] DELETE /{store_id:string} List(ctx, Filter, limit, offset) handler.ListHandler POST, QUERY /list Every one decodes the request, calls your method, answers with ctx.OK / ctx.Created / ctx.NoContent, and hands any error to the rest/errors pipeline. The controller gets its own router, with errors.Handler installed by UseOnce, and SetSourceByMethodType stamps each route with the Go method it came from. COMPOSITION app.Router("/stores", mvc.UseController[*StoreController]()) .With("/books", mvc.UseController[*BookController]()) gives /stores/{store_id:string}/books, and the child reads the parent's parameter through a StoreParam *mvc.PathParam[Store, string] field. .WithViews[IndexData, CreateData, ModelData, ListData]() adds IndexView, CreateView, DetailsView, UpdateView, DeleteView, ListView for the view methods you implement. Your own Routing(r iris.Router) runs last, on the same router.
The two type parameters of a controller decide one shared path parameter, and each of the five interface methods becomes a route wrapped in the decode, call and respond pipeline.

Resource Options

UseController takes a variadic ...mvc.Option, and the five options below cover what a generated resource usually needs to say about itself. Existing UseController() calls compile unchanged.

Go
app.Router("/stores", mvc.UseController[*StoreController](
    mvc.Guard(auth.RequireAdmin),
    mvc.Without(mvc.Delete),
    mvc.IDParam("id"),
    mvc.Pagination(iris.AllowSort("name", "created_at")),
))
OptionWhat it does
mvc.Guard(handlers...)Registers middleware on the write routes only: Create, Update, Delete
mvc.Without(verbs...)Drops generated routes. The verbs are mvc.Create, mvc.Get, mvc.Update, mvc.Delete and mvc.List
mvc.IDParam(name)Overrides the derived parameter name; the macro still comes from the ID type
mvc.BeforeWrite(fn)Runs on Create and Update, after the body is decoded and before the controller sees it
mvc.Pagination(opts...)The list route’s sort policy: iris.AllowSort, iris.DenySort, or errors.<Code>.DenySort to answer with a chosen code

Guard exists because “everyone may read, only a signed-in caller may write” is the common shape and a router-level Use cannot express it without splitting the resource into two routers. A dropped route under Without is not registered at all, so it answers 404, or 405 when the application enables FireMethodNotAllowed and another method of the same path survives.

BeforeWrite is how a value the client must not choose gets stamped on every body in one place, which is the other half of the request scope from Chapter 10:

Go
mvc.BeforeWrite(func(ctx iris.Context, v *Store) error {
    tenantID, ok := auth.ScopeID[string](ctx)
    if !ok {
        return errors.Unauthenticated
    }

    v.TenantID = tenantID
    return nil
})

The model type is a type parameter, so the hook receives *T directly and no reflection happens at serve time. Three of the options fail loudly rather than quietly: a BeforeWrite declared for a type the resource does not write panics when the routes are registered, naming both types, because what it was silently skipping is typically “stamp the tenant id on this body” and the symptom would otherwise be a cross-tenant write. Without panics on an unknown verb and IDParam on an empty name, both at option-construction time.

Routes Straight Over a Repository

When a resource’s HTTP half adds nothing to its store, there is no controller type left to write. mvc.UseRepository registers the same six routes directly over the repository:

Go
app.Router("/invoices", mvc.UseRepository(invoiceRepository))

The repository is the five methods a service already has, over the standard context: a repository never sees Iris.

Go
type Repository[T any, ID iris.IDType, Filter any] interface {
    Create(ctx context.Context, v T) (ID, error)
    Get(ctx context.Context, id ID) (T, error)
    Update(ctx context.Context, id ID, v T, onlyColumns ...string) (
        updated bool, err error)
    Delete(ctx context.Context, id ID) (deleted bool, err error)
    List(ctx context.Context, filter Filter, limit, offset int) (
        []T, int64, error)
}

POST / renders 201 {"id": ...}, GET /{model_id} the model, PUT and PATCH /{model_id} a 204 or a 304 through ctx.NoContent, DELETE /{model_id} the same, and POST or QUERY /list the page the List route already renders. The parameter name and its macro derive from the model and its ID type exactly as they do for UseController, so a uuid.UUID id registers {invoice_id:uuid} and validates before any handler runs, and every option from the table above applies here too.

One deliberate difference: the update routes read their body and columns through ctx.BindUpdate (Chapter 5), so a column that is not a member of the model answers 400 naming it and the members it could have been. The controller routes cannot do that, because their request type is decoded through a pluggable decoder. The names that reach the repository are still the client’s, so a repository that builds SQL from them maps them to its own columns and never interpolates them.

Reach for a controller as soon as a route needs anything the repository does not do: a mail on create, a computed response, a second store to keep in sync.

Custom Routes and the Generated Parameter

A controller may additionally implement Routing(r iris.Router) to add routes beside the generated ones. mvc.GetPathParam returns the generated parameter’s name, macro type and route path:

Go
func (c *StoreController) Routing(r iris.Router) {
    param := mvc.GetPathParam(c) // {Name: "store_id", Type: "uuid", ...}
    r.Get("/featured", func(ctx iris.Context) {
        ctx.Writef("featured stores (id param: %s)", param.Name)
    })
}

Routing runs before the five generated routes are registered, so an r.Use here guards those too:

Go
func (c *StoreController) Routing(r iris.Router) {
    r.Use(auth.RequireAdmin) // protects Create, Get, Update, Delete, List
}

The ordering is what makes that work. A route captures the router’s middleware at the moment it is created, so middleware added after a route exists never reaches it.

One Controller, Every Request

A controller is a singleton. It is resolved once, its exported fields are bound once, and its five methods are captured into handlers at registration. There is no per-request instance, so every field is shared across all concurrent requests:

Go
type StoreController struct {
    Service *StoreService // fine: written once, read everywhere
    current *Store        // wrong: one request's value, visible to all
}

Keep fields for dependencies. Anything that belongs to a single request belongs on the context or in a method parameter.

Nested Controllers

UseController returns a *DeployedController. Its With(relPathWithoutParam, controllers...) method mounts children under the parent’s id segment:

Go
app.Router("/stores", mvc.UseController[*StoreController]().
    With("/books", mvc.UseController[*BookController]()))
// BookController serves under /stores/{store_id}/books

A child reads the parent’s path parameter through an auto-injected mvc.PathParam[T, V] field, where T is the parent response type and V its ID type:

Go
type BookController struct {
    Service    *BookService
    StoreParam *mvc.PathParam[Store, string]
}

func (c *BookController) Create(ctx iris.Context, book Book) (Book, error) {
    book.StoreID = c.StoreParam.Get(ctx) // or GetString(ctx)
    return book, nil
}

DeployedController.WithViews adds HTML endpoints beside the JSON ones for controllers that also implement the view methods (IndexView, CreateView, DetailsView, UpdateView, DeleteView, ListView) against a registered view engine. It is a generic method: the four type arguments name the data types those methods return, and a controller that does not implement mvc.ViewController for them panics at registration time rather than at first request:

Go
app.Router("/books", mvc.UseController[*BookController]().
    WithViews[IndexData, CreateData, ModelData, ListData]())

The generated routes are GET /, /create, /{id}/details, /{id}/update, /{id}/delete and /list, and the template names derive from the controller’s model name plus mvc.DefaultTemplateExtension (books/index.html and so on).

The handler Subpackage

The machinery behind UseController is public in controller/mvc/handler and usable route-by-route. handler.Handler wraps a typed function into an iris.Handler with the same decode/encode/error pipeline; options tune it:

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

// POST /orders: decode Order from the body, respond 201.
app.Post("/orders", handler.Handler(createOrder,
    handler.WithStatusCode(iris.StatusCreated)))

// GET /orders/{order_id:int64}: decode the named path parameter
// into the function's second argument.
app.Get("/orders/{order_id:int64}", handler.Handler(getOrder,
    handler.WithPathParameter[int64]("order_id")))

func createOrder(ctx iris.Context, in Order) (Order, error) { /* ... */ }
func getOrder(ctx iris.Context, id int64) (Order, error)    { /* ... */ }

For single-value types (string, int, int64, uint64, uuid.UUID) the default decoder already reads the last path parameter; WithPathParameter[T](name) makes the binding explicit by name. handler.IndexHandler wraps a func(Context) (Response, error) action with no request payload.

WebSocket and gRPC Controllers

Both protocols in this section depart from HTTP’s request-response rhythm. A WebSocket is a long-lived, two-way connection: after an initial HTTP handshake, client and server can each push messages at any moment, the transport behind chats, live dashboards and games. gRPC is a remote procedure call protocol: instead of paths and JSON, a client calls typed methods defined in a shared .proto schema, with binary messages carried over HTTP/2, a common choice for backend-to-backend APIs.

Two more controller forms ship in the same package. mvc.Websocket registers WebSocket controllers backed by the third-party github.com/kataras/neffos library; struct pointers or constructor functions, resolved through DI:

Go
app.Router("/websocket", mvc.Websocket(newChatController))

A WebSocket controller may implement Namespace() string and the standard neffos events (OnNamespaceConnect, OnNamespaceConnected, OnNamespaceDisconnect, OnRoomJoin, OnRoomJoined, OnRoomLeave, OnRoomLeft); any other exported method with the func(*neffos.NSConn, neffos.Message) error shape becomes a custom event.

mvc.GRPC serves a gRPC server (third-party google.golang.org/grpc) through the Iris router, POST-only, at /{ServiceName}/{Method} paths:

Go
grpcServer := grpc.NewServer()
pb.RegisterGreeterServer(grpcServer, ctrl)

app.Router("/", mvc.GRPC(ctrl).
    Server(grpcServer).
    ServiceName("helloworld.Greeter").
    Strict(false))

With Strict(true) only gRPC clients are served and plain HTTP requests receive 404; set it to true for streaming.

When Generic Controllers Pay Off

Reach for mvc.UseController when you have several resources that genuinely fit the Create/Get/Update/Delete/List shape and you want identical REST semantics, error handling and pagination across all of them: the five signatures are a contract, and uniformity is the reward. When a resource is not CRUD-shaped, or its routes need individual middleware and status conventions, a plain routing controller from Chapter 4 is the better tool. The two styles compose freely: a generic controller’s Routing method and With children are ordinary routers.

Custom Typed Contexts

Handlers that keep reading the same values out of ctx.Values() and casting them accumulate noise. iris.NewContextWrapper lets you define your own context type (embedding iris.Context) and write handlers against it directly:

Go
package main

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

// appContext extends iris.Context with application-specific helpers.
type appContext struct {
    iris.Context
}

// SetContext makes *appContext usable by the wrapper's default pool.
func (c *appContext) SetContext(ctx iris.Context) {
    c.Context = ctx
}

func (c *appContext) TenantID() string {
    return c.Values().GetString("tenant_id")
}

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

    // The pointer type must implement SetContext(iris.Context).
    w := iris.NewContextWrapper[appContext]()

    app.Get("/dashboard", w.Handler(func(ctx *appContext) {
        ctx.JSON(iris.Map{"tenant": ctx.TenantID()})
    }))

    app.Listen(":8080")
}

w.Handler converts a func(*appContext) into a standard iris.Handler: per request it acquires an *appContext, calls SetContext with the live Iris context, runs your function, and releases the value. Because the custom type embeds iris.Context, every context method remains available, and you can override methods or add helpers as needed.

The wrapper offers more converters for the places Iris expects specific function shapes: Handlers (a slice at once), HandlerReturnError, HandlerReturnDuration and Filter.

A sync.Pool, from Go’s standard library, is a recycling bin for short-lived objects: instead of allocating a fresh value for every request and leaving it to the garbage collector, code takes an instance from the pool and puts it back when done, reducing allocation pressure under load.

The default pool allocates a zero value per request. To recycle instances, build the wrapper from the context subpackage with a sync.Pool-backed pool:

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

w := context.NewContextWrapperWithPool(
    context.NewContextPool[appContext]())

With a recycling pool, SetContext is your reset hook: it runs on every acquire, so reset any custom fields there: instances are reused across requests.

Global Patches

iris.Patches() returns a process-wide singleton for overriding framework defaults. These are the patch points:

PatchEffect
Patches().Context().Writers().JSON(fn)Replaces the writer behind ctx.JSON
Patches().Context().Writers().JSONP/XML/Markdown/YAML(fn)Same for the other body writers
Patches().Context().GetDomain(fn)Changes how Context.Domain() derives the domain from host:port
Patches().Context().SetDefaultCookieExpiration(d)Changes the lifetime Context.AddCookie gives a cookie with no CookieExpires option (365 days by default)
Patches().Context().ResolveHTTPFS(fn)Changes filesystem resolution for the file server
Patches().Context().ResolveFS(fn)Changes filesystem resolution for the view engine
Patches().Context().SetMaxPageSize(n)Sets pagination.MaxPageSize, the upper bound of PageOptions.GetLimit
Patches().Context().SetDefaultPageSize(n)Sets pagination.DefaultPageSize, used when a list request sends no size

The classic use of the JSON patch point was swapping in a faster third-party encoder (jsoniter, easyjson). That trade no longer exists: since Go 1.27 ctx.JSON streams through the standard library’s encoding/json/v2, redesigned around the same streaming approach those libraries were built for, so the stdlib encoder is already the fast path. What the seam is still for is policy: a binary that must pretty-print every response, or enforce one marshaling option set everywhere, patches once instead of threading options through every call:

Go
package main

import (
    "encoding/json/jsontext"
    json "encoding/json/v2"

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

func main() {
    // Process-global: applies to every ctx.JSON call
    // of every Iris application in this binary.
    iris.Patches().Context().Writers().JSON(
        func(ctx iris.Context, v any, options *iris.JSON) error {
            return json.MarshalWrite(ctx.ResponseWriter(), v,
                jsontext.WithIndent("  ")) // house style: always indented.
        })

    app := iris.New()
    app.Get("/", func(ctx iris.Context) {
        ctx.JSON(iris.Map{"encoder": "stdlib, patched"})
    })
    app.Listen(":8080")
}

(For a per-call version of the same control, pass options in the iris.JSON struct’s MarshalOptions field instead; see Chapter 6. A type that implements encoding/json/v2’s json.MarshalerTo interface streams itself through plain ctx.JSON with no patch at all, which replaces the old ctx.EasyJSON special case.)

Two caveats keep patches honest. First, they are process-global: a patch affects every application in the binary, including test servers, so libraries must never patch; only the main package should. Second, apply them once, at startup, before serving; patches are package-level variables underneath, not synchronized for concurrent replacement.

Custom Path Macros

Chapter 4 covers the built-in path parameter types ({id:int}, {id:uuid}, {p:path} and friends). app.Macros() exposes the registry behind them, so you can extend an existing type with a new function or register an entirely new parameter type.

Add a function to an existing type with Get + RegisterFunc. The outer function receives the compile-time arguments from the route pattern; the returned function validates each request’s value:

Go
app.Macros().Get("string").RegisterFunc("equals",
    func(expected string) func(string) bool {
        return func(paramValue string) bool {
            return paramValue == expected
        }
    })

// Usage:
app.Get("/status/{state:string equals(active)}", handler)

Register a new parameter type with Register. The evaluator converts the raw segment into a typed value, or reports failure:

Go
import "time"

app.Macros().Register("datetime", "", time.Time{}, false, false,
    func(paramValue string) (any, bool) {
        t, err := time.Parse("2006-01-02", paramValue)
        if err != nil {
            return nil, false
        }
        return t, true
    })

app.Get("/events/{when:datetime}", func(ctx iris.Context) {
    when := ctx.Params().GetTime("when") // the stored time.Time, as is
    ctx.JSON(iris.Map{"weekday": when.Weekday().String()})
})

The Register arguments after the alias are the sample value (its type becomes the parameter’s Go type), whether the type is the master default, and whether it is trailing (consumes the rest of the path, like path).

When a typed parameter fails to evaluate, builder-built applications respond with 400 INVALID_ARGUMENT (“invalid path parameter”) by default. SetErrorHandler (on the whole registry or a single macro) replaces that behavior:

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

app.Macros().SetErrorHandler(
    func(ctx iris.Context, paramIndex int, err error) {
        errors.InvalidArgument.Detailf(ctx, "bad path parameter",
            "parameter %d: %v", paramIndex, err)
    })

Wrapping the Router

net/http is the HTTP server in Go’s standard library, the foundation every Go web framework, Iris included, builds on. Its two base types are http.ResponseWriter, which writes the response, and *http.Request, which describes the request; a router wrapper works directly at that level, before any iris.Context exists.

Middleware runs after Iris has acquired a context and, for Use, matched a route. Router wrappers run earlier: raw net/http code executed before routing, for every request. The wrapper signature is:

Go
func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc)

Call next to continue into the router (or the next wrapper); skip it to short-circuit. Because the wrapper sees the request before any matching happens, it is the right place for cross-cutting URL rewrites:

Go
package main

import (
    "net/http"
    "strings"

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

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

    // Serve the old /legacy/* URLs from the /v2 routes,
    // without redirects and without touching the routes themselves.
    app.WrapRouter(func(w http.ResponseWriter, r *http.Request,
        next http.HandlerFunc) {
        if p, ok := strings.CutPrefix(r.URL.Path, "/legacy/"); ok {
            r.URL.Path = "/v2/" + p
        }
        next(w, r)
    })

    app.Get("/v2/users", func(ctx iris.Context) {
        ctx.JSON(iris.Map{"version": 2})
    })

    app.Listen(":8080")
}

Three registration methods exist on the root router, differing only in ordering:

  • WrapRouter(fn): wraps immediately; with multiple calls the last registered wrapper runs first (each new wrapper wraps the previous chain).
  • AddRouterWrapper(fn): defers wrapping until the router builds; the first registered runs first. Use it for redirect-style wrappers that must observe the request before others rewrite it.
  • PrependRouterWrapper(fn): like AddRouterWrapper but always first; reserved for path-normalization concerns.

Choose UseRouter (Iris middleware, has a Context, still before route matching) when you need context features; choose a router wrapper when you need to mutate the raw request or integrate net/http-level tooling.

At the extreme end, app.Downgrade(handlerFunc) replaces the entire Iris router with your own http.HandlerFunc. The application still builds, hosts and shuts down normally, but routing (and every wrapper) is bypassed; you are responsible for everything.

Multi-Application Composition

Every iris.New call registers the application in a process-wide registry. Give applications names through their options and any package can retrieve them without import cycles:

Go
app := iris.New(iris.Options{Name: "api"})

// Elsewhere, without importing the package that built it:
import "github.com/kataras/iris/v14/apps"

api := apps.Get("api") // nil if no app has that exact name.
all := apps.GetAll()   // every registered *iris.Application.
apps.OnApplicationRegistered(func(app *iris.Application) {
    // fires for each future registration.
})

The registry itself lives in the context package: context.GetApplications() returns the same set as read-only context.Application interface values; that is what framework internals use, while apps.GetAll casts to the concrete type for you.

The Switch Application

apps.Switch composes several applications behind one listener. It returns a new, ordinary application whose job is to route each request to the first matching case:

Go
package main

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

func newWebsite() *iris.Application {
    app := iris.New(iris.Options{Name: "website"})
    app.Get("/", func(ctx iris.Context) { ctx.Writef("website") })
    return app
}

func newAPI() *iris.Application {
    app := iris.New(iris.Options{Name: "api"})
    app.Get("/status", func(ctx iris.Context) {
        ctx.JSON(iris.Map{"ok": true})
    })
    return app
}

func main() {
    website := newWebsite()
    api := newAPI()

    switcher := apps.Switch(apps.Hosts{
        {Pattern: "api.example.com", Target: api},
        {Pattern: "example.com", Target: website},
    })

    switcher.Listen(":80")
}

What Switch actually does: it builds each case application on its own Build (via OnBuild), then installs a UseRouter handler that evaluates the case filters in registration order. On the first match it forwards the request to that application’s ServeHTTP: the matched app serves with its own context pool and configuration. If nothing matches, the request falls through to the switcher’s own router, so you can register fallback routes or a custom 404 on the switcher itself. It is deliberately not a load balancer; it is a filter chain.

The Hosts provider matches the request host against each Pattern, which may be a literal or a regular expression (the apps.AnyDomain constant matches any domain). Target accepts an *iris.Application, the name of a registered application, or a plain host string; the latter produces a permanent redirect to that host. Providers combine with apps.Join, and the apps.SetHost("canonical.example.com") option rewrites the request host before the matched application sees it, useful for serving www. and the root domain from one application without redirects.

Standard Library Interop

Iris converts in both directions: standard handlers into Iris handlers, and the application itself into a standard http.Handler.

http.Handler is a one-method interface, ServeHTTP(http.ResponseWriter, *http.Request), and it is the lingua franca of Go’s HTTP ecosystem: metrics exporters, profilers, proxies and entire frameworks all either implement it or accept it. That shared contract is what makes both conversions below mechanical.

Calls in the other direction, out to somebody else’s service, have their own package: github.com/kataras/iris/v14/client, whose client.New takes a base URL, persistent request options, timeouts, rate limiters and a retry policy, and whose request helpers marshal and unmarshal JSON, form and multipart bodies, so a service does not hand-roll a fourth *http.Client wrapper. c.BindJSON[Order](ctx, http.MethodGet, "/orders/42", nil) names the response type at the call site and returns it, the way ctx.BindJSON does on the server side; a status of 400 or above comes back as an APIError that rest/errors.HandleAPIError forwards to your own client. A paginated Iris list reads back through client.List[T], one page, or client.Items[T], which walks every page as an iter.Seq2[T, error]; both send QUERY and decode into the same Page[T] the server built. Retries with backoff, a rate limit per client or per endpoint, and redaction of API keys from error messages and debug dumps are options, not code you write. The package is a thin layer over the author’s standalone github.com/kataras/httpclient, with the framework’s JSON policy as its default, so an application never imports that module directly.

Mounting http.Handler Values

iris.FromStd accepts an http.Handler, an http.HandlerFunc-shaped function, or a middleware-shaped func(w, r, next http.HandlerFunc) and returns an iris.Handler:

Go
package main

import (
    // Third-party: the Prometheus client.
    "github.com/prometheus/client_golang/prometheus/promhttp"

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

func main() {
    app := iris.New()
    app.Get("/metrics", iris.FromStd(promhttp.Handler()))
    app.Listen(":8080")
}

A mounted handler reads route parameters with r.PathValue(name) and the matched route template, such as /users/{id:uuid}, from r.Pattern, in all three forms. r.Pattern stays empty where no route has matched yet or none will: in the UseRouter slot and in the built-in 404 and 405 handlers. Anything written against net/http mounts this way; see Chapter 17 for the full monitoring setup around that /metrics route.

The Application as an http.Handler

After Build, the application satisfies http.Handler, so it drops into any standard server, test harness or third-party listener:

Go
package main

import (
    "log"
    "net/http"

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

func main() {
    app := iris.New()
    app.Get("/", func(ctx iris.Context) {
        ctx.JSON(iris.Map{"served_by": "net/http"})
    })

    if err := app.Build(); err != nil {
        log.Fatal(err)
    }

    srv := &http.Server{
        Addr:    ":8080",
        Handler: app, // *iris.Application implements http.Handler.
    }
    log.Fatal(srv.ListenAndServe())
}

app.Listen and app.Run call Build for you; call it yourself only when handing the application to infrastructure you control, as above. This is also how one Iris application embeds inside another: build the inner app and forward requests to its ServeHTTP from a route of the outer one.

Built-in Controllers

The controller package tree ships ready-made controllers that implement the same Routing(r iris.Router) interface as your own and register the same two ways:

Go
// Directly on a router:
app.Router("/static", fileserver.New("./assets", fileserver.DefaultOptions))

// Or through the builder's API step (see Chapter 3):
iris.NewBuilder().
    // ...builder steps...
    API("/static", fileserver.New("./assets", fileserver.DefaultOptions)).
    Build().
    Listen(":8080")

File Server

fileserver.New(fileSystemOrDirectory, options) serves static files from a directory path, an http.FileSystem, an embed.FS or an fs.FS. A directory path follows symbolic links that point outside it; pass root.FS() of an os.OpenRoot instead when that matters, and accept that a link you placed there on purpose stops being served (Chapter 18):

Go
package main

import (
    "embed"

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

//go:embed assets
var embeddedAssets embed.FS

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

    app.Router("/static", fileserver.New(embeddedAssets,
        fileserver.Options{
            IndexName:      "index.html",
            ShowList:       true,
            AssetValidator: fileserver.BlockHidden,
        }))

    app.Listen(":8080")
}

The options worth knowing: IndexName (directory index file), ShowList and ShowHidden (directory listings, with DirList for a custom renderer), SPA (serve the index instead of 404 for unknown files, as single-page applications), Attachments (download mode with rate limiting), Cache (in-memory caching and pre-compression), PushTargets (HTTP/2 push), and AssetValidator, a per-request func(ctx iris.Context, name string) bool gate. fileserver.BlockHidden is a ready validator that denies dotfiles such as .git, .env and .htpasswd. fileserver.DefaultOptions is a sensible starting value.

The package also exports Handler (the same serving logic as a plain iris.Handler, no controller), Favicon(path) (a cached GET|HEAD /favicon.ico controller) and WebDAV(handler) (wraps a golang.org/x/net/webdav handler, third-party, registering all WebDAV methods on /{p:path}).

API Graph

apigraph.New serves an interactive D3.js visualization of every registered route: paths, methods, handlers, with editor links to each handler’s source line. The graph is built after the application builds, so registration order does not matter:

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

app.Router("/apigraph", apigraph.New(apigraph.Options{Theme: "dark"}))

Options.Theme is "light" (default) or "dark"; register custom themes with apigraph.RegisterTheme. Mount it in development builds: it is a map of your entire API surface.

Sitemap

A sitemap is an XML file that lists a site’s pages for search-engine crawlers, optionally hinting how often each page changes and how important it is relative to the rest.

sitemap.New(domain) generates /sitemap.xml from the registered static routes at build time:

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

app.Router("/", sitemap.New("https://www.example.com"))

app.Get("/", index)
app.Get("/about", about).
    SetLastMod(time.Now()).
    SetChangeFreq("monthly").
    SetPriority(0.8)
app.Get("/internal", internal).ExcludeSitemap()
// GET /sitemap.xml now lists / and /about.

The route methods SetLastMod, SetChangeFreq and SetPriority fill the matching sitemap elements; ExcludeSitemap removes a route. Dynamic, subdomain and offline routes are excluded automatically, and above 50,000 entries the output splits into an indexed set. With the i18n middleware in place, WithLocalization emits translated alternate links per language.

Summary

  • mvc.UseController[*C]() generates the five REST routes from a typed controller, with DI-resolved construction, nested resources via With and parent parameters via mvc.PathParam[T, V]; the controller/mvc/handler package exposes the same typed pipeline (Handler, WithStatusCode, WithPathParameter) route-by-route, and mvc.Websocket / mvc.GRPC cover the non-REST protocols.
  • iris.NewContextWrapper[T]() turns a struct embedding iris.Context into the argument type of your handlers; pool it for reuse with context.NewContextWrapperWithPool.
  • iris.Patches() swaps framework internals (JSON and other body writers, domain resolution, cookie defaults, filesystem resolution) once, at startup, for the whole process.
  • app.Macros() extends the path parameter language: RegisterFunc adds functions to existing types, Register adds new types, and SetErrorHandler shapes the 400 response.
  • WrapRouter, AddRouterWrapper and PrependRouterWrapper run raw net/http code before routing; Downgrade replaces the router entirely.
  • The apps package is a process-wide application registry, and apps.Switch with apps.Hosts routes one listener across many applications by host pattern.
  • iris.FromStd mounts any net/http handler, and after Build the application itself is an http.Handler.
  • controller/fileserver, controller/apigraph and controller/sitemap are ready-made controllers for static assets, route visualization and sitemap generation.

Further Reading

You have followed Iris from a first iris.New() to generic controllers, typed contexts and framework patches; there is no larger machinery left hidden.

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