In Chapter 19 we wired up the routes and an in-memory store, but POST /tasks was still a 501 Not Implemented stub. This chapter replaces that stub with a working handler and makes GET /tasks return real data.
Our stub create handler used to answer 501 Not Implemented no matter what body you sent. Now, we will decode that body, hand it to the store, and let the store fill in the fields a client should never pick on its own. Once POST /tasks can actually store data, GET /tasks will stop returning an empty array and start returning the tasks you just created.

Here is what changes in this chapter:
POST /tasks decodes the JSON body, asks the store to create a task, and returns 201 Created along with the stored task.id and createdAt fields, so the client does not have to provide them.GET /tasks returns everything in the store as a JSON array with a 200 OK status.Data race: This happens when two goroutines read and write the same memory at the exact same time without coordination. Because every HTTP request runs in its own goroutine (Chapter 13), two requests hitting POST /tasks at once could corrupt the store's map if nothing protects it.
201 Created: This is the HTTP status code for "a new resource now exists because of this request." It differs from the plain 200 OK we use for a list, which simply means "here is some data, nothing was created."
In-memory store: A store that keeps its data in a Go value in the running process—in this case, a map[int]Task—instead of a database. It is fast and simple, but everything disappears when the process restarts. That trade-off is fine for now. We will replace it with PostgreSQL in Section 6.
If clients picked their own id values, two clients could pick the same number and overwrite each other's tasks. If a client sent its own createdAt timestamp, it could lie about when a task was made. Neither risk is worth taking, so the store handles both. Because the server generates these authoritative details, the response body is more than just an echo of what the client sent. This is why POST /tasks returns 201 Created instead of 200 OK.
The store locks every map access with a sync.Mutex, using the same pattern from Chapter 13's SafeCounter. Two POST /tasks requests can land at the exact same instant. Without a lock, both goroutines could read nextID, increment it, and write to the map simultaneously. This would corrupt the count or lose a task entirely. Locking the entire read-modify-write sequence prevents this.
internal/task/store.go gains a nextID counter and a Create method. Both Create and List now take a context.Context as their first parameter:
go// internal/task/store.gopackage taskimport ("context""sync""time")type Store struct {mu sync.Mutextasks map[int]TasknextID int}func NewStore() *Store {return &Store{tasks: make(map[int]Task)}}func (s *Store) Create(_ context.Context, t Task) (Task, error) {s.mu.Lock()defer s.mu.Unlock()s.nextID++t.ID = s.nextIDt.CreatedAt = time.Now()s.tasks[t.ID] = treturn t, nil}func (s *Store) List(_ context.Context) ([]Task, error) {s.mu.Lock()defer s.mu.Unlock()tasks := make([]Task, 0, len(s.tasks))for _, t := range s.tasks {tasks = append(tasks, t)}return tasks, nil}
Create locks the mutex, bumps nextID, stamps the incoming Task with an ID and CreatedAt time, stores it in the map under that ID, and returns the stored copy. List builds its slice using make([]Task, 0, len(s.tasks)) rather than a bare var tasks []Task. A nil slice serializes to null in JSON, but this approach ensures it always serializes to [], even when the store is empty.
Neither method uses its context.Context parameter yet, which is why we name it _. The in-memory store has no query to cancel and no deadline to respect. We include the parameter anyway because Section 6 replaces this store with a PostgreSQL database. That future store will pass this exact same ctx straight into a database query. Adding it now means our handler code will not have to change when we swap out the storage later.
internal/task/handler.go fills in create and updates list to handle the store's new return signature:
go// internal/task/handler.gopackage taskimport ("encoding/json""log""net/http")type Handler struct {store *Store}func NewHandler(store *Store) *Handler {return &Handler{store: store}}func (h *Handler) Routes(mux *http.ServeMux) {mux.HandleFunc("GET /tasks", h.list)mux.HandleFunc("POST /tasks", h.create)mux.HandleFunc("GET /tasks/{id}", h.get)}func (h *Handler) list(w http.ResponseWriter, r *http.Request) {tasks, err := h.store.List(r.Context())if err != nil {http.Error(w, "failed to list tasks", http.StatusInternalServerError)return}writeJSON(w, http.StatusOK, tasks)}func (h *Handler) create(w http.ResponseWriter, r *http.Request) {var t Taskif err := json.NewDecoder(r.Body).Decode(&t); err != nil {http.Error(w, "invalid request body", http.StatusBadRequest)return}created, err := h.store.Create(r.Context(), t)if err != nil {http.Error(w, "failed to create task", http.StatusInternalServerError)return}writeJSON(w, http.StatusCreated, created)}func (h *Handler) get(w http.ResponseWriter, _ *http.Request) {http.Error(w, "not implemented", http.StatusNotImplemented)}func writeJSON(w http.ResponseWriter, status int, v any) {w.Header().Set("Content-Type", "application/json")w.WriteHeader(status)if err := json.NewEncoder(w).Encode(v); err != nil {log.Printf("writeJSON: %v", err)}}
The create method decodes the request body straight into a Task using json.NewDecoder(r.Body).Decode(&t). This is the streaming style we saw in Chapter 17. Whatever the client sends for id or createdAt is overwritten a moment later inside Create, so we do not need to strip those fields out by hand. If the body fails to decode, create returns a plain 400 Bad Request and stops. That is the entire error path for now. We will add a proper JSON error shape and field validation (like checking for an empty title) in Chapter 22.
The list method passes r.Context() down to the store. It now checks the error the store returns, even though our in-memory store never actually produces one.
The get method is untouched. It still answers every GET /tasks/{id} request with a 501. We will replace it in Chapter 21.
Boot the server and create a couple of tasks:
bashmake run
bashcurl -i -X POST localhost:8080/tasks -d '{"title":"Buy milk","description":"2% and oat"}'curl -i -X POST localhost:8080/tasks -d '{"title":"Write the chapter"}'curl -i localhost:8080/tasks

Each POST request comes back 201 Created with the full task, including the new ID and createdAt timestamp. This happens even though the request only sent a title (and sometimes a description). Notice that the second task has no description in its response at all. That field carries the omitempty tag in Go, so an empty string is dropped from the JSON entirely rather than showing up as "description":"". Finally, GET /tasks comes back 200 OK with both tasks inside a JSON array.
One detail to keep in mind: the order you see in that array is not guaranteed. List builds its slice by ranging over a Go map, and map iteration order in Go is intentionally randomized (Chapter 10). The tasks came out 1, 2 here, but on another run, they could easily come out 2, 1. Nothing in this code promises a specific order. Sorting is a job for a real query, which we will handle in the database chapters.
A body that doesn't decode gets a bare 400, not a JSON error:
bashcurl -i -X POST localhost:8080/tasks -d 'not json'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
invalid request body
That plain-text response is intentional for now. In Chapter 22, we will give every error in this API a consistent JSON shape.
A few things are easy to get wrong when building this:
go test -race (or just hammer the endpoint with concurrent requests) on a version without the lock, Go's race detector will eventually complain.null for an empty list: A var tasks []Task starts as nil, and nil slices serialize to null in JSON. Clients expecting an array then have to write special code to handle that. Using make([]Task, 0, ...) avoids the problem entirely.Content-Type: application/json: The writeJSON helper sets this header before writing the status or the body, ensuring every response is correctly labeled. If you skip that header, some HTTP clients will refuse to parse the body as JSON.Chapter 21 finishes the CRUD operations for a single task. We will implement GET, PUT, and DELETE on /tasks/{id}, making sure each endpoint chooses the right status code for a found or missing task.