65. Capstone 3: an inventory service
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
Itemwith a SKU, name, quantity and price. - A
Storeinterface: 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:
Storeis an interface,MemStoreis a struct. "Accept interfaces, return structs" (module 6) — a Postgres implementation drops in later without touching anything that consumesStore.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 takeRLock, writes takeLock, and every method defers its unlock (module 9).- Getting an item returns a copy.
Itemis a value type, so callers can't reach into the store and mutate it — mutation only happens throughAdjust, under the lock. Listsorts. 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/httpis 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,sortanderrors. - Run
go vetand-raceon 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.