Chapter 22 finished the in-memory API's behavior: validation, status codes, and a consistent JSON error shape. This chapter changes none of that behavior and instead reorganizes the code underneath into clean layers.
Right now, internal/task/handler.go does three jobs at once. It decodes HTTP requests, calls Validate(), and talks to the store directly. That works for a simple app, but it welds the HTTP layer and the storage layer together. We are going to split this code into three distinct responsibilities, connecting them through an interface.

Here is what changes in this chapter:
Service sits between the handlers and storage. It holds the validation and business rules that used to live in handler.go.TaskStore interface describes exactly what the service needs from storage. It requires five methods and nothing more. The concrete in-memory Store satisfies this interface.*Service, and main.go wires the three layers together at startup.Every curl request in this chapter returns the exact same status code and body as it did in Chapter 22. This is a pure refactoring exercise.
Interface. A set of method signatures with no implementation. In Chapter 11, you saw that Go checks interface satisfaction implicitly. A type satisfies an interface just by having the matching methods, without needing an implements keyword. TaskStore is an interface. Any type with a Create, List, Get, Update, and Delete method matching its signatures satisfies it.
Dependency injection. Passing a dependency into a constructor instead of creating it inside that constructor. NewService(store) takes a TaskStore as an argument rather than building one itself. This means the caller decides which implementation to hand over. We can pass the real store today, and a fake one in tests later.
Service layer. The code between HTTP handling and storage that holds business rules. Here, it is deliberately thin. It runs Validate() and forwards the call to the store.
The handlers already call store.Create, store.Get, and so on directly. That works fine when you only have one store. It stops working the moment you need a second one. We are about to need two: a fake store for fast tests in Section 5, and a real PostgreSQL store in Section 6.
If handlers hold a concrete *Store, swapping storage means editing every single handler. If handlers hold an interface instead, swapping storage means writing a new type that satisfies the interface and changing one line in main.go. The interface acts as a seam. It is a specific place where the code is designed to change without disturbing anything around it.
Chapter 11 built a TaskStore interface as a teaching demo, but this is the first time one earns its place in the real internal/task package, rather than back in Chapter 19 when the layout was only sketched. Writing an interface when you only have one implementation is often a guess about the future. Writing an interface right before a second implementation shows up is a solid plan.
The interface lives in a new file, internal/task/service.go, right next to the service that uses it:
go// internal/task/service.gotype TaskStore interface {Create(ctx context.Context, t Task) (Task, error)List(ctx context.Context) ([]Task, error)Get(ctx context.Context, id int) (Task, error)Update(ctx context.Context, id int, t Task) (Task, error)Delete(ctx context.Context, id int) error}
These five methods are exactly the ones Store already has from Chapters 20 and 21. Each takes context.Context as its first parameter, just as Chapter 18 established. Nothing about the store's method signatures changes. We are simply naming a contract that already exists in practice and making it explicit in the code.
Over in internal/task/store.go, we add one line right after the Store type declaration:
go// internal/task/store.govar _ TaskStore = (*Store)(nil)
This is a compile-time assertion. It declares a variable of type TaskStore, assigns it a nil *Store, and throws the result away. The blank identifier _ means we never use the variable. The only reason this line exists is to force the compiler to check, at build time, that *Store really implements every method TaskStore requires.
If a method's name or signature ever drifts (for example, if someone renames Delete to Remove), the build fails right here with a clear message. This is much better than failing later at a random call site that tried to use *Store as a TaskStore. It costs nothing at runtime because the assignment is erased before the binary even runs.
Service is the new layer sitting between the handlers and the storage:
go// internal/task/service.gotype Service struct {store TaskStore}func NewService(store TaskStore) *Service {return &Service{store: store}}
Notice the field type is store TaskStore, not store *Store. Service only knows about the interface. It has no idea whether the concrete type behind it is a map in memory or a PostgreSQL connection pool, and it does not need to know.
The Create and Update methods are where the validation logic now lives:
go// internal/task/service.gofunc (s *Service) Create(ctx context.Context, t Task) (Task, error) {if err := t.Validate(); err != nil {return Task{}, err}return s.store.Create(ctx, t)}func (s *Service) Update(ctx context.Context, id int, t Task) (Task, error) {if err := t.Validate(); err != nil {return Task{}, err}return s.store.Update(ctx, id, t)}
List, Get, and Delete have no business rules to enforce, so they just forward the call directly to the store:
go// internal/task/service.gofunc (s *Service) List(ctx context.Context) ([]Task, error) {return s.store.List(ctx)}func (s *Service) Get(ctx context.Context, id int) (Task, error) {return s.store.Get(ctx, id)}func (s *Service) Delete(ctx context.Context, id int) error {return s.store.Delete(ctx, id)}
This is a deliberately small service. A common mistake here is over-engineering. People often add a repository package, a dto package, and an interface for every struct before there is a second implementation to justify any of it. We are keeping one internal/task package with four files: task.go, store.go, service.go, and handler.go. That is all this API needs today. You should usually split into subpackages only when a real second consumer forces the question. Even then, watch the import direction. Storage must never import handlers, or you will create an import cycle.
internal/task/handler.go changes shape to match this new design. Handler now holds a *Service:
go// internal/task/handler.gotype Handler struct {svc *Service}func NewHandler(svc *Service) *Handler {return &Handler{svc: svc}}
The create method no longer calls Validate() or a store directly. It decodes the request, calls the service, and encodes the result:
go// internal/task/handler.gofunc (h *Handler) create(w http.ResponseWriter, r *http.Request) {var t Taskif err := json.NewDecoder(r.Body).Decode(&t); err != nil {writeError(w, http.StatusBadRequest, "invalid request body")return}created, err := h.svc.Create(r.Context(), t)if err != nil {writeServiceError(w, err)return}writeJSON(w, http.StatusCreated, created)}
The body-decode failure and the id-parse failure in get, update, and delete stay in the handler. Those are HTTP-layer concerns, not business rules, so they belong here rather than in the service.
We rename writeStoreError from Chapter 22 to writeServiceError. It now handles three outcomes instead of two, because validation errors arrive from the service instead of being checked inline:
go// internal/task/handler.gofunc writeServiceError(w http.ResponseWriter, err error) {switch {case errors.Is(err, ErrTitleRequired), errors.Is(err, ErrTitleTooLong):writeError(w, http.StatusBadRequest, err.Error())case errors.Is(err, ErrNotFound):writeError(w, http.StatusNotFound, "task not found")default:log.Printf("unexpected service error: %v", err)writeError(w, http.StatusInternalServerError, "something went wrong")}}
The handler never mentions *Store anywhere in this file. If a handler needed to import a specific storage package to compile, the storage details would have leaked into the HTTP layer. Swapping storage later would then mean editing the handlers all over again.
Something in the application has to know all three concrete types exist and connect them. That place is cmd/server/main.go, often called the composition root:
go// cmd/server/main.gostore := task.NewStore()svc := task.NewService(store)handler := task.NewHandler(svc)handler.Routes(mux)
We have three lines and three constructors, each injecting its dependency into the next. main.go is the only file in the program that knows a *Store exists. When Chapter 30 swaps in a Postgres-backed store, this is the only file that changes. handler.go and service.go stay exactly as they are because neither one names *Store: the service depends on the TaskStore interface, and the handler depends on the service.
Run the server the same way as before:
bashmake run

The same four requests from Chapter 22 (an empty title, a valid title, a missing task, and a delete) come back with the exact same status codes and JSON bodies. The validation sentinels (ErrTitleRequired, ErrTitleTooLong) and the store's ErrNotFound flow through the service unchanged. writeServiceError still maps them to the same 400s and 404s it always did. Build, vet, and golangci-lint run all stay clean.
Section 5 starts by testing these handlers with net/http/httptest. Because Service depends on the TaskStore interface instead of the concrete store, those tests can inject a fake store and skip the database entirely.