65. Capstone 3: an inventory service

📖 Reading · 15 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

The last project is a small library the way you'd actually structure one: a domain type with validation, an interface for storage, an in-memory implementation, custom errors, JSON serialisation, and safe concurrent access. It's the shape of most Go services, minus the HTTP layer.

The specification

  • An Item with a SKU, name, quantity and price.
  • A Store interface: add, get, list, adjust quantity.
  • Errors callers can branch on: not found, duplicate, insufficient stock.
  • JSON in and out.
  • Safe to use from several goroutines.

Step 1: the domain type

package main

import (
    "errors"
    "fmt"
)

type Item struct {
    SKU      string  `json:"sku"`
    Name     string  `json:"name"`
    Quantity int     `json:"quantity"`
    Price    float64 `json:"price"`
}

func (i Item) Value() float64 {
    return float64(i.Quantity) * i.Price
}

func (i Item) String() string {
    return fmt.Sprintf("%s (%s) x%d @ $%.2f", i.Name, i.SKU, i.Quantity, i.Price)
}

func (i Item) Validate() error {
    if i.SKU == "" {
        return errors.New("sku is required")
    }
    if i.Name == "" {
        return errors.New("name is required")
    }
    if i.Quantity < 0 {
        return fmt.Errorf("quantity %d cannot be negative", i.Quantity)
    }
    if i.Price < 0 {
        return fmt.Errorf("price %.2f cannot be negative", i.Price)
    }
    return nil
}

func main() {
    good := Item{SKU: "KB-1", Name: "Keyboard", Quantity: 12, Price: 49.99}
    fmt.Println(good)
    fmt.Printf("value: $%.2f, valid: %v\n", good.Value(), good.Validate() == nil)

    bad := Item{SKU: "X", Quantity: -1}
    fmt.Println("bad item:", bad.Validate())
}

Item is a plain value type with value receivers — it's small, nothing mutates it, and every method just reads (module 5). Validate returning an error rather than a bool means the caller learns what was wrong.

Step 2: errors worth branching on

package main

import (
    "errors"
    "fmt"
)

var (
    ErrNotFound  = errors.New("item not found")
    ErrDuplicate = errors.New("item already exists")
)

type InsufficientStockError struct {
    SKU       string
    Requested int
    Available int
}

func (e *InsufficientStockError) Error() string {
    return fmt.Sprintf("insufficient stock for %s: requested %d, available %d",
        e.SKU, e.Requested, e.Available)
}

func take(sku string, available, requested int) error {
    if requested > available {
        return &InsufficientStockError{SKU: sku, Requested: requested, Available: available}
    }
    return nil
}

func main() {
    err := take("KB-1", 5, 10)
    fmt.Println(err)

    if se, ok := err.(*InsufficientStockError); ok {
        fmt.Println("short by:", se.Requested-se.Available)
    }

    wrapped := fmt.Errorf("processing order 42: %w", ErrNotFound)
    fmt.Println(wrapped)
    fmt.Println("is not-found:", errors.Is(wrapped, ErrNotFound))
}

Both error styles from module 7, each where it fits:

  • Sentinels (ErrNotFound, ErrDuplicate) for conditions with nothing to report but the fact itself.
  • A custom type for InsufficientStockError, because the caller wants the numbers — how short they are decides whether to backorder or reject.

Pointer receiver, & on return: the convention that keeps identity comparisons honest.

Step 3: the interface and an implementation

package main

import (
    "errors"
    "fmt"
    "sort"
    "sync"
)

type Item struct {
    SKU      string  `json:"sku"`
    Name     string  `json:"name"`
    Quantity int     `json:"quantity"`
    Price    float64 `json:"price"`
}

func (i Item) Value() float64 { return float64(i.Quantity) * i.Price }

var (
    ErrNotFound  = errors.New("item not found")
    ErrDuplicate = errors.New("item already exists")
)

type InsufficientStockError struct {
    SKU       string
    Requested int
    Available int
}

func (e *InsufficientStockError) Error() string {
    return fmt.Sprintf("insufficient stock for %s: requested %d, available %d",
        e.SKU, e.Requested, e.Available)
}

// Store is the behaviour the rest of the program depends on.
type Store interface {
    Add(item Item) error
    Get(sku string) (Item, error)
    List() []Item
    Adjust(sku string, delta int) (Item, error)
}

type MemStore struct {
    mu    sync.RWMutex
    items map[string]Item
}

func NewMemStore() *MemStore {
    return &MemStore{items: make(map[string]Item)}
}

func (s *MemStore) Add(item Item) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, exists := s.items[item.SKU]; exists {
        return fmt.Errorf("adding %s: %w", item.SKU, ErrDuplicate)
    }
    s.items[item.SKU] = item
    return nil
}

func (s *MemStore) Get(sku string) (Item, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    item, ok := s.items[sku]
    if !ok {
        return Item{}, fmt.Errorf("getting %s: %w", sku, ErrNotFound)
    }
    return item, nil
}

func (s *MemStore) List() []Item {
    s.mu.RLock()
    defer s.mu.RUnlock()

    out := make([]Item, 0, len(s.items))
    for _, item := range s.items {
        out = append(out, item)
    }
    sort.Slice(out, func(i, j int) bool { return out[i].SKU < out[j].SKU })
    return out
}

func (s *MemStore) Adjust(sku string, delta int) (Item, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    item, ok := s.items[sku]
    if !ok {
        return Item{}, fmt.Errorf("adjusting %s: %w", sku, ErrNotFound)
    }
    if item.Quantity+delta < 0 {
        return item, &InsufficientStockError{
            SKU:       sku,
            Requested: -delta,
            Available: item.Quantity,
        }
    }
    item.Quantity += delta
    s.items[sku] = item
    return item, nil
}

var _ Store = (*MemStore)(nil)

func main() {
    store := NewMemStore()

    store.Add(Item{SKU: "KB-1", Name: "Keyboard", Quantity: 12, Price: 49.99})
    store.Add(Item{SKU: "MN-2", Name: "Monitor", Quantity: 3, Price: 199.50})

    if err := store.Add(Item{SKU: "KB-1", Name: "Duplicate"}); err != nil {
        fmt.Println("expected:", err)
        fmt.Println("  is duplicate:", errors.Is(err, ErrDuplicate))
    }

    if _, err := store.Get("NOPE"); err != nil {
        fmt.Println("expected:", err)
        fmt.Println("  is not-found:", errors.Is(err, ErrNotFound))
    }

    item, _ := store.Adjust("KB-1", -5)
    fmt.Println("after selling 5:", item.Quantity)

    if _, err := store.Adjust("MN-2", -10); err != nil {
        fmt.Println("expected:", err)
    }

    total := 0.0
    for _, it := range store.List() {
        fmt.Printf("  %-6s %-9s x%-3d $%.2f\n", it.SKU, it.Name, it.Quantity, it.Value())
        total += it.Value()
    }
    fmt.Printf("inventory value: $%.2f\n", total)
}

The design decisions worth naming:

  • Store is an interface, MemStore is a struct. "Accept interfaces, return structs" (module 6) — a Postgres implementation drops in later without touching anything that consumes Store.
  • var _ Store = (*MemStore)(nil) — the compile-time assertion from module 6. Break a method signature and the build fails here, with a clear message, rather than at some distant call site.
  • sync.RWMutex, unexported, next to the data. Reads take RLock, writes take Lock, and every method defers its unlock (module 9).
  • Getting an item returns a copy. Item is a value type, so callers can't reach into the store and mutate it — mutation only happens through Adjust, under the lock.
  • List sorts. Map iteration is random; a method that returns a slice should return a stable one.

Step 4: JSON at the edges

package main

import (
    "encoding/json"
    "fmt"
    "sort"
)

type Item struct {
    SKU      string  `json:"sku"`
    Name     string  `json:"name"`
    Quantity int     `json:"quantity"`
    Price    float64 `json:"price"`
    Note     string  `json:"note,omitempty"`
}

type Snapshot struct {
    Items []Item  `json:"items"`
    Total float64 `json:"total_value"`
}

func main() {
    raw := []byte(`[
        {"sku":"MN-2","name":"Monitor","quantity":3,"price":199.50},
        {"sku":"KB-1","name":"Keyboard","quantity":12,"price":49.99}
    ]`)

    var items []Item
    if err := json.Unmarshal(raw, &items); err != nil {
        fmt.Println("decoding inventory:", err)
        return
    }

    sort.Slice(items, func(i, j int) bool { return items[i].SKU < items[j].SKU })

    snap := Snapshot{Items: items}
    for _, it := range items {
        snap.Total += float64(it.Quantity) * it.Price
    }

    out, err := json.MarshalIndent(snap, "", "  ")
    if err != nil {
        fmt.Println("encoding snapshot:", err)
        return
    }
    fmt.Println(string(out))
}

JSON belongs at the boundary — decode into real types on the way in, encode on the way out, and let everything in between work with Item, not map[string]any. omitempty keeps the optional Note out of the output when it's empty.

Step 5: what the HTTP layer would look like

func handleGetItem(store Store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        sku := r.PathValue("sku")

        item, err := store.Get(sku)
        if err != nil {
            if errors.Is(err, ErrNotFound) {
                http.Error(w, "not found", http.StatusNotFound)
                return
            }
            http.Error(w, "internal error", http.StatusInternalServerError)
            return
        }

        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(item)
    }
}

func main() {
    store := NewMemStore()

    mux := http.NewServeMux()
    mux.HandleFunc("GET /items/{sku}", handleGetItem(store))

    log.Fatal(http.ListenAndServe(":8080", mux))
}

Two things to take from that box even though it can't run here. The handler is a closure over the store (module 3) rather than a global, so tests construct one with a fake. And errors.Is on your own sentinel is what maps a domain failure to an HTTP status — module 7's payoff, at the edge of the system.

The shape of a Go program

Every module of this course shows up in this design, and the arrangement is the standard one:

  ┌──────────────────────────────────────────┐
  │  transport   HTTP / CLI / gRPC           │  errors -> status codes
  ├──────────────────────────────────────────┤
  │  domain      Item, Validate, Store       │  interfaces, business rules
  ├──────────────────────────────────────────┤
  │  storage     MemStore, PostgresStore     │  implements Store
  └──────────────────────────────────────────┘
        dependencies point INWARD ↑

The domain layer defines the interface it needs; storage implements it; transport wires them together in main. Nothing in the middle imports net/http or database/sql, so the interesting code is testable without a server or a database.

Your turn

Complete Adjust so it returns ErrNotFound for an unknown SKU, refuses to go negative, and otherwise updates the quantity:

7
insufficient stock
item not found
package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("item not found")
var ErrInsufficient = errors.New("insufficient stock")

type Store struct {
    items map[string]int
}

func (s *Store) Adjust(sku string, delta int) (int, error) {
    // return ErrNotFound for a missing sku, ErrInsufficient if the
    // result would be negative, otherwise the new quantity
}

func main() {
    s := &Store{items: map[string]int{"KB-1": 12}}

    q, err := s.Adjust("KB-1", -5)
    if err == nil {
        fmt.Println(q)
    }

    if _, err := s.Adjust("KB-1", -100); err != nil {
        fmt.Println(err)
    }
    if _, err := s.Adjust("NOPE", 1); err != nil {
        fmt.Println(err)
    }
}
package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("item not found")
var ErrInsufficient = errors.New("insufficient stock")

type Store struct {
    items map[string]int
}

func (s *Store) Adjust(sku string, delta int) (int, error) {
    qty, ok := s.items[sku]
    if !ok {
        return 0, ErrNotFound
    }
    if qty+delta < 0 {
        return qty, ErrInsufficient
    }
    s.items[sku] = qty + delta
    return s.items[sku], nil
}

func main() {
    s := &Store{items: map[string]int{"KB-1": 12}}

    q, err := s.Adjust("KB-1", -5)
    if err == nil {
        fmt.Println(q)
    }

    if _, err := s.Adjust("KB-1", -100); err != nil {
        fmt.Println(err)
    }
    if _, err := s.Adjust("NOPE", 1); err != nil {
        fmt.Println(err)
    }
}

That's the course

You've gone from package main to a concurrent, tested, well-structured Go program. What's left is the part no course can do for you: build things.

Reasonable next steps:

  • Write a real CLI. Take capstone 1 and add flags, file input and tests.
  • Write an HTTP service. The standard library's net/http is enough; you don't need a framework.
  • Read the standard library. It's the best Go you'll find, and it's designed to be read — start with strings, sort and errors.
  • Run go vet and -race on everything, from the first commit.

The Go proverbs are worth a read once the language is in your hands, and the Go blog's articles on slices, interfaces and concurrency patterns cover the same ground this course did, from another angle.