Structuring a Service

Split a Go API into handler, service and store layers, joined by interfaces, with no framework involved.

Syntaxtype Service struct{ store Store } // handler -> service -> store, never backwards

A REST API is a program that answers HTTP requests with data, almost always JSON. Once it has more than a couple of handlers, the real question is where each piece of code lives. Go ships no framework that decides this for you, and you do not need one. The common shape is three layers of plain Go code.

The three layers

A handler deals with HTTP and nothing else. It reads the request, calls one method, then writes a status code and a body. A service holds the rules: what counts as valid, what happens in which order, what counts as a conflict. A store is the code that talks to the database. Dependencies point one way. The handler knows the service. The service knows the store. Nothing points back.

How the layers meet

The service does not name a concrete store type. It declares an interface listing the few methods it needs. The real store satisfies that interface simply by having those methods. That is what lets a test hand the service a fake store. Each layer is a struct whose fields are the things it depends on, and main builds them in order: store, then service, then handlers.

What trips people up

Two habits cause most of the pain. The first is a handler running database queries, which welds your JSON shape onto your table shape. The second is passing *http.Request downwards. A service that takes a request can only ever be called by a web server, so it cannot be reused by a background worker or a command-line tool. Pass a context.Context and plain values instead.

Example

Example · go
package main

import (
	"errors"
	"fmt"
)

type Note struct {
	ID   string
	Text string
}

// ErrNotFound is the store's way of saying "nothing has that id".
var ErrNotFound = errors.New("note not found")

// Store is what the service needs from storage, and nothing more.
type Store interface {
	Get(id string) (Note, error)
}

// memStore is a map that satisfies Store. A database store would too.
type memStore map[string]Note

func (m memStore) Get(id string) (Note, error) {
	n, ok := m[id]
	if !ok {
		return Note{}, ErrNotFound
	}
	return n, nil
}

// Service holds the rules. It never mentions HTTP.
type Service struct {
	store Store
}

func (s Service) Find(id string) (Note, error) {
	return s.store.Get(id)
}

func main() {
	svc := Service{store: memStore{"1": {ID: "1", Text: "buy milk"}}}

	n, err := svc.Find("1")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(n.Text)

	_, err = svc.Find("2")
	fmt.Println("missing:", errors.Is(err, ErrNotFound))
}

// Output:
// buy milk
// missing: true

When to use it

  • Splitting a single 900-line main.go into layers without picking a web framework.
  • Letting the same note-creation rules run from an HTTP handler and from a nightly import job.
  • Testing business rules with a stub store so the test suite needs no database.

More examples

The handler layer on top

The whole handler is HTTP plumbing: pull the id out of the path, call one service method, turn an error into a status code, encode the result. There is no rule and no query in it. Because the rules live in Service, the same logic can later be exposed over gRPC or run from a worker without being rewritten.

Example · go
package main

import (
	"encoding/json"
	"errors"
	"net/http"
)

// api is the handler layer. It holds the service, not the database.
type api struct {
	svc Service
}

func (a api) getNote(w http.ResponseWriter, r *http.Request) {
	n, err := a.svc.Find(r.PathValue("id"))
	if errors.Is(err, ErrNotFound) {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	body, err := json.Marshal(n)
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	if _, err := w.Write(body); err != nil {
		return
	}
}

func routes(a api) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /notes/{id}", a.getNote)
	return mux
}

Where the files go

One package per layer, not one per file. Packages under internal/ cannot be imported by any other module, so the service and store stay private to this program while still being separate packages. cmd/api holds the main package; everything it needs comes from internal/. Reach for this layout when a single main.go passes a few hundred lines.

Example · text
notes/
  go.mod
  cmd/api/main.go          # wiring only: config, store, service, server
  internal/note/note.go    # Note, ErrNotFound, Service, the Store interface
  internal/note/memory.go  # a Store implementation
  internal/api/api.go      # handlers, routes, the JSON request and reply shapes

A stub store, so the service is testable alone

This is the payoff for declaring Store as an interface inside the service's own package. The test needs no database, no HTTP server and no mocking library: one small struct with two fields covers every case the service must handle, including storage failures that are hard to trigger for real.

Example · go
package main

import (
	"errors"
	"testing"
)

// stubStore returns whatever the test puts in it. It satisfies Store because
// it has the one method Store lists. Nothing had to be registered anywhere.
type stubStore struct {
	note Note
	err  error
}

func (s stubStore) Get(id string) (Note, error) {
	return s.note, s.err
}

func TestFindMissing(t *testing.T) {
	svc := Service{store: stubStore{err: ErrNotFound}}

	if _, err := svc.Find("nope"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("err = %v, want ErrNotFound", err)
	}
}

Discussion

  • Be the first to comment on this lesson.