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:
| 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 suiteSetup:
mkdir shortl && cd shortl
go mod init shortl
go get github.com/kataras/iris/v14@latestRun 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:
// 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:
// 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:
// 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
go mod tidy && go test ./... # ok shortl
go run .# 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 warmupSetup:
mkdir stockroom && cd stockroom
go mod init stockroom
go get github.com/kataras/iris/v14@latest
go get gopkg.in/yaml.v3@latestRun 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:
// 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:
// 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:
// 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:
// 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:
// 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:
api/items.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:
// 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:
// 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:
# 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: infoThe 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:
// 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
go mod tidy && go test ./... # ok stockroom
ADMIN_PASSWORD=change-me go run .Walk the rules with curl (keep the created id in $W):
# 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/healthChoosing 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, andgo 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.BindJSONwith a customvalidation.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.Paginatedlistings, an admin party behind basic auth exposing the force action,iris.Initializerwarmup insideBuild()andDeferrablesat 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
Locationheader 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 testand the_test.goconvention both suites rely on. - uuid: the standard library’s UUID package
(Go 1.27) behind Stockroom’s
{id:uuid}identifiers.