Chapter 3

Building Applications with iris.NewBuilder

The compile-enforced builder chain. Each step returns an interface exposing only the moves still legal, so a step called out of turn will not compile.

iris.NewBuilder() is the recommended way to assemble an Iris v14 application. It is a fluent chain of configuration steps whose order the compiler enforces: each step returns the interface of the next, so CORS cannot come after logging and controllers cannot be registered before services. Along the way the builder installs the production plumbing you would otherwise wire by hand: recovery, structured error handling, compression, access logging, a health endpoint, dependency injection and graceful shutdown. This chapter walks through every step, backed by what the builder actually does in iris_builder.go, and ends with a complete application you can run and test.

Background

What “wiring” an application means. Every server program has two phases: a short setup phase and a long serving phase. In the setup phase you read configuration (the values that differ per deployment: addresses, credentials, allowed origins), construct your services and their dependencies, register routes and middleware, and decide what must happen at shutdown. Wiring is that setup phase, and it is where most production bugs are born, not because any single step is hard, but because the steps depend on each other and their order matters.

Cross-cutting concerns. Some behavior belongs to every request rather than to any one handler: logging what happened, recovering from crashes, compressing responses, checking which websites may call you, formatting errors consistently. These are called cross-cutting concerns because they cut across all your routes. Handled naively, each one is a line you must remember in every handler; handled as middleware wired once at startup, they disappear from your handlers entirely.

Startup and shutdown order. Resources start in dependency order (the database connection opens before the service that queries it) and must stop in a sensible order too: stop accepting new requests, let in-flight ones finish, then close what startup opened. Forgetting the shutdown half leaks connections and loses buffered logs. A good wiring layer pairs every “open” with its “close” at the moment you write it.

The builder pattern. A builder is an object you configure through a chain of method calls and finish with one final call (here Build()) that produces the assembled product. The chain reads top to bottom like a checklist, which is the point: the whole application’s wiring is one visible expression instead of registrations scattered across files. Iris adds a stricter twist: each step returns a different type that offers only the next legal steps, so skipping a step or calling them out of order is not a bug you find under traffic. It is code that does not compile. The class of mistake this removes is exactly the class that is hardest to test for: middleware registered after the routes it should protect, recovery forgotten, shutdown code never registered.

Why the Builder

A production HTTP API needs more than routes: panics must become logged 500 responses instead of dropped connections, errors need one consistent JSON shape, responses should compress, requests should be logged, shutdown must close what startup opened, and middleware must run in the right order. None of that is hard individually; getting the order right every time is. The builder encodes the order once, in the framework, and gives you named steps for the decisions that are actually yours: which origins to allow, what to log, how to map your errors, which services to inject.

The result is that a whole application reads as a single expression:

Go
iris.NewBuilder().
    Prefix("/api").
    AllowOrigin("https://example.com").
    Compression(true).
    LogRequests(true).
    Health(true, "production", "team@example.com").
    Errors(errors.NewOptions().
        MapErrors(errors.NotFound, ErrNoteNotFound)).
    Deferrables(func() { log.Println("bye") }).
    Services(store, NewNoteService).
    RouterMiddlewares().
    Middlewares().
    API("/notes", NewNotesAPI).
    Build().
    Listen(":8080")

iris.NewBuilder(configurators ...iris.Configurator) creates the underlying application (any configurators you pass apply first) and registers the *iris.Application itself as a dependency, so controllers and custom components can receive it through injection.

The Step Chain

Each builder method returns a narrower interface that exposes only the next legal steps:

The iris.NewBuilder step chain Each builder step returns the interface for the next one, so the compiler walks you through the chain in order; four steps return their own interface and may be repeated, and Build ends the chain with an application. Builder Prefix(prefixPath) or NoPrefix() returns CorsBuilder CorsBuilder AllowOrigin(originLine) or Cors(c) returns CompressionBuilder CompressionBuilder Compression(enabled) returns RequestLoggingBuilder RequestLoggingBuilder LogRequests(enabled) or LogRequestsWith(ac) returns HealthBuilder HealthBuilder Health(enabled, env, developer) or NoHealth() returns ServiceBuilder ServiceBuilder Errors(opts) Deferrables(closers...) Services(deps...) returns MiddlewareBuilder Errors, Deferrables repeat MiddlewareBuilder RouterMiddlewares(mw...) Middlewares(mw...) returns APIBuilder RouterMiddlewares repeats APIBuilder API(prefixPath, controllers...) Build() returns *iris.Application API repeats, one per group Every chain passes through Middlewares(), the only exit from MiddlewareBuilder. It calls RouterMiddlewares() internally, so the built-in middleware installs exactly once whichever you call first.
Each builder step returns the interface for the next one, so the compiler walks you through the chain in order; four steps return their own interface and may be repeated, and Build ends the chain with an application.

Three of the stages allow repetition. Errors and Deferrables return ServiceBuilder again, so you may call them in either order or several times before Services advances the chain. RouterMiddlewares returns MiddlewareBuilder, so it can be called repeatedly before Middlewares advances. API returns APIBuilder, so you mount as many groups as you need before the terminal Build.

The order is not decorative. Because each step returns a different type, skipping a stage is a compile error, not a runtime surprise. This is also why real applications contain the two seemingly empty calls:

Go
Services(store).
RouterMiddlewares(). // nothing custom, still required to move on
Middlewares().       // nothing custom, still required to move on
API("/notes", NewNotesAPI)

Services returns MiddlewareBuilder, and the only way out of MiddlewareBuilder is Middlewares(), so every chain passes through it even with no arguments. The calls are not no-ops either: the first of them triggers the builder’s one-time middleware setup described in RouterMiddlewares and Middlewares. Strictly speaking RouterMiddlewares() could be omitted, because Middlewares() invokes it internally, but writing both keeps the two middleware layers visible at the call site.

One consequence of the changing return types: you cannot hold the chain in a single variable and reassign it across steps. Compute conditional values first, then build in one uninterrupted expression:

Go
origin := "https://example.com"
if env == "development" {
    origin = "*"
}

app := iris.NewBuilder().
    Prefix("/api").
    AllowOrigin(origin).
    Compression(env == "production").
    LogRequests(env != "production").
    Health(true, env, "team@example.com").
    Services().
    Middlewares().
    Build()

Step Reference

Prefix and NoPrefix

Prefix("/api") creates a child router with app.Router("/api") and points every later step at it: CORS, access logging, your middlewares and all API() groups mount under that prefix. Use it for versioning (/api/v1) or to keep the API apart from static content. NoPrefix() targets the application root instead; it is the same as Prefix("").

The one deliberate exception is the health endpoint, which always registers at the root (see below).

AllowOrigin

CORS (Cross-Origin Resource Sharing) is the browser’s permission system for cross-site calls: JavaScript served from https://app.com may not call an API on a different origin unless that API’s responses explicitly allow it in headers. For some requests the browser first sends a preflight (an automatic OPTIONS request that asks “may I?”) and proceeds only when the answer permits. The middleware writes those headers and answers preflights for you; your only decision is which origins to allow.

AllowOrigin configures the CORS middleware. The argument is trimmed and lowercased, then routed:

  • "none": no CORS middleware is installed at all.
  • "*": allow every origin (public, credential-free APIs only).
  • "https://app.com": exactly one origin.
  • "https://app.com,https://admin.app.com": a comma-separated list.

When enabled, the builder registers cors.New().HandleErrorFunc(errors.FailedPrecondition.Err). AllowOrigin(originLine).Handler() with UseRouter, so it runs before routing and handles preflight requests for every path under the prefix. Wiring HandleErrorFunc to the errors package means a disallowed origin is rejected with the same structured JSON error body as the rest of your API, instead of an anonymous plain-text response.

Cors

AllowOrigin covers the common case in one string. Everything else the middleware can do (credentials, exposed headers, max age, the origin extractor, a different error handler) needs the CORS value itself, which is what the alternative step at this stage takes:

Go
iris.NewBuilder().
    Prefix("/api").
    Cors(cors.New().
        HandleErrorFunc(errors.FailedPrecondition.Err).
        AllowOrigins("https://app.com", "https://*.admin.com").
        ExposeHeaders("X-Total-Count").
        AllowCredentials()).
    Compression(true).
    // ...

The two steps are the same call underneath: AllowOrigin(line) is the shorthand for Cors(cors.New().HandleErrorFunc(errors.FailedPrecondition.Err).AllowOrigin(line)). Both register through UseRouter at the same position in the chain, so preflights are answered before routing either way. Pass nil and no middleware is installed, the same as AllowOrigin("none").

Your value is used exactly as given, with no defaults merged into it. That one difference matters: cors.New() on its own carries the middleware’s default error handler, so a Cors step that omits HandleErrorFunc rejects disallowed origins with a bare 403 rather than the structured error body AllowOrigin installs. Set it yourself when you want the two to behave alike.

AllowCredentials is worth a note. A credentialed cross-origin request (one carrying cookies or an Authorization header) is only permitted when the response says Access-Control-Allow-Credentials: true, and browsers refuse that header when the allowed origin is the wildcard *. The middleware follows the browser rule: with a wildcard origin it suppresses the credentials header unless you called AllowCredentials explicitly. In practice a browser always sends Origin and the middleware echoes that origin back rather than *, so credentials are advertised normally; the wildcard case arises for requests that carry no Origin header at all, where AllowCredentials is the way to opt in.

Compression

Compression shrinks response bodies before they cross the network (JSON and HTML typically compress to a fraction of their size), trading a little CPU for less bandwidth and faster responses on slow links. Client and server agree on the algorithm through headers, which is the negotiation described below.

Compression(true) only records a flag; the actual middleware/compress handler is installed later, during the one-time middleware setup, and only if a handler named iris.compression is not already registered. The middleware negotiates the response encoding from the client’s Accept-Encoding header (gzip, deflate, br, snappy, s2, zstd) and also decompresses request bodies that arrive with a Content-Encoding header, which is the “read” half of the parameter’s enableReadWriteCompression name. Pass false while debugging if you want to read raw response bodies on the wire.

LogRequests and LogRequestsWith

LogRequests(true) is a shortcut for LogRequestsWith(ac) with the framework’s tuned middleware/accesslog configuration:

  • JSON lines, indented, with human-readable timestamps, written to access_log.json and mirrored to stdout.
  • Per request: latency, status code, method, path, client IP, request and response bodies, and their byte sizes.
  • Panics are logged too (PanicLog), with multi-line errors preserved.
  • Synchronous writes (Async = false), so nothing is lost on crash.

The handler registers with UseRouter on the builder’s router, and its Close method is added as an application closer, so buffered output is flushed on shutdown. LogRequests(false) installs nothing.

When the defaults do not fit, build your own access log and pass it at the same position; a nil value is ignored:

Go
ac := accesslog.File("access_log.json")
ac.RequestBody = false // do not log request payloads
ac.AddFields(func(ctx iris.Context, f *accesslog.Fields) {
    f.Set("tenant", ctx.Header("X-Tenant-ID"))
})

// ... .Compression(true).LogRequestsWith(ac). ...

Either way, add access_log.json to .gitignore.

Health and NoHealth

A health endpoint is a URL whose only job is to answer “is this process alive, and which build is it?”. Monitoring systems poll it and alert, restart or reroute traffic when it stops answering.

Health(true, env, developer) registers GET /health at the application root, intentionally outside the prefix: load balancers, Kubernetes probes and uptime monitors ask for /health, not /api/v1/health. The handler comes from middleware/modrevision and responds with plain text containing the server name and environment (Iris Server (production)), the build revision and build time when the binary carries module build info, the developer line, detected cloud container information, and a final OK.

The developer string does double duty: the builder remembers it and, during the one-time middleware setup, installs a handler that sets a Server: Iris header on every response plus an X-Developer header when the string is non-empty. That makes it trivial to tell from any response which team owns a deployment. NoHealth() skips the endpoint and leaves the developer empty, so no X-Developer header is emitted.

Errors

Errors(*errors.Options) accepts the central error map from github.com/kataras/iris/v14/rest/errors. The builder stores the options and, during the one-time middleware setup, registers opts.Handler() with UseRouter; the handler attaches your options to each request context, so every error helper in the framework consults your map when it renders a response.

errors.NewOptions() starts from useful defaults, sending database/sql.ErrNoRows to NOT_FOUND and the kataras/jwt token errors to UNAUTHENTICATED. You extend it with your own mappings:

Go
Errors(errors.NewOptions().
    MapErrors(errors.NotFound, ErrNoteNotFound).
    MapErrors(errors.PermissionDenied, ErrArchiveLocked).
    MapErrorFunc(errors.Internal, func(err error) error {
        return errors.Private(err) // hide details from clients
    }))

MapErrors matches with errors.Is semantics, MapErrorFunc and the context-aware MapErrorFuncCtx transform errors, and MapErrorTypes matches by type. The default rendering is a structured JSON object; chain SetRenderer(errors.Problem) for RFC 7807 Problem Details output. Chapter 9 covers the error system in full.

Deferrables

Deferrables(closers ...any) registers shutdown work. Every argument is forwarded to app.AddCloser, which accepts exactly these shapes and panics at registration time on anything else:

  • func()
  • func() error (the error is preserved and joined into the shutdown error with errors.Join)
  • func(context.Context)
  • func(context.Context) error
  • io.Closer (anything with Close() error)
  • the context-aware Closer from core/server/closer (CloseWithContext(context.Context) error)

Closers run in registration order when the application shuts down through app.Shutdown(ctx). The built-in interrupt handler drains in-flight requests but does not run them by itself, so register the shutdown explicitly if you want closers on Ctrl+C:

Go
iris.RegisterOnInterrupt(app.Shutdown)

Because the server’s own shutdown is prepended to the closer list, it releases Listen while the remaining closers are still running. Keep main alive until they finish (for example with a channel the last closer closes) if their work must complete before the process exits.

Go
Deferrables(
    func() { worker.Stop() },
    func() error { return cache.Flush() },
    logFile, // io.Closer
)

Services and the Initializer Hook

Services(deps ...any) feeds the dependency injection container that later constructs your middlewares and controllers. Each argument is routed by its shape:

Argument shapeWhat the builder does
nilSkipped.
func() or func() errorAdded as a shutdown closer only; a function with no outputs cannot be a constructor.
Value with Close() or Close() errorRegistered as a dependency and as a shutdown closer.
Value implementing iris.WorkerRegistered as a dependency, started after the initializers; at shutdown it is stopped and waited for before its own Close, in registration order with the other closers.
iris.ConfiguratorApplied to the application immediately; not a dependency.
Anything elseRegistered as a dependency: values by their type, functions as constructors.

Constructor functions are called lazily with their parameters resolved from previously registered dependencies, which is why order matters inside a Services call:

Go
Services(
    store,          // *NoteStore value (has Close: also a closer)
    NewNoteService, // func(*NoteStore) *NoteService
)

If registration fails, Services panics with the underlying error. That is deliberate: a broken dependency graph should stop the process at startup, not surface as confusing behavior under traffic.

Services also get a startup hook. Any registered singleton (a value you passed or a constructor’s output) that implements iris.Initializer runs exactly once inside Build(), after dependency injection completes, in registration order:

Go
type Initializer interface {
    Init(ctx context.Context) error
}

Use it for cache warm-up, connection pinging or background pool setup. An Init error aborts the build, making the interface the startup counterpart of Deferrables.

Long-running work has its own interface. A scheduler, a queue consumer or a cache refresher passed to Services as an iris.Worker runs inside the same chain, instead of in a goroutine started next to it:

Go
type Worker interface {
    Run(ctx context.Context) error
    Stop(ctx context.Context) error
}

Run starts in its own goroutine after every Initializer has returned, with a context that is cancelled at shutdown. An error it returns before that is logged with the worker’s type name and nothing else: a failed cache refresher should not take down a server that can still answer every request which does not need it.

Stop runs once, in registration order with the closers, and Shutdown then waits for Run to return, so a worker is really finished when Shutdown reports success rather than still draining while the process exits from under it. A value that is both a Worker and a closer is stopped, waited for, and only then closed, because closing the connection a running Run is still reading from is the crash that ordering avoids.

Both halves are bounded. They get the Shutdown context when that one carries a deadline, and a ten second budget when it does not, so a hanging Stop cannot hold the process open. That budget is per worker, not per shutdown, because the closers run one after another: pass a deadline to app.Shutdown to bound the whole chain instead.

A Worker is a dependency like any other value, so the same scheduler can be injected into a controller that wants to trigger it. Only a Worker passed to Services as a value is started; one returned by a constructor is injected but never run, so hand the scheduler itself to Services, not its constructor.

RouterMiddlewares and Middlewares

Two of the built-ins are installed by NewBuilder itself, before the first step runs, because Prefix copies the parent’s context options and macros into the child router the moment it is called:

  • the macro (path parameter) error handler, errors.DefaultPathParameterTypeErrorHandler, so a request like /notes/abc against {id:int} renders 400 INVALID_ARGUMENT (“invalid path parameter”) instead of a bare 404, and
  • the context error handler, errors.DefaultContextErrorHandler, so failures inside response writers such as ctx.JSON render structured errors.

These two methods separate the middleware layers, and the first call to either performs the builder’s one-time setup, guarded by a sync.Once. That setup installs the pre-routing chain, in execution order:

  1. Your Errors options handler, if you provided one. It goes first so everything after it, the CORS rejection included, is written through the collector, renderer and id generator you configured.
  2. The access log, when LogRequests asked for one. It logs when ctx.Next returns rather than from a defer, so it has to sit outside recovery: otherwise a panicking request is logged nowhere.
  3. The recovery middleware with errors.Internal.LogErr as its error handler: a panic becomes a logged, structured 500 response. Skipped when a handler named iris.recovery or iris.errors.recover is already registered.
  4. CORS, below recovery so a panic inside its error handler is caught, and below the access log so a rejection is recorded.
  5. The Server: Iris header on every response, plus X-Developer when Health received a developer string.
  6. compress.Safe, when Compression(true) was chosen. Safe rather than Handler: an unbounded decompressor turns a one-megabyte gzip body into a gigabyte of memory.
  7. errors.Handler from rest/errors, the last of the built-in router middleware and after compression, so what it writes is compressed like any other body. A failure status that reaches the client with nothing written, from a route or from any middleware registered after this point, answers the JSON envelope of Chapter 9 instead of an empty body: ctx.StopWithStatus(403), ctx.NotFound(), the 401 of the jwt middleware.
  8. The rate-limit exceed handler, which is a binding rather than a middleware in the chain: responses from middleware/rate render as the errors package’s JSON instead of empty 429s.

When the application is built, the builder also registers errors.Handler as the 404 and the 405 handler, so an unknown path answers NOT_FOUND as JSON and not the text Not Found. It fills those two slots only when they are empty: a HandleNoRoute or HandleNoMethod of your own, registered from a Configurator passed to NewBuilder, wins. Two rejections are answered before errors.Handler runs and keep their bare status: one from a handler a Configurator registered with UseRouter, and the 415 of the compression middleware for a request body that is not what its Content-Encoding says.

One consequence of that order is worth stating plainly: a panic inside the access log or inside your own Errors options handler is above recovery, so it is not recovered. Both are framework-adjacent code you register once; keep them simple.

After the built-ins, your own entries register:

  • RouterMiddlewares(...) uses UseRouter: the handlers run before routing, for every request that reaches the builder’s router, including requests that end in 404. Use it for request IDs, security headers, metrics and global rate limiting.
  • Middlewares(...) uses Use: the handlers run only for matched routes. Use it for authentication and anything that assumes a real route.

Both accept plain iris.Handler values as well as constructor functions whose parameters resolve from registered services:

Go
RouterMiddlewares(func(ctx iris.Context) {
    ctx.SetHeader("X-Frame-Options", "DENY")
    ctx.Next()
}).
Middlewares(func(auth *AuthService) iris.Handler {
    return func(ctx iris.Context) {
        user, err := auth.UserFromRequest(ctx)
        if err != nil {
            errors.Unauthenticated.Err(ctx, err)
            return
        }
        ctx.Values().Set("user", user)
        ctx.Next()
    }
})

Middlewares calls RouterMiddlewares() internally before doing its own work, so the built-ins install exactly once regardless of which method you call first or how many times.

API

API(prefixPath string, controllers ...any) mounts a route group under the builder’s prefix by delegating to router.Router(prefixPath, ...). A controller is anything with a Routing(r iris.Router) method; you usually pass its constructor and let injection supply the dependencies, as the complete example below shows. Plain handlers listed before a controller act as middleware for that group only:

Go
API("/notes", NewNotesAPI).
API("/admin", basicauth.Default(map[string]string{
    "admin": adminPassword,
}), NewAdminAPI).

Call API as many times as you have groups. Controllers, grouping and the helper controllers (iris.Middleware, iris.Dependencies and friends) are the subject of Chapter 4.

Build

Build() finalizes the application: it runs the iris.Initializer hooks of your services (once), then compiles the router and middleware stack. On any misconfiguration it panics; like the Services panic, this is intentional so that a broken application never starts serving.

It returns the *iris.Application, which you can serve or test:

Go
app := builder.Build()

// Plain HTTP.
app.Listen(":8080")

// Or with explicit server options (TLS shown; see Chapter 19).
app.Run(iris.ServerOptions{
    Addr:     ":443",
    CertFile: "cert.pem",
    KeyFile:  "key.pem",
})

A Complete Application

The following program is a small but production-shaped notes API. It is a single file here for readability; the layering (config, domain, storage, controllers, wiring) is exactly what you would split into packages as the application grows. The code compiles and runs as shown.

Go
package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "sync"

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

// Config carries the deployment-specific values. iris.BaseConfiguration
// brings Host, Port, AllowOrigin, EnableCompression, RequestLog and the
// framework options, so cfg.Addr() feeds Listen and there is no Addr field
// of our own to keep in step. Environment variables keep the example short;
// Chapter 11 covers configuration in depth, including loading this same
// struct from a YAML document with iris.LoadConfiguration.
type Config struct {
    iris.BaseConfiguration `yaml:",inline"`

    Env       string `yaml:"Env"`
    Developer string `yaml:"Developer"`
    AdminUser string `yaml:"AdminUser"`
    AdminPass string `yaml:"-"`
}

func loadConfig() (Config, error) {
    cfg := Config{
        BaseConfiguration: iris.BaseConfiguration{
            Port:              8080,
            AllowOrigin:       "none",
            EnableCompression: true,
        },
        Env:       "development",
        Developer: "notes-team",
        AdminUser: "admin",
        AdminPass: os.Getenv("ADMIN_PASSWORD"),
    }

    // HOST, PORT, HTTP_PLATFORM_PORT and ALLOW_ORIGIN override the
    // defaults above. iris.LoadConfiguration calls this for you when the
    // configuration arrives as a YAML document.
    cfg.BindEnv()

    if cfg.AdminPass == "" {
        return cfg, fmt.Errorf("ADMIN_PASSWORD is required")
    }

    return cfg, nil
}

// ErrNoteNotFound is a sentinel error; the builder's Errors step maps
// it to a 404 response.
var ErrNoteNotFound = fmt.Errorf("note not found")

type Note struct {
    ID   int    `json:"id"`
    Text string `json:"text"`
}

// NoteInput is the request payload. Implementing Validate makes
// ctx.BindJSON run it automatically after decoding.
type NoteInput struct {
    Text string `json:"text"`
}

func (in NoteInput) Validate() error {
    if in.Text == "" {
        return fmt.Errorf("text: must not be empty")
    }
    return nil
}

The storage type demonstrates two builder behaviors at once: it has a Close method, so Services registers it as a shutdown closer automatically, and it implements iris.Initializer, so Init runs once inside Build().

Go
type NoteStore struct {
    mu    sync.Mutex
    seq   int
    notes []Note
}

func NewNoteStore() *NoteStore { return &NoteStore{} }

func (s *NoteStore) Init(ctx context.Context) error {
    log.Println("notes: store ready") // ping a real database here.
    return nil
}

func (s *NoteStore) Close() error {
    log.Println("notes: store closed")
    return nil
}

func (s *NoteStore) List(ctx context.Context) ([]Note, error) {
    s.mu.Lock()
    defer s.mu.Unlock()
    return append([]Note(nil), s.notes...), nil
}

func (s *NoteStore) Create(ctx context.Context, in NoteInput) (Note, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    s.seq++
    note := Note{ID: s.seq, Text: in.Text}
    s.notes = append(s.notes, note)
    return note, nil
}

func (s *NoteStore) Get(ctx context.Context, id int) (Note, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    for _, note := range s.notes {
        if note.ID == id {
            return note, nil
        }
    }
    return Note{}, fmt.Errorf("%w: %d", ErrNoteNotFound, id)
}

Two controllers: the public notes API and an admin API mounted behind basic authentication. (Basic authentication is the simplest HTTP login scheme: the client repeats a username and password in the Authorization header of every request. Served over TLS, it is a reasonable fit for small internal admin endpoints.)

Go
type NotesAPI struct {
    store *NoteStore
}

func NewNotesAPI(store *NoteStore) *NotesAPI {
    return &NotesAPI{store: store}
}

func (api *NotesAPI) Routing(r iris.Router) {
    r.Get("/", api.list)
    r.Post("/", api.create)
    r.Get("/{id:int}", api.get)
}

func (api *NotesAPI) list(ctx iris.Context) {
    notes, err := api.store.List(ctx)
    ctx.List(notes, err) // an empty store renders [], never null.
}

func (api *NotesAPI) create(ctx iris.Context) {
    input, ok := ctx.BindJSON[NoteInput]()
    if !ok {
        return // 400 already written: bad JSON or failed Validate.
    }

    note, err := api.store.Create(ctx, input)
    ctx.Created(note, err)
}

func (api *NotesAPI) get(ctx iris.Context) {
    note, err := api.store.Get(ctx, ctx.Params().GetInt("id"))
    ctx.OK(note, err) // ErrNoteNotFound renders as 404.
}

type AdminAPI struct {
    store *NoteStore
}

func NewAdminAPI(store *NoteStore) *AdminAPI {
    return &AdminAPI{store: store}
}

func (api *AdminAPI) Routing(r iris.Router) {
    r.Get("/stats", api.stats)
}

func (api *AdminAPI) stats(ctx iris.Context) {
    notes, err := api.store.List(ctx)
    if err != nil {
        errors.HandleError(ctx, err)
        return
    }
    ctx.JSON(iris.Map{"notes": len(notes)})
}

Finally the wiring. newApp takes its storage as a parameter, so tests can build the identical application around any store they like:

Go
// newApp assembles the application. Storage arrives as a parameter:
// main passes the real store, tests pass whatever they need.
func newApp(cfg Config, store *NoteStore) *iris.Application {
    return iris.NewBuilder(cfg.Iris).
        Prefix("/api").
        AllowOrigin(cfg.AllowOrigin).
        Compression(cfg.EnableCompression).
        LogRequests(cfg.Env == "development").
        Health(true, cfg.Env, cfg.Developer).
        Errors(errors.NewOptions().
            MapErrors(errors.NotFound, ErrNoteNotFound)).
        Deferrables(func() { log.Println("notes: goodbye") }).
        Services(store).
        RouterMiddlewares().
        Middlewares().
        API("/notes", NewNotesAPI).
        API("/admin", basicauth.Default(map[string]string{
            cfg.AdminUser: cfg.AdminPass,
        }), NewAdminAPI).
        Build()
}

func main() {
    cfg, err := loadConfig()
    if err != nil {
        log.Fatal(err)
    }

    app := newApp(cfg, NewNoteStore())
    app.Listen(cfg.Addr())
}

Run it and exercise the behaviors the builder gave you for free:

Shell
ADMIN_PASSWORD=change-me go run .

# 201 {"id":1,"text":"hello"}; an empty text renders
# 400 INVALID_ARGUMENT because ctx.BindJSON runs NoteInput.Validate.
curl -X POST localhost:8080/api/notes \
  -H 'Content-Type: application/json' -d '{"text":"hello"}'

# 404 NOT_FOUND: the central error map at work.
curl localhost:8080/api/notes/42

# 400 INVALID_ARGUMENT: malformed {id:int} path parameter.
curl localhost:8080/api/notes/abc

# 401, then 200 {"notes":1}.
curl localhost:8080/api/admin/stats
curl -u admin:change-me localhost:8080/api/admin/stats

# 200 OK: health lives at the root, outside /api.
curl localhost:8080/health

Testing the Built Application

Build() returns a fully assembled *iris.Application, and httptest.New drives it in memory: real router, real middleware, real error map, no network. Because newApp accepts its dependencies, a test constructs the exact production application around a test store:

Go
package main

import (
    "testing"

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

func testConfig() Config {
    return Config{
        BaseConfiguration: iris.BaseConfiguration{AllowOrigin: "none"},
        Env:               "test",
        Developer:         "tests",
        AdminUser:         "admin",
        AdminPass:         "test-password",
    }
}

func TestNotesAPI(t *testing.T) {
    app := newApp(testConfig(), NewNoteStore())
    e := httptest.New(t, app)

    e.POST("/api/notes").WithJSON(NoteInput{Text: "hello"}).
        Expect().Status(httptest.StatusCreated).
        JSON().Object().HasValue("id", 1)

    e.GET("/api/notes/42").Expect().Status(httptest.StatusNotFound)

    e.GET("/api/admin/stats").WithBasicAuth("admin", "test-password").
        Expect().Status(httptest.StatusOK).
        JSON().Object().HasValue("notes", 1)
}

Chapter 16 expands on this pattern.

When to Use iris.New Instead

iris.New() creates a bare application: no recovery, no error handling, nothing installed for you. It remains the right tool for small programs and for setups where you want to control every registration yourself:

Go
app := iris.New()
app.UseRouter(recovery.Handler)
app.Get("/", func(ctx iris.Context) {
    ctx.JSON(iris.Map{"message": "hello"})
})
app.Listen(":8080")

For quick prototypes there is also iris.Default(), which is itself implemented as a builder chain, a good indication of how idiomatic the builder is:

Go
iris.NewBuilder().
    NoPrefix().
    AllowOrigin("*").
    Compression(true).
    LogRequests(false).
    NoHealth().
    Services().
    RouterMiddlewares(requestid.New()).
    Middlewares()

plus debug-level logging. Note the missing Build(): iris.Default() hands back an application that has not been built, so routes registered after the call are still served, exactly as with iris.New(). For anything you intend to deploy, start from iris.NewBuilder().

Summary

  • iris.NewBuilder() assembles a production-ready application in one expression; each step returns the next step’s interface, so the order is checked by the compiler.
  • The chain is Prefix/NoPrefix, AllowOrigin ("none" disables) or Cors for a fully configured CORS value, Compression, LogRequests/LogRequestsWith, Health/NoHealth (always GET /health at the root), then Errors, Deferrables and Services, then RouterMiddlewares/Middlewares, then API groups and Build.
  • The first middleware call installs the built-ins once: path parameter error handling, your error options, recovery, the context error handler, Server/X-Developer headers, compression, errors.Handler and the rate-limit exceed handler. An unknown path, a wrong method and a failure status with no body all answer the rest/errors JSON envelope. Empty RouterMiddlewares().Middlewares() calls exist because the type chain must pass through them.
  • Services routes arguments by shape: zero-output functions become closers, values with Close become dependency plus closer, configurators apply immediately, nil is skipped, and registration failures panic at startup.
  • Services implementing iris.Initializer run Init(ctx) once inside Build(), after injection, in registration order; an error aborts the build. Deferrables is the mirror image at shutdown.
  • Build() panics on misconfiguration by design, and returns the *iris.Application for Listen, Run or httptest.

Further Reading

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