An In-Memory Store
Chapter 1 built the plumbing: encode a response, decode a request, route a
resource. None of it touched where tasks actually live. This chapter fixes
that. If you've done Fundamentals' concurrency Part, sync.Mutex is already
familiar territory, and the "Parallel Tests" lesson already showed you why
shared state needs guarding under concurrent access — this lesson puts both
to work behind two methods: Create and Get.
The store as a seam
Every handler in this book is going to call a TaskStore method instead of
touching data directly. That indirection is the point. TaskStore today is
a map protected by a mutex; a year from now it could be a Postgres table
behind the exact same method signatures — Create(title string, priority int) Task, Get(id string) (Task, bool) — and not one handler would need
to change. The seam is the contract, not the implementation. Get the
contract right now, in the simplest possible backing store, and swapping the
backing store later becomes a one-file change instead of a rewrite.
type TaskStore struct {
mu sync.Mutex
items map[string]Task
order []string
nextID int
}
Four fields, each earning its place: items holds the actual data, mu
guards it, nextID drives ID assignment, and order — more on that below —
remembers what got created first.
Why the map needs a mutex
An HTTP server handles requests concurrently — that's the whole point of
net/http's one-goroutine-per-request model. Two clients could hit POST /tasks at the same moment, and both handlers would call Create on the
same *TaskStore at the same time. A plain Go map isn't safe for
that: concurrent reads and writes to a map without synchronization are a
data race, and the runtime will crash the program rather than let it happen
silently. s.mu.Lock() at the top of every method, defer s.mu.Unlock()
right after, and the rest of the method runs single-file no matter how many
goroutines call it at once. It's the same pattern the concurrency Part
covered — a mutex fencing a shared resource — applied to the resource this
whole service is built around.
Deterministic IDs, on purpose
nextID starts at 1 and increments once per Create call; the ID stored on
the task is strconv.Itoa(nextID) — "1", "2", "3", and so on. It would
be easy to reach for a UUID or a random string instead, and a production
system layered on top of this store might do exactly that. But for this
course, sequential IDs buy you something UUIDs can't: a test that creates
two tasks can assert the exact IDs it expects, not just that they're
non-empty and different. That determinism is what makes TestCreateAssignsSequentialIDs
possible to write at all — and it'll make your own debugging easier too,
since task IDs tell you creation order at a glance.
Remembering insertion order
items is a map, and Go maps have no defined iteration order — ranging over
one twice in the same program can yield a different order each time. That's
fine for Get, which looks up one key directly, but it's a problem for a
future List endpoint that should return tasks in the order clients created
them. order solves it cheaply: Create appends each new ID to the slice
right after storing the task, so order is always a plain, stable record of
insertion sequence. You won't use it until the next lesson builds List,
but it has to be populated from the very first Create call, so it's part
of this lesson's job.
The comma-ok Get
Get is the simpler of the two methods, and it leans entirely on a pattern
you've already used: the comma-ok map lookup. t, ok := s.items[id]
returns the zero Task and false when id isn't present, or the stored
task and true when it is. Get doesn't need to do anything more than lock,
look up, and return that pair straight through — no error wrapping, no
special-casing. The caller — a future handler — decides what a missing task
means (a 404, most likely); the store's job is just to report presence
honestly.
With Create and Get in place, the store is no longer a stub — it's a
working, concurrency-safe piece of persistence that the rest of this chapter
will build directly on. The next lesson adds List, which is where order
finally gets used.
Practice
1. An In-Memory Store
IntermediateTime for the data layer. The whole service talks to a
TaskStore, and this lesson builds the first two methods. TheTasktype and the store's fields are given; you implement the behavior.Create(title string, priority int) Task— assign the next sequential ID as a string ("1","2", …), store the task, and return it. New tasks startDone: false. Record the id inordertoo (the next lesson'sListneeds insertion order).Get(id string) (Task, bool)— return the task andtrue, or the zeroTaskandfalseif there's no such id.
Both methods run under
s.mu— a map is not safe for concurrent use. Sequential IDs (not random ones) keep the tests — and your debugging — deterministic.package main import ( "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 { // TODO: lock s.mu (defer the unlock); the id is strconv.Itoa(s.nextID), // then increment s.nextID; build the Task with Done: false; store it in // s.items and append its id to s.order; return the task. return Task{} } // Get returns the task with id, or ok=false if there is none. func (s *TaskStore) Get(id string) (Task, bool) { // TODO: lock s.mu (defer the unlock); look up id in s.items and return // the task and the comma-ok bool straight through. return Task{}, false } func main() {}Sign in to write and run your solution in the browser.
Sign in for hints and the solution.