<!--
  Real-World Examples
  From the Iris book, chapter 22, covering Iris 14.0.0 on Go 1.27.
  Import path: github.com/kataras/iris/v14
  Canonical page: https://iris-go.com/docs/real-world-examples/
-->
# Chapter 22: Real-World Examples

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](#choosing-between-the-two-shapes) 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:

| Method | Path              | Purpose                |
| ------ | ----------------- | ---------------------- |
| POST   | /api/links        | create 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:

```sh
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](https://iris-go.com/docs/routing-controllers/)).

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](https://iris-go.com/docs/error-handling/)).

### 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

```sh
go mod tidy && go test ./...   # ok  shortl
go run .
```

```sh
# 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.

| Method | Path                                      | Purpose                      | Protection         |
| ------ | ----------------------------------------- | ---------------------------- | ------------------ |
| GET    | /api/warehouses                           | list warehouses              | —                  |
| POST   | /api/warehouses                           | create a warehouse           | validation         |
| GET    | /api/warehouses/{id}                      | one warehouse                | —                  |
| DELETE | /api/warehouses/{id}                      | delete (refused when sealed) | —                  |
| POST   | /api/warehouses/{id}/seal                 | seal a warehouse             | —                  |
| GET    | /api/warehouses/{wid}/items               | list items, paginated        | warehouse exists   |
| POST   | /api/warehouses/{wid}/items               | add an item                  | warehouse unsealed |
| GET    | /api/warehouses/{wid}/items/{id}          | one item                     | warehouse exists   |
| DELETE | /api/warehouses/{wid}/items/{id}          | remove an item               | warehouse unsealed |
| POST   | /api/warehouses/{wid}/items/{id}/withdraw | remove units                 | warehouse unsealed |
| GET    | /api/admin/stats                          | totals                       | basic auth         |
| POST   | /api/admin/warehouses/{id}/unseal         | force-unseal                 | basic auth         |
| GET    | /health                                   | health 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:

```sh
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:

\[Diagram: 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](https://iris-go.com/docs/authentication-authorization/) 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](https://iris-go.com/docs/configuration-management/)). 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](https://iris-go.com/docs/iris-newbuilder/)). 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](https://iris-go.com/docs/error-handling/).

### 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

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

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

```sh
# 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](https://iris-go.com/docs/routing-controllers/),
dependency injection in [Chapter 8](https://iris-go.com/docs/dependency-injection/), the
error pipeline in [Chapter 9](https://iris-go.com/docs/error-handling/), configuration in
[Chapter 11](https://iris-go.com/docs/configuration-management/) and the test techniques in
[Chapter 16](https://iris-go.com/docs/testing/).

## 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections):
  how 3xx status codes and the `Location` header drive Shortl's 301.
- [MDN: HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication):
  the Basic scheme that protects Stockroom's admin party.
- [Go: sync.RWMutex](https://pkg.go.dev/sync#RWMutex): the read-write
  lock guarding Shortl's in-memory store.
- [Go: the errors package](https://pkg.go.dev/errors): sentinel error
  values and `errors.Is`, the standard mechanics an error map builds on.
- [Go: Add a test](https://go.dev/doc/tutorial/add-a-test): the
  official primer on `go test` and the `_test.go` convention both
  suites rely on.
- [uuid](https://pkg.go.dev/uuid): the standard library's UUID package
  (Go 1.27) behind Stockroom's `{id:uuid}` identifiers.
