Chapter 22

Real-World Examples

Two complete applications, file by file: Shortl built on iris.New and Stockroom built on iris.NewBuilder, and when to reach for each of the two shapes.

Every previous chapter isolated one topic. This chapter does the opposite: it presents two complete applications, listed file by file, with nothing elided. The first (a URL shortener) uses iris.New() and two source files, the lightweight shape for small programs. The second (an inventory service for warehouses and stock) uses the full recommended stack: iris.NewBuilder, dependency injection, controllers, a central error map, a protected admin surface, startup and shutdown hooks, and an httptest suite. Both programs compile and their tests pass exactly as printed.

Background

How to read a complete program listing

Each code block below belongs to one real file, named in the text right before it. Copy the blocks of an application into files with those names, run the setup commands shown at its start, and you have the working program. The same files were built and tested with Go 1.27 against Iris v14 before being pasted here, and the test output in this chapter is their real output.

Read a listing the way you would review a colleague’s pull request, not the way you read a novel. Start at the file tree to take in the shape, then read the innermost package first (the one that imports the least) and finish with the file that wires everything together. For Stockroom that order is inventory, then api, then main.go, which is exactly the order the chapter prints them in.

What separates a toy from a production-shaped service

A toy program answers requests. A production-shaped service also answers the questions that arrive later: how do settings differ between your laptop and the server, who may call the dangerous endpoints, do all failures come out in one consistent shape, and how do you prove any of it still works after a change. Four ingredients cover those questions, and all four appear in Stockroom:

  • Configuration: settings live outside the code, in a file or in the process environment, so the same compiled binary runs in development and in production; secrets never sit in committed files.
  • Guards: small checks that run before handlers do, so a rule such as “sealed warehouses are read-only” is enforced in one place instead of being repeated inside every handler.
  • One error map: every failure the business logic can produce is translated to an HTTP status code exactly once, so clients see the same response shape for the same problem everywhere.
  • Tests: an in-memory suite proves the routes, guards and error map behave, without a running server or a real database.

Why two shapes

The two applications deliberately share nothing, because program size honestly changes what good structure looks like. Shortl shows how little ceremony a small Iris program needs; any layering there would be ceremony for its own sake. Stockroom shows where every piece of the recommended architecture goes when the program is meant to grow. Neither is a lesser version of the other; the closing section lists the concrete signals for choosing, and growing from the first shape into the second is mechanical rather than a rewrite.

Shortl: A URL Shortener

Shortl stores a target URL under a random six-letter code and redirects visitors of /{code} to it, counting hits. Three routes, two files:

MethodPathPurpose
POST/api/linkscreate a short link
GET/api/links/{code}link statistics
GET/{code}redirect to the target
shortl/
├── go.mod
├── main.go        # routes and validation
├── store.go       # in-memory store behind a mutex
└── main_test.go   # httptest suite

Setup:

Shell
mkdir shortl && cd shortl
go mod init shortl
go get github.com/kataras/iris/v14@latest

Run go mod tidy after pasting the files (the test file uses one more module, the httpexpect library that httptest builds on).

The Store

A Go server handles many requests at once, each on its own goroutine, a lightweight thread managed by the Go runtime. Two goroutines writing to the same map at the same moment corrupt it, so shared data needs a lock. A mutex (mutual-exclusion lock) admits one goroutine at a time; the read-write variant used here lets any number of readers in simultaneously while a writer waits for exclusive access.

The store is ordinary Go: a map guarded by a read-write mutex, a random code generator, and a retry loop for the rare collision. Codes are built from letters only: that is not an aesthetic choice, it is what lets the route macro type-check them, as you will see in main.go.

store.go:

Go
// store.go
package main

import (
    "errors"
    "math/rand/v2"
    "sync"
    "time"
)

// Link is a stored short link.
type Link struct {
    Code      string    `json:"code"`
    URL       string    `json:"url"`
    Hits      int64     `json:"hits"`
    CreatedAt time.Time `json:"created_at"`
}

// ErrCodeSpaceExhausted is returned when no free code can be found.
var ErrCodeSpaceExhausted = errors.New("shortl: code space exhausted")

// codeAlphabet holds letters only, so every generated code matches the
// {code:alphabetical} route macro. Ambiguous glyphs (l, I, O) are left out.
const (
    codeAlphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ"
    codeLength   = 6
)

// Store is an in-memory link store guarded by a read-write mutex.
type Store struct {
    mu    sync.RWMutex
    links map[string]*Link
}

func NewStore() *Store {
    return &Store{links: make(map[string]*Link)}
}

// Save stores the target URL under a fresh random code.
func (s *Store) Save(url string) (Link, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    for range 100 {
        code := randomCode()
        if _, taken := s.links[code]; taken {
            continue
        }

        link := &Link{
            Code:      code,
            URL:       url,
            CreatedAt: time.Now().UTC(),
        }
        s.links[code] = link
        return *link, nil
    }
    return Link{}, ErrCodeSpaceExhausted
}

// Resolve returns the target URL of a code and counts the hit.
func (s *Store) Resolve(code string) (string, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()

    link, ok := s.links[code]
    if !ok {
        return "", false
    }
    link.Hits++
    return link.URL, true
}

// Stats returns a snapshot of one link.
func (s *Store) Stats(code string) (Link, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    link, ok := s.links[code]
    if !ok {
        return Link{}, false
    }
    return *link, true
}

func randomCode() string {
    b := make([]byte, codeLength)
    for i := range b {
        b[i] = codeAlphabet[rand.IntN(len(codeAlphabet))]
    }
    return string(b)
}

The Application

The whole HTTP surface fits in one file. Three details carry it.

First, the input type implements Validate() error, which makes ctx.BindJSON run it immediately after decoding; the handler never calls it. The URL check is a custom Func chained onto the validation.String builder: return an empty string for a valid value, a reason for an invalid one, and the reason lands in the structured 400 payload with its field name.

Second, the catch-all route is typed: /{code:alphabetical} matches letters (and spaces) only, per the macro’s ^[a-zA-Z ]+$ rule. Requests like /favicon.ico or /robots.txt never reach the handler: the router answers 404 on its own. The static /api prefix always wins over the parameterized root route, because static segments sort before parameters (Chapter 4).

Third, iris.New(iris.Options{Name: "shortl"}) passes a partial options literal: it merges with the defaults, configuring only what it sets.

main.go:

Go
// Shortl is a URL shortener: the smallest complete Iris program that is
// still honest about input validation and error responses.
package main

import (
    "net/url"

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

// ShortenInput is the create-link payload. Implementing Validate makes
// ctx.BindJSON run it right after decoding: handlers never call it.
type ShortenInput struct {
    URL string `json:"url"`
}

func (in ShortenInput) Validate() error {
    return validation.Join(
        validation.String("url", in.URL).NotEmpty().
            Func(absoluteHTTPURL),
    )
}

// absoluteHTTPURL is a custom validation.Func: empty string means valid.
func absoluteHTTPURL(s string) string {
    u, err := url.Parse(s)
    if err != nil || u.Host == "" ||
        (u.Scheme != "http" && u.Scheme != "https") {
        return "must be an absolute http(s) URL"
    }
    return ""
}

// newApp wires the routes around a store. Tests call it too.
func newApp(store *Store) *iris.Application {
    app := iris.New(iris.Options{Name: "shortl"})

    api := app.Router("/api")

    api.Post("/links", func(ctx iris.Context) {
        input, ok := ctx.BindJSON[ShortenInput]()
        if !ok {
            return // 400 already written: bad JSON or failed Validate.
        }

        link, err := store.Save(input.URL)
        ctx.Created(link, err) // 201 with the full Link as JSON.
    })

    api.Get("/links/{code:alphabetical}", func(ctx iris.Context) {
        link, ok := store.Stats(ctx.Params().GetString("code"))
        if !ok {
            errors.NotFound.Message(ctx, "unknown short code")
            return
        }
        ctx.OK(link, nil)
    })

    // The typed macro keeps this catch-all narrow: only letter codes
    // reach the handler, everything else is a plain 404.
    app.Get("/{code:alphabetical}", func(ctx iris.Context) {
        target, ok := store.Resolve(ctx.Params().GetString("code"))
        if !ok {
            errors.NotFound.Message(ctx, "unknown short code")
            return
        }
        ctx.Redirect(target, iris.StatusMovedPermanently)
    })

    return app
}

func main() {
    app := newApp(NewStore())
    app.Listen(":8080")
}

Even without the builder, the rest and rest/errors helpers work at full strength: ctx.BindJSON renders its own field-level 400, and the errors.NotFound.Message sender writes the canonical JSON error shape (Chapter 9).

The Tests

The suite drives the real application in memory through httptest.New. One technique is worth pausing on: the test client follows redirects by default, which would turn the redirect assertion into a request against the target. WithRedirectPolicy(httpexpect.DontFollowRedirects) (from the httpexpect library that httptest re-exposes) freezes the response so the test can inspect the 301 and its Location header.

main_test.go:

Go
// main_test.go
package main

import (
    "testing"

    "github.com/iris-contrib/httpexpect/v2"

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

func TestShortenResolveStats(t *testing.T) {
    e := httptest.New(t, newApp(NewStore()))

    // Create a link; the response is the stored Link.
    obj := e.POST("/api/links").
        WithJSON(ShortenInput{URL: "https://iris-go.com/docs"}).
        Expect().Status(httptest.StatusCreated).JSON().Object()
    obj.HasValue("url", "https://iris-go.com/docs")
    code := obj.Value("code").String().NotEmpty().Raw()

    // The redirect: do not follow it, inspect it.
    e.GET("/"+code).
        WithRedirectPolicy(httpexpect.DontFollowRedirects).
        Expect().Status(httptest.StatusMovedPermanently).
        Header("Location").IsEqual("https://iris-go.com/docs")

    // The hit above was counted.
    e.GET("/api/links/"+code).
        Expect().Status(httptest.StatusOK).
        JSON().Object().HasValue("hits", 1)
}

func TestValidationAndNotFound(t *testing.T) {
    e := httptest.New(t, newApp(NewStore()))

    // Validate rejects a relative URL with a field-level payload.
    e.POST("/api/links").
        WithJSON(ShortenInput{URL: "not-a-url"}).
        Expect().Status(httptest.StatusBadRequest).
        JSON().Object().Value("validation").Array().NotEmpty()

    // Unknown code: matched by the macro, missing in the store.
    e.GET("/nosuch").
        WithRedirectPolicy(httpexpect.DontFollowRedirects).
        Expect().Status(httptest.StatusNotFound)
}

Run It

Shell
go mod tidy && go test ./...   # ok  shortl
go run .
Shell
# 201 {"code":"XkfPam","url":"https://iris-go.com","hits":0,...}
curl -X POST localhost:8080/api/links \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://iris-go.com"}'

# 301 -> https://iris-go.com
curl -i localhost:8080/XkfPam

# 400 with a field-level validation payload
curl -X POST localhost:8080/api/links \
  -H 'Content-Type: application/json' -d '{"url":"not-a-url"}'

Stockroom: An Inventory API

Stockroom manages warehouses and the stock items inside them. One business rule shapes the whole service: a sealed warehouse is read-only. Nothing can be added, withdrawn or deleted until an administrator unseals it. Unsealing is deliberately absent from the public API. A second rule guards quantities: a withdrawal that would drive an item’s balance below zero is refused.

MethodPathPurposeProtection
GET/api/warehouseslist warehouses—
POST/api/warehousescreate a warehousevalidation
GET/api/warehouses/{id}one warehouse—
DELETE/api/warehouses/{id}delete (refused when sealed)—
POST/api/warehouses/{id}/sealseal a warehouse—
GET/api/warehouses/{wid}/itemslist items, paginatedwarehouse exists
POST/api/warehouses/{wid}/itemsadd an itemwarehouse unsealed
GET/api/warehouses/{wid}/items/{id}one itemwarehouse exists
DELETE/api/warehouses/{wid}/items/{id}remove an itemwarehouse unsealed
POST/api/warehouses/{wid}/items/{id}/withdrawremove unitswarehouse unsealed
GET/api/admin/statstotalsbasic auth
POST/api/admin/warehouses/{id}/unsealforce-unsealbasic auth
GET/healthhealth check—

The layout keeps one direction of dependency: api depends on inventory; inventory knows nothing about HTTP.

stockroom/
├── go.mod
├── config.yml             # committed settings; secrets stay in the env
├── main.go                # configuration, wiring, startup
├── main_test.go           # HTTP tests with fake repositories
├── api/
│   ├── warehouses.go      # WarehousesAPI: CRUD, seal, nested router
│   ├── items.go           # ItemsAPI: guards, CRUD, withdraw, pagination
│   └── admin.go           # AdminAPI: stats, force-unseal
└── inventory/
    ├── inventory.go       # entities, inputs, sentinel errors
    ├── repository.go      # the storage interface (the test seam)
    ├── memstore.go        # in-memory implementation
    └── service.go         # business rules and warmup

Setup:

Shell
mkdir stockroom && cd stockroom
go mod init stockroom
go get github.com/kataras/iris/v14@latest
go get gopkg.in/yaml.v3@latest

Run go mod tidy after pasting the files below.

The Domain

Two terms carry this section. The domain is the part of a program that speaks the business language (warehouses, items, quantities) and knows nothing about transport concerns such as HTTP. A sentinel error is a named, package-level error value created once with errors.New; failing code returns one of the fixed set instead of inventing a new message on the spot, so callers recognize each failure by identity.

The inventory package defines the entities, the request inputs and three sentinel errors. The sentinels are the domain’s whole vocabulary of failure, with no status codes here. The HTTP layer maps them exactly once, in main.go. Each input type implements Validate() error with the rest/errors/validation builders, so ctx.BindJSON rejects bad payloads with field-level detail before any handler logic runs. (UUIDs come from the standard library’s uuid package, part of Go since 1.27, so nothing beyond Iris and the YAML reader needs fetching.)

inventory/inventory.go:

Go
// Package inventory is the domain of the Stockroom service: entities,
// request inputs and business errors. It imports no HTTP types.
package inventory

import (
    "errors"
    "time"
    "uuid"

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

// Sentinel errors: the service and the store return these, the HTTP
// layer maps them to status codes exactly once, at startup.
var (
    ErrNotFound          = errors.New("inventory: not found")
    ErrSealed            = errors.New("inventory: warehouse is sealed")
    ErrInsufficientStock = errors.New("inventory: insufficient stock")
)

// Warehouse holds stock items. A sealed warehouse is read-only: no new
// items, no withdrawals, no deletion — until an admin unseals it.
type Warehouse struct {
    ID        uuid.UUID `json:"id"`
    Name      string    `json:"name"`
    Region    string    `json:"region"`
    Sealed    bool      `json:"sealed"`
    CreatedAt time.Time `json:"created_at"`
}

// StockItem is one SKU inside one warehouse.
type StockItem struct {
    ID          uuid.UUID `json:"id"`
    WarehouseID uuid.UUID `json:"warehouse_id"`
    SKU         string    `json:"sku"`
    Name        string    `json:"name"`
    Quantity    int       `json:"quantity"`
    CreatedAt   time.Time `json:"created_at"`
}

// WarehouseInput is the create payload. Implementing Validate makes
// ctx.BindJSON run it after decoding.
type WarehouseInput struct {
    Name   string `json:"name"`
    Region string `json:"region"`
}

func (in WarehouseInput) Validate() error {
    return validation.Join(
        validation.String("name", in.Name).NotEmpty().Length(1, 100),
        validation.String("region", in.Region).NotEmpty(),
    )
}

// ItemInput is the create payload for a stock item.
type ItemInput struct {
    SKU      string `json:"sku"`
    Name     string `json:"name"`
    Quantity int    `json:"quantity"`
}

func (in ItemInput) Validate() error {
    return validation.Join(
        validation.String("sku", in.SKU).NotEmpty().Length(3, 32),
        validation.String("name", in.Name).NotEmpty(),
        validation.Number("quantity", in.Quantity).Positive(),
    )
}

// WithdrawInput asks to remove units from a stock item.
type WithdrawInput struct {
    Quantity int `json:"quantity"`
}

func (in WithdrawInput) Validate() error {
    return validation.Join(
        validation.Number("quantity", in.Quantity).Positive(),
    )
}

// ItemFilter narrows item listings; it arrives as an optional JSON body
// next to the ?page=&size= query (see ctx.Paginated).
type ItemFilter struct {
    SKU string `json:"sku"`
}

// Stats is the admin dashboard summary. Units is the sum of all
// item quantities.
type Stats struct {
    Warehouses int64 `json:"warehouses"`
    Items      int64 `json:"items"`
    Units      int64 `json:"units"`
}

The Storage Seam

A Go interface names a set of methods without naming who implements them; any type with those methods satisfies it automatically. That indirection creates a seam: a line along which one implementation swaps for another (a real database in production, an in-memory fake in tests) without the calling code changing. Two more words below deserve definitions: a race is two concurrent requests interleaving in an unlucky order (both read a balance of 5, both withdraw 5), leaving a state neither intended; atomic means a check and its write happen as one indivisible step, so no other request can slip between them.

The service talks to storage through an interface, the seam that makes the application testable. Every method takes a context.Context first; iris.Context implements it, so request cancellation reaches the store with no extra code. One method deserves attention: AdjustItemQuantity is atomic and owns the non-negative balance invariant, because a check-then-write across two calls would race.

inventory/repository.go:

Go
// inventory/repository.go
package inventory

import (
    "context"
    "uuid"

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

// Repository is the storage seam: the service depends on this interface,
// production wires a database implementation, tests wire MemStore or a
// stub. Every method takes a context.Context first — iris.Context
// implements it, so request cancellation reaches the store for free.
type Repository interface {
    CreateWarehouse(ctx context.Context, w Warehouse) error
    Warehouses(ctx context.Context) ([]Warehouse, error)
    // Warehouse returns ErrNotFound for an unknown id.
    Warehouse(ctx context.Context, id uuid.UUID) (Warehouse, error)
    // SetWarehouseSealed reports false when the flag did not change.
    SetWarehouseSealed(ctx context.Context, id uuid.UUID,
        sealed bool) (bool, error)
    DeleteWarehouse(ctx context.Context, id uuid.UUID) (bool, error)

    CreateItem(ctx context.Context, item StockItem) error
    Items(ctx context.Context, warehouseID uuid.UUID,
        opts iris.PageOptions, filter ItemFilter) ([]StockItem, int64, error)
    // Item returns ErrNotFound for an unknown pair.
    Item(ctx context.Context, warehouseID, id uuid.UUID) (StockItem, error)
    // AdjustItemQuantity atomically adds delta to the quantity. It
    // returns ErrNotFound for an unknown item and ErrInsufficientStock
    // when the result would go below zero.
    AdjustItemQuantity(ctx context.Context, warehouseID, id uuid.UUID,
        delta int) (StockItem, error)
    DeleteItem(ctx context.Context, warehouseID, id uuid.UUID) (bool, error)

    Counts(ctx context.Context) (warehouses, items, units int64, err error)
}

The in-memory implementation is a mutex and two maps: development storage today, the test suite’s honest fake forever. A database implementation replaces it later by changing one Services entry.

inventory/memstore.go:

Go
// inventory/memstore.go
package inventory

import (
    "context"
    "sort"
    "strings"
    "sync"
    "uuid"

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

// MemStore is an in-memory Repository: development storage and the test
// suite's fake. Swapping it for a database changes one Services entry.
type MemStore struct {
    mu         sync.RWMutex
    warehouses map[uuid.UUID]Warehouse
    items      map[uuid.UUID]StockItem
}

var _ Repository = (*MemStore)(nil)

func NewMemStore() *MemStore {
    return &MemStore{
        warehouses: make(map[uuid.UUID]Warehouse),
        items:      make(map[uuid.UUID]StockItem),
    }
}

func (s *MemStore) CreateWarehouse(_ context.Context, w Warehouse) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    s.warehouses[w.ID] = w
    return nil
}

func (s *MemStore) Warehouses(_ context.Context) ([]Warehouse, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    list := make([]Warehouse, 0, len(s.warehouses))
    for _, w := range s.warehouses {
        list = append(list, w)
    }
    sort.Slice(list, func(i, j int) bool {
        return list[i].CreatedAt.Before(list[j].CreatedAt)
    })
    return list, nil
}

func (s *MemStore) Warehouse(_ context.Context,
    id uuid.UUID) (Warehouse, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    w, ok := s.warehouses[id]
    if !ok {
        return Warehouse{}, ErrNotFound
    }
    return w, nil
}

func (s *MemStore) SetWarehouseSealed(_ context.Context, id uuid.UUID,
    sealed bool) (bool, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    w, ok := s.warehouses[id]
    if !ok {
        return false, ErrNotFound
    }
    if w.Sealed == sealed {
        return false, nil
    }
    w.Sealed = sealed
    s.warehouses[id] = w
    return true, nil
}

func (s *MemStore) DeleteWarehouse(_ context.Context,
    id uuid.UUID) (bool, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, ok := s.warehouses[id]; !ok {
        return false, nil
    }

    delete(s.warehouses, id)
    for itemID, item := range s.items {
        if item.WarehouseID == id {
            delete(s.items, itemID)
        }
    }
    return true, nil
}

func (s *MemStore) CreateItem(_ context.Context, item StockItem) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    s.items[item.ID] = item
    return nil
}

func (s *MemStore) Items(_ context.Context, warehouseID uuid.UUID,
    opts iris.PageOptions, filter ItemFilter) ([]StockItem, int64, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    matched := make([]StockItem, 0)
    for _, item := range s.items {
        if item.WarehouseID != warehouseID {
            continue
        }
        if filter.SKU != "" &&
            !strings.EqualFold(item.SKU, filter.SKU) {
            continue
        }
        matched = append(matched, item)
    }
    sort.Slice(matched, func(i, j int) bool {
        return matched[i].CreatedAt.Before(matched[j].CreatedAt)
    })

    total := int64(len(matched))

    // Apply the page window (a SQL implementation uses LIMIT/OFFSET).
    offset, limit := opts.GetOffset(), opts.GetLimit()
    if offset >= len(matched) {
        return []StockItem{}, total, nil
    }
    end := offset + limit
    if limit == 0 || end > len(matched) {
        end = len(matched)
    }
    return matched[offset:end], total, nil
}

func (s *MemStore) Item(_ context.Context,
    warehouseID, id uuid.UUID) (StockItem, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    item, ok := s.items[id]
    if !ok || item.WarehouseID != warehouseID {
        return StockItem{}, ErrNotFound
    }
    return item, nil
}

func (s *MemStore) AdjustItemQuantity(_ context.Context,
    warehouseID, id uuid.UUID, delta int) (StockItem, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    item, ok := s.items[id]
    if !ok || item.WarehouseID != warehouseID {
        return StockItem{}, ErrNotFound
    }
    if item.Quantity+delta < 0 {
        return StockItem{}, ErrInsufficientStock
    }
    item.Quantity += delta
    s.items[id] = item
    return item, nil
}

func (s *MemStore) DeleteItem(_ context.Context,
    warehouseID, id uuid.UUID) (bool, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    item, ok := s.items[id]
    if !ok || item.WarehouseID != warehouseID {
        return false, nil
    }
    delete(s.items, id)
    return true, nil
}

func (s *MemStore) Counts(_ context.Context) (int64, int64, int64, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    var units int64
    for _, item := range s.items {
        units += int64(item.Quantity)
    }
    return int64(len(s.warehouses)), int64(len(s.items)), units, nil
}

Rules and Warmup

The service owns the business rules. DeleteWarehouse refuses a sealed warehouse; handlers never check the flag for deletion. They forward whatever the service returns. The service also implements iris.Initializer: because it is registered through the builder’s Services step, its Init(ctx) runs exactly once inside Build(), after dependency injection and before the server accepts traffic. Here it proves storage is reachable and logs the counters, the startup mirror of the Deferrables shutdown hooks. A failing Init stops the application from building, which is exactly what you want from a broken database at boot.

inventory/service.go:

Go
// inventory/service.go
package inventory

import (
    "context"
    "fmt"
    "log"
    "time"
    "uuid"

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

// Service holds the business rules. Its constructor declares what it
// needs; the iris dependency container calls it with the registered
// Repository.
type Service struct {
    repo Repository
}

func NewService(repo Repository) *Service {
    return &Service{repo: repo}
}

// Init implements iris.Initializer: it runs once inside Build, after
// dependency injection and before the server accepts traffic. Here it
// proves the storage is reachable and warms the counters.
func (s *Service) Init(ctx context.Context) error {
    warehouses, items, units, err := s.repo.Counts(ctx)
    if err != nil {
        return fmt.Errorf("inventory: warmup: %w", err)
    }

    log.Printf("inventory: ready (%d warehouses, %d items, %d units)",
        warehouses, items, units)
    return nil
}

func (s *Service) CreateWarehouse(ctx context.Context,
    in WarehouseInput) (uuid.UUID, error) {
    w := Warehouse{
        ID:        uuid.New(),
        Name:      in.Name,
        Region:    in.Region,
        CreatedAt: time.Now().UTC(),
    }
    return w.ID, s.repo.CreateWarehouse(ctx, w)
}

func (s *Service) Warehouses(ctx context.Context) ([]Warehouse, error) {
    return s.repo.Warehouses(ctx)
}

func (s *Service) Warehouse(ctx context.Context,
    id uuid.UUID) (Warehouse, error) {
    return s.repo.Warehouse(ctx, id)
}

// Seal freezes a warehouse. Sealing twice is not an error: the false
// result renders as 304 Not Modified.
func (s *Service) Seal(ctx context.Context, id uuid.UUID) (bool, error) {
    return s.repo.SetWarehouseSealed(ctx, id, true)
}

// Unseal reverses Seal; only the admin API exposes it.
func (s *Service) Unseal(ctx context.Context, id uuid.UUID) (bool, error) {
    return s.repo.SetWarehouseSealed(ctx, id, false)
}

// DeleteWarehouse refuses to delete a sealed warehouse; that rule lives
// here, not in a handler.
func (s *Service) DeleteWarehouse(ctx context.Context,
    id uuid.UUID) (bool, error) {
    w, err := s.repo.Warehouse(ctx, id)
    if err != nil {
        return false, err
    }
    if w.Sealed {
        return false, ErrSealed
    }
    return s.repo.DeleteWarehouse(ctx, id)
}

func (s *Service) CreateItem(ctx context.Context, warehouseID uuid.UUID,
    in ItemInput) (uuid.UUID, error) {
    item := StockItem{
        ID:          uuid.New(),
        WarehouseID: warehouseID,
        SKU:         in.SKU,
        Name:        in.Name,
        Quantity:    in.Quantity,
        CreatedAt:   time.Now().UTC(),
    }
    return item.ID, s.repo.CreateItem(ctx, item)
}

func (s *Service) Items(ctx context.Context, warehouseID uuid.UUID,
    opts iris.PageOptions, filter ItemFilter) ([]StockItem, int64, error) {
    return s.repo.Items(ctx, warehouseID, opts, filter)
}

func (s *Service) Item(ctx context.Context,
    warehouseID, id uuid.UUID) (StockItem, error) {
    return s.repo.Item(ctx, warehouseID, id)
}

// Withdraw removes units from a stock item. The repository adjusts
// atomically and returns ErrInsufficientStock when the balance would go
// negative — mapped to 409 Conflict by the central error map.
func (s *Service) Withdraw(ctx context.Context, warehouseID, id uuid.UUID,
    in WithdrawInput) (StockItem, error) {
    return s.repo.AdjustItemQuantity(ctx, warehouseID, id, -in.Quantity)
}

func (s *Service) DeleteItem(ctx context.Context,
    warehouseID, id uuid.UUID) (bool, error) {
    return s.repo.DeleteItem(ctx, warehouseID, id)
}

func (s *Service) Stats(ctx context.Context) (Stats, error) {
    warehouses, items, units, err := s.repo.Counts(ctx)
    return Stats{Warehouses: warehouses, Items: items, Units: units}, err
}

The Warehouses Controller

A controller here is a plain struct with a constructor (its dependency declaration) and a Routing(r iris.Router) method (its route table). The container calls NewWarehousesAPI with the registered *inventory.Service; the router calls Routing once at startup. The {id:uuid} macros reject malformed ids before any handler runs; in builder-built applications the macro error handler renders them as 400 INVALID_ARGUMENT (“invalid path parameter”).

api/warehouses.go:

Go
// Package api is the HTTP layer of the Stockroom service: thin handlers
// that read input, call the service and hand the result to rest helpers.
package api

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

    "stockroom/inventory"
)

// WarehousesAPI groups the /warehouses handlers around their
// dependencies. NewWarehousesAPI is registered in the builder's API
// step; the container calls it with the registered *inventory.Service.
type WarehousesAPI struct {
    svc *inventory.Service
}

func NewWarehousesAPI(svc *inventory.Service) *WarehousesAPI {
    return &WarehousesAPI{svc: svc}
}

// Routing implements the iris controller convention: the router calls
// it once at startup.
func (api *WarehousesAPI) Routing(r iris.Router) {
    r.Get("/", api.list)
    r.Post("/", api.create)
    r.Get("/{id:uuid}", api.get)
    r.Delete("/{id:uuid}", api.delete)
    r.Post("/{id:uuid}/seal", api.seal)

    // Everything about a warehouse's items lives in its own nested
    // router; the container resolves NewItemsAPI like any controller.
    r.Router("/{warehouse_id:uuid}/items", NewItemsAPI)
}

func (api *WarehousesAPI) list(ctx iris.Context) {
    warehouses, err := api.svc.Warehouses(ctx)
    ctx.List(warehouses, err)
}

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

    id, err := api.svc.CreateWarehouse(ctx, input)
    ctx.Created(id, err) // 201 with {"id": "..."}.
}

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

    w, err := api.svc.Warehouse(ctx, id)
    ctx.OK(w, err) // ErrNotFound is mapped to 404 centrally.
}

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

    deleted, err := api.svc.DeleteWarehouse(ctx, id)
    ctx.NoContent(deleted, err) // 204, or 403 when sealed.
}

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

    sealed, err := api.svc.Seal(ctx, id)
    ctx.NoContent(sealed, err) // 204 first time, 304 after.
}

Guards and Nesting

Items live under a warehouse, so they get their own router mounted at /{warehouse_id:uuid}/items. Two middleware idioms do the protective work. A group guard (r.Use) runs before every item route: it loads the warehouse once, replies with the mapped 404 when it does not exist, and caches it on the request so no handler loads it twice. A positional guard sits between path and handler on the write routes only, rejecting sealed warehouses with 403. Guards are methods on the API struct, so they see the same injected service as the handlers.

Pagination returns a large collection in numbered slices (page 2, twenty items per page) instead of all at once, so responses stay small and fast no matter how much data accumulates.

The list handler is one ctx.Paginated call: it reads ?page= and ?size= from the query and an optional JSON filter body, invokes the collector, and renders the standard page envelope (current_page, total_items, has_next_page, items, …).

Before the code, the shape of the whole tree, because where each guard attaches is the design:

Stockroom's router tree and its guards A group guard on the items child router runs for every route below it, while the seal check is positional and sits only on the writes, which is why a sealed warehouse still answers reads. iris.NewBuilder(cfg.Options) ... Prefix("/api") pre-routing chain: AllowOrigin("none") skipped, LogRequests(false), the errors handler, recovery, the Server header. See Chapter 12. API("/warehouses", api.NewWarehousesAPI) GET / POST / GET /{id:uuid} DELETE /{id:uuid} POST /{id:uuid}/seal CHILD ROUTER /{WAREHOUSE_ID:UUID}/ITEMS, API.ITEMSAPI r.Use(api.requireWarehouse) parses warehouse_id, loads the warehouse, and hands any error to errors.HandleError, which maps it to 404, and caches it under "stockroom.warehouse". A group guard: it runs for every route below. route handlers what it answers GET / list ctx.Paginated: ?page= and ?size=, plus an optional filter body POST / requireUnsealed then create sealed: 403 PERMISSION_DENIED GET /{id:uuid} get no seal check: reads always work DELETE /{id:uuid} requireUnsealed then delete sealed: 403 POST /{id:uuid}/withdraw requireUnsealed then withdraw ErrInsufficientStock: 409 API("/admin", basicauth.Default(...), api.NewAdminAPI) GET /stats POST /warehouses/{id}/unseal, the one capability the public API refuses to offer. Wrong or missing credentials: 401. GET /health Health(true, env, developer) registers it on the root, outside /api A group guard (r.Use) covers every route of the child router; a positional guard sits between path and handler, on the routes that name it. That is the whole reason a sealed warehouse still answers reads.
A group guard on the items child router runs for every route below it, while the seal check is positional and sits only on the writes, which is why a sealed warehouse still answers reads.

api/items.go:

Go
// api/items.go
package api

import (
    stdContext "context"

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

    "stockroom/inventory"
)

// warehouseContextKey caches the guard-loaded warehouse on the request
// so the handlers below never load it twice.
const warehouseContextKey = "stockroom.warehouse"

// ItemsAPI serves /warehouses/{warehouse_id:uuid}/items.
type ItemsAPI struct {
    svc *inventory.Service
}

func NewItemsAPI(svc *inventory.Service) *ItemsAPI {
    return &ItemsAPI{svc: svc}
}

func (api *ItemsAPI) Routing(r iris.Router) {
    // Group guard: every item route needs an existing warehouse.
    r.Use(api.requireWarehouse)

    r.Get("/", api.list)
    // Positional guards: writes additionally need an unsealed warehouse.
    r.Post("/", api.requireUnsealed, api.create)
    r.Get("/{id:uuid}", api.get)
    r.Delete("/{id:uuid}", api.requireUnsealed, api.delete)
    r.Post("/{id:uuid}/withdraw", api.requireUnsealed, api.withdraw)
}

// requireWarehouse loads the warehouse of the path parameter, replies
// 404 when it does not exist and caches it for later handlers.
func (api *ItemsAPI) requireWarehouse(ctx iris.Context) {
    id := ctx.Params().GetUUID("warehouse_id")

    w, err := api.svc.Warehouse(ctx, id)
    if errors.HandleError(ctx, err) {
        return // mapped 404, request stopped.
    }

    ctx.Values().Set(warehouseContextKey, w)
    ctx.Next()
}

// requireUnsealed rejects writes against a sealed warehouse.
func (api *ItemsAPI) requireUnsealed(ctx iris.Context) {
    if currentWarehouse(ctx).Sealed {
        errors.PermissionDenied.Detail(ctx, "warehouse is sealed",
            "ask an administrator to unseal it first")
        return
    }
    ctx.Next()
}

func currentWarehouse(ctx iris.Context) inventory.Warehouse {
    w, _ := ctx.ValueAs[inventory.Warehouse](warehouseContextKey)
    return w
}

func (api *ItemsAPI) list(ctx iris.Context) {
    warehouse := currentWarehouse(ctx)

    ctx.Paginated(func(reqCtx stdContext.Context,
        opts iris.PageOptions,
        filter inventory.ItemFilter) ([]inventory.StockItem, int64, error) {
        return api.svc.Items(reqCtx, warehouse.ID, opts, filter)
    })
}

func (api *ItemsAPI) create(ctx iris.Context) {
    input, ok := ctx.BindJSON[inventory.ItemInput]()
    if !ok {
        return
    }

    id, err := api.svc.CreateItem(ctx, currentWarehouse(ctx).ID, input)
    ctx.Created(id, err)
}

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

    item, err := api.svc.Item(ctx, currentWarehouse(ctx).ID, id)
    ctx.OK(item, err)
}

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

    input, ok := ctx.BindJSON[inventory.WithdrawInput]()
    if !ok {
        return
    }

    item, err := api.svc.Withdraw(ctx, currentWarehouse(ctx).ID, id, input)
    ctx.OK(item, err) // ErrInsufficientStock renders as 409.
}

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

    deleted, err := api.svc.DeleteItem(ctx, currentWarehouse(ctx).ID, id)
    ctx.NoContent(deleted, err)
}

The Admin Party

HTTP Basic Authentication is the simplest login scheme the protocol offers: the client repeats a username and password in an Authorization header on every request, and the server verifies them before serving. The credentials are only encoded, not encrypted, so the scheme is safe strictly over HTTPS, a good fit for a small internal admin surface, the wrong tool for public user login (Chapter 10 covers the alternatives).

The admin controller knows nothing about authentication: protection is applied where it is mounted, in the builder’s API step. It exposes the one capability the public API refuses to offer: reversing a seal.

api/admin.go:

Go
// api/admin.go
package api

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

    "stockroom/inventory"
)

// AdminAPI is mounted behind basic auth (see the builder's API step).
// Nothing in it knows about authentication.
type AdminAPI struct {
    svc *inventory.Service
}

func NewAdminAPI(svc *inventory.Service) *AdminAPI {
    return &AdminAPI{svc: svc}
}

func (api *AdminAPI) Routing(r iris.Router) {
    r.Get("/stats", api.stats)
    r.Post("/warehouses/{id:uuid}/unseal", api.unseal)
}

func (api *AdminAPI) stats(ctx iris.Context) {
    stats, err := api.svc.Stats(ctx)
    ctx.OK(stats, err)
}

// unseal reverses a seal: an admin-only capability, the force action
// that the public API deliberately does not expose.
func (api *AdminAPI) unseal(ctx iris.Context) {
    id := ctx.Params().GetUUID("id")

    unsealed, err := api.svc.Unseal(ctx, id)
    ctx.NoContent(unsealed, err)
}

Configuration and Wiring

An environment variable is a named string the operating system hands a process at start. It is the standard channel for per-deployment settings and secrets precisely because it lives outside the source tree: nothing to commit, nothing to leak through a repository, and every container platform knows how to set one.

One struct holds the whole configuration. It embeds iris.BaseConfiguration, which carries Host, Port, AllowOrigin, EnableCompression, RequestLog and the framework Iris options, so a single YAML document configures the server and the application alike (Chapter 11). Loading is one call: iris.LoadConfiguration[Config] reads the STOCKROOM_CONFIG variable when it is set (the value may be the document itself or a path to it, so a container can mount or inline it) and config.yml otherwise, then runs the BindEnv that BaseConfiguration provides, letting HOST, PORT, HTTP_PLATFORM_PORT and ALLOW_ORIGIN override the document. The admin password comes only from the environment; its field is yaml:"-" because the file is committed and secrets never are. One difference from the hand-written loader this replaces: the document is now required, so a deployment with neither the variable nor the file fails at startup instead of running on defaults nobody chose.

newApp assembles everything in one builder expression (Chapter 3). The chain order is compiler-enforced. Note where each concern lands: the three sentinel errors map to their status codes once in Errors; the repository and the service constructor register in Services (the container calls NewService(repo) because the types match, then runs its Init); Deferrables registers the shutdown hook; the admin controller mounts behind basicauth.Default. Health(true, ...) serves GET /health at the root, outside the /api prefix. The repository arrives as a parameter of newApp: main passes the real store, tests pass fakes.

main.go:

Go
// Stockroom is a warehouse inventory service on the full recommended
// Iris stack: builder, dependency injection, controllers, central error
// map, admin party, warmup and shutdown hooks.
package main

import (
    "fmt"
    "log"
    "os"

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

    "stockroom/api"
    "stockroom/inventory"
)

// Config is the whole application configuration: the fields every
// service shares come from iris.BaseConfiguration, inlined, and the
// stockroom's own settings sit next to them in the same YAML document.
// AdminPassword is deliberately yaml:"-": secrets come from the
// environment, never from a committed file.
type Config struct {
    iris.BaseConfiguration `yaml:",inline"`

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

// LoadConfig reads the document from the STOCKROOM_CONFIG environment
// variable or the config.yml file, lets HOST, PORT and ALLOW_ORIGIN
// override it, then reads the secrets from the environment.
func LoadConfig() (Config, error) {
    cfg, err := iris.LoadConfiguration[Config](
        "STOCKROOM_CONFIG", "config.yml")
    if err != nil {
        return cfg, err
    }

    cfg.AdminPassword = os.Getenv("ADMIN_PASSWORD")
    if cfg.AdminPassword == "" {
        return cfg, fmt.Errorf("ADMIN_PASSWORD is required")
    }
    return cfg, nil
}

// newApp assembles the whole application. The repository arrives as a
// parameter: main passes the real store, tests pass fakes.
func newApp(cfg Config, repo inventory.Repository) *iris.Application {
    return iris.NewBuilder(cfg.Iris).
        Prefix("/api").
        AllowOrigin(cfg.AllowOrigin).
        Compression(cfg.EnableCompression).
        LogRequests(cfg.RequestLog).
        Health(true, cfg.Env, cfg.Developer).
        Errors(errors.NewOptions().
            MapErrors(errors.NotFound, inventory.ErrNotFound).
            MapErrors(errors.PermissionDenied, inventory.ErrSealed).
            MapErrors(errors.Aborted, inventory.ErrInsufficientStock)).
        Deferrables(func() { log.Println("stockroom: goodbye") }).
        Services(repo, inventory.NewService).
        RouterMiddlewares().
        Middlewares().
        API("/warehouses", api.NewWarehousesAPI).
        API("/admin", basicauth.Default(map[string]string{
            cfg.AdminUser: cfg.AdminPassword,
        }), api.NewAdminAPI).
        Build()
}

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

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

The committed config.yml:

YAML
# Stockroom configuration. Committed settings only — no secrets here:
# the admin password comes from the ADMIN_PASSWORD environment variable.
Port: 8080
AllowOrigin: none
EnableCompression: true
RequestLog: false
Env: development
Developer: stockroom
AdminUser: admin
Iris:
  Name: stockroom
  LogLevel: info

The mapping of errors.Aborted deserves a note: its canonical status is 409 Conflict, the honest answer for a withdrawal that lost the race for the last units. The full code table lives in Chapter 9.

The Test Suite

Because newApp(cfg, repo) takes its storage as a parameter, every test builds the real application (real router, real guards, real error map, real basic auth) around any repository it likes, and httptest.New drives it in memory. The final test embeds the Repository interface in a struct and overrides a single method to fail with a fake connection string, proving a storage outage renders as a generic 500 that leaks nothing. Note that the fake breaks Warehouses, not Counts, so the service’s Init warmup still succeeds and the application builds.

main_test.go:

Go
// main_test.go
package main

import (
    stdContext "context"
    "fmt"
    "testing"
    "uuid"

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

    "stockroom/inventory"
)

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

func TestWarehouseLifecycleAndSealRule(t *testing.T) {
    app := newApp(testConfig(), inventory.NewMemStore())
    e := httptest.New(t, app)

    // Create.
    id := e.POST("/api/warehouses").
        WithJSON(inventory.WarehouseInput{
            Name: "central", Region: "eu-west",
        }).
        Expect().Status(httptest.StatusCreated).
        JSON().Object().Value("id").String().NotEmpty().Raw()

    // Read back.
    e.GET("/api/warehouses/"+id).Expect().Status(httptest.StatusOK).
        JSON().Object().HasValue("name", "central")

    // Validation: ctx.BindJSON runs WarehouseInput.Validate -> 400.
    e.POST("/api/warehouses").
        WithJSON(inventory.WarehouseInput{Name: "", Region: ""}).
        Expect().Status(httptest.StatusBadRequest)

    // Seal: 204 the first time, 304 when nothing changes.
    e.POST("/api/warehouses/" + id + "/seal").
        Expect().Status(httptest.StatusNoContent)
    e.POST("/api/warehouses/" + id + "/seal").
        Expect().Status(httptest.StatusNotModified)

    // A sealed warehouse refuses deletion: ErrSealed -> 403.
    e.DELETE("/api/warehouses/" + id).
        Expect().Status(httptest.StatusForbidden)

    // The admin force action reverses the seal; then deletion works.
    e.POST("/api/admin/warehouses/"+id+"/unseal").
        WithBasicAuth("admin", "test-password").
        Expect().Status(httptest.StatusNoContent)
    e.DELETE("/api/warehouses/" + id).
        Expect().Status(httptest.StatusNoContent)

    // The central error map turns ErrNotFound into a 404.
    e.GET("/api/warehouses/" + uuid.New().String()).
        Expect().Status(httptest.StatusNotFound)
}

func TestItemsPaginationGuardsAndWithdraw(t *testing.T) {
    app := newApp(testConfig(), inventory.NewMemStore())
    e := httptest.New(t, app)

    warehouseID := e.POST("/api/warehouses").
        WithJSON(inventory.WarehouseInput{
            Name: "north", Region: "eu-north",
        }).
        Expect().Status(httptest.StatusCreated).
        JSON().Object().Value("id").String().NotEmpty().Raw()

    itemsPath := "/api/warehouses/" + warehouseID + "/items"

    var firstItemID string
    for i := 1; i <= 5; i++ {
        itemID := e.POST(itemsPath).
            WithJSON(inventory.ItemInput{
                SKU:      fmt.Sprintf("CRT-%03d", i),
                Name:     fmt.Sprintf("Crate %d", i),
                Quantity: 10 * i,
            }).
            Expect().Status(httptest.StatusCreated).
            JSON().Object().Value("id").String().NotEmpty().Raw()
        if i == 1 {
            firstItemID = itemID
        }
    }

    // Page 2 with 2 items per page: items 3 and 4 of 5.
    page := e.GET(itemsPath).
        WithQuery("page", 2).WithQuery("size", 2).
        Expect().Status(httptest.StatusOK).JSON().Object()
    page.HasValue("current_page", 2)
    page.HasValue("total_items", 5)
    page.Value("items").Array().Length().IsEqual(2)

    // The JSON filter body narrows the listing to one SKU.
    e.GET(itemsPath).
        WithJSON(inventory.ItemFilter{SKU: "CRT-003"}).
        Expect().Status(httptest.StatusOK).
        JSON().Object().HasValue("total_items", 1)

    // Withdraw 4 of 10: the response carries the new balance.
    e.POST(itemsPath+"/"+firstItemID+"/withdraw").
        WithJSON(inventory.WithdrawInput{Quantity: 4}).
        Expect().Status(httptest.StatusOK).
        JSON().Object().HasValue("quantity", 6)

    // Withdrawing more than the balance: ErrInsufficientStock -> 409.
    e.POST(itemsPath+"/"+firstItemID+"/withdraw").
        WithJSON(inventory.WithdrawInput{Quantity: 100}).
        Expect().Status(httptest.StatusConflict)

    // Seal the warehouse: writes turn 403, reads keep working.
    e.POST("/api/warehouses/" + warehouseID + "/seal").
        Expect().Status(httptest.StatusNoContent)
    e.POST(itemsPath).
        WithJSON(inventory.ItemInput{
            SKU: "CRT-999", Name: "Late crate", Quantity: 1,
        }).
        Expect().Status(httptest.StatusForbidden)
    e.GET(itemsPath).Expect().Status(httptest.StatusOK)

    // Unknown warehouse: the group guard replies 404 first.
    e.GET("/api/warehouses/" + uuid.New().String() + "/items").
        Expect().Status(httptest.StatusNotFound)
}

func TestAdminRequiresCredentials(t *testing.T) {
    app := newApp(testConfig(), inventory.NewMemStore())
    e := httptest.New(t, app)

    e.GET("/api/admin/stats").
        Expect().Status(httptest.StatusUnauthorized)

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

// failingRepo overrides one method of a working repository; everything
// else is inherited. Interface embedding makes one-method fakes
// one-liners. Counts still works, so Service.Init succeeds and the
// application builds.
type failingRepo struct {
    inventory.Repository
}

func (failingRepo) Warehouses(
    stdContext.Context) ([]inventory.Warehouse, error) {
    return nil, fmt.Errorf("pg: SSL is off on 192.168.7.4:5432")
}

// Internal error details must never reach the client.
func TestInternalErrorsDoNotLeak(t *testing.T) {
    app := newApp(testConfig(), failingRepo{inventory.NewMemStore()})
    e := httptest.New(t, app)

    body := e.GET("/api/warehouses").
        Expect().Status(httptest.StatusInternalServerError).
        JSON().Object()
    body.HasValue("message", "server error")
    body.NotContainsValue("192.168.7.4") // stays server-side.
}

Run It

Shell
go mod tidy && go test ./...   # ok  stockroom
ADMIN_PASSWORD=change-me go run .

Walk the rules with curl (keep the created id in $W):

Shell
# 201 {"id":"..."}
curl -X POST localhost:8080/api/warehouses \
  -H 'Content-Type: application/json' \
  -d '{"name":"central","region":"eu-west"}'

# 201 — add stock (use the warehouse id from above)
curl -X POST localhost:8080/api/warehouses/$W/items \
  -H 'Content-Type: application/json' \
  -d '{"sku":"CRT-001","name":"Crates","quantity":40}'

# 200 — paginated listing
curl "localhost:8080/api/warehouses/$W/items?page=1&size=10"

# 204 — seal, then 403 on any write
curl -X POST localhost:8080/api/warehouses/$W/seal
curl -X DELETE localhost:8080/api/warehouses/$W

# 401 then 204 — only an admin can unseal
curl -X POST localhost:8080/api/admin/warehouses/$W/unseal
curl -u admin:change-me -X POST \
  localhost:8080/api/admin/warehouses/$W/unseal

# 200 — health, at the root, outside /api
curl localhost:8080/health

Choosing Between the Two Shapes

Shortl and Stockroom are not two quality levels; they are two honest answers to differently sized problems. iris.New() with hand-registered routes is the right call when the whole program fits in a couple of files: there is no indirection to justify, and the response helpers and typed macros work identically in both shapes.

Reach for iris.NewBuilder when any of these appear: more than one group of endpoints, a dependency you will want to fake in tests, errors that repeat across handlers, configuration that differs between environments, or work that must happen at startup or shutdown. Each of those concerns has exactly one home in the builder chain, which is the point: Stockroom’s tests could replace the entire storage layer in one line only because the wiring lives in newApp, not scattered across handlers.

Growing from the first shape to the second is mechanical, not a rewrite: extract the domain into its own package, put the storage behind an interface, turn route groups into controllers, and move the wiring into the builder. The chapters this one leans on cover each move in depth: routing and controllers in Chapter 4, dependency injection in Chapter 8, the error pipeline in Chapter 9, configuration in Chapter 11 and the test techniques in Chapter 16.

Summary

  • Both applications in this chapter are complete: paste the labeled blocks into files, run go mod tidy, and go test ./... passes.
  • Shortl shows the lightweight path: iris.New() with a partial options literal, a typed {code:alphabetical} catch-all that filters junk requests before the handler, ctx.BindJSON with a custom validation.Func, a mutex-guarded store, and a redirect test that inspects the 301 instead of following it.
  • Stockroom shows the production shape: env-first configuration with secrets outside YAML, three sentinel errors mapped once, controllers with constructors and Routing, a nested router protected by a group guard plus positional guards, ctx.Paginated listings, an admin party behind basic auth exposing the force action, iris.Initializer warmup inside Build() and Deferrables at shutdown.
  • The architecture is what makes the tests cheap: because storage is an interface handed to newApp, an interface-embedding fake proved in five lines that internal failures never leak to clients.

Further Reading

  • MDN: Redirections in HTTP: how 3xx status codes and the Location header drive Shortl’s 301.
  • MDN: HTTP authentication: the Basic scheme that protects Stockroom’s admin party.
  • Go: sync.RWMutex: the read-write lock guarding Shortl’s in-memory store.
  • Go: the errors package: sentinel error values and errors.Is, the standard mechanics an error map builds on.
  • Go: Add a test: the official primer on go test and the _test.go convention both suites rely on.
  • uuid: the standard library’s UUID package (Go 1.27) behind Stockroom’s {id:uuid} identifiers.

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