Listing in Order
The last lesson gave TaskStore its first two methods, Create and Get,
and along the way Create started doing something that looked like busywork:
every new task's id got appended to an order slice, even though nothing
read from it yet. This lesson is where that slice earns its keep. List
returns every task in the store, and the one requirement that makes it more
than a one-liner is that the order has to be stable — the same,
oldest-first sequence every time you call it.
The trap: ranging over the map
items is a map[string]Task, and it's tempting to write List as a
straight range over it:
for _, t := range s.items {
out = append(out, t)
}
This compiles, passes a casual glance, and is wrong. Go maps have no defined
iteration order, and the runtime deliberately randomizes it — a language
design choice that stops programs from silently depending on an
implementation detail that could change. Run that loop twice in the same
program and you can get two different orderings back. For Get, which pulls
a single key out by name, that randomness is invisible. For List, it means
clients see tasks shuffled on every request: pagination breaks, "what changed
since last time" diffs break, and a test asserting a specific order fails
intermittently instead of never. The map is the wrong source of truth for
sequence, full stop.
The fix: walk order, not items
order is a plain []string — no randomness, no surprises. Create
appends each new id to it in the exact sequence tasks were created, so
order[0] is always the oldest task's id, order[len(order)-1] the newest.
List's job is just to translate that slice of ids into a slice of tasks:
out := make([]Task, 0, len(s.order))
for _, id := range s.order {
out = append(out, s.items[id])
}
Two things worth noticing. First, make([]Task, 0, len(s.order))
pre-sizes the backing array instead of letting append grow it
incrementally — a small, free optimization once you know the final length up
front, and idiomatic Go whenever you do. Second, the lookup s.items[id]
is guaranteed to succeed: every id in order was written to items in the
same Create call that appended it, and nothing in this store ever deletes
from items without also removing from order. There's no comma-ok check
here — unlike Get, List isn't handling arbitrary caller-supplied ids, so
that would be dead code, not defensive code.
Zero-length, not nil
An empty store — no Create calls yet — should return a zero-length slice
(the returned out slice, where len(out) == 0), not a nil slice that
happens to also report zero length. In Go, a nil
slice and a zero-length slice usually behave the same to callers (len,
range, and JSON encoding all treat them identically — both marshal to []
with encoding/json), so this distinction rarely bites you in practice. But
make([]Task, 0, len(s.order)) gives you the zero-length slice for free
when order is empty, at no extra cost, so there's no reason to reach for a
nil return instead. Lock s.mu for the whole operation exactly like Create
and Get do — a range over s.order while another goroutine calls Create
would be exactly the kind of concurrent map access the mutex exists to
prevent.
What a real store would do differently
This in-memory List walks a slice because that's the data structure on
hand. A database-backed store wouldn't maintain a parallel order slice at
all — it would issue SELECT * FROM tasks ORDER BY id (or created_at) and
let the database do the ordering, which is exactly what indexes and query
planners exist for. order is a stand-in for that ORDER BY clause, sized
to the needs of an in-memory demo. The method signature — List() []Task —
is what survives the swap; the mechanics underneath are free to change.
With List in place, TaskStore now supports the three operations any
handler in this book will need: create a task, fetch one, and list them all.
The next chapter puts these behind real HTTP handlers.
Practice
1. Listing in Order
IntermediateClients need to list tasks, and the order must be stable — always oldest-first — or paging and diffing become a nightmare.
Implement
List() []TaskonTaskStore: return every task in insertion order. The store already records creation order ins.order(a slice of ids); walk it and pull each task out ofs.items.Return an empty (non-nil is fine, but zero-length) slice for an empty store — never nil-panic. Lock
s.mulike the other methods.package main import ( "strconv" "sync" ) // Task is the resource this service manages. type Task struct { ID string `json:"id"` Title string `json:"title"` Done bool `json:"done"` Priority int `json:"priority"` } // TaskStore is an in-memory, concurrency-safe store. A real service would // swap a database in behind the same methods. type TaskStore struct { mu sync.Mutex items map[string]Task order []string nextID int } // NewTaskStore returns a ready-to-use store. func NewTaskStore() *TaskStore { return &TaskStore{items: make(map[string]Task), nextID: 1} } // Create adds a new task with the next sequential ID and returns it. func (s *TaskStore) Create(title string, priority int) Task { s.mu.Lock() defer s.mu.Unlock() id := strconv.Itoa(s.nextID) s.nextID++ t := Task{ID: id, Title: title, Done: false, Priority: priority} s.items[id] = t s.order = append(s.order, id) return t } // Get returns the task with id, or ok=false if there is none. func (s *TaskStore) Get(id string) (Task, bool) { s.mu.Lock() defer s.mu.Unlock() t, ok := s.items[id] return t, ok } // List returns all tasks in the order they were created. func (s *TaskStore) List() []Task { // TODO: lock s.mu (defer the unlock); make a slice sized to // len(s.order); range over s.order appending s.items[id] for each id; // return the slice. return nil } func main() {}Sign in to write and run your solution in the browser.
Sign in for hints and the solution.