Practice 2 — CRUD, split into layers
The same task list, now in packages with a real database behind it.
- Split a CRUD API into packages for HTTP, storage and wiring.
- Replace an in-memory store with a Postgres-backed one behind the same methods.
- Explain what splitting into layers gains and what it costs.
Why split it at all
The one-file version works. It stops working when the file gets long enough that you cannot find anything, or when you want to test the logic without starting an HTTP server.
Splitting into layers means separating code by the job it does: one part talks to the outside world, one part holds the rules, one part talks to storage. Each part can then be read, changed and tested without the others.
This second version keeps things simple by grouping folders by layer — all the storage in one folder, all the HTTP in another. It is a layout you will meet often in other people's code, and it is a good step up from one file. Its limits show up in practice project three.
The layout
What each folder is for
| Folder | Holds | May import | Must never import |
|---|---|---|---|
cmd/api/ | one main.go that builds everything and starts the server | everything | — |
internal/task/ | the Task type and all its SQL | database/sql | net/http |
internal/api/ | handlers and routes | net/http, internal/task | database/sql |
internal/database/ | opening and configuring the pool | database/sql | anything of yours |
migrations/ | the schema as .sql files | — (not Go code) | — |
internal/task ever imports net/http, you can no longer test your storage without HTTP, and the split has bought you nothing.The schema
CREATE TABLE tasks ( id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now());The store, now backed by Postgres
The methods do the same jobs as the map version. They are now exported and take a context.Context, but the shape of each call is the same. Only the inside changed, which is what a layer boundary is supposed to give you.
package tasktype Store struct{ db *sql.DB }func NewStore(db *sql.DB) *Store { return &Store{db: db} }func (s *Store) Create(ctx context.Context, t *Task) error { return s.db.QueryRowContext(ctx, `INSERT INTO tasks (title) VALUES ($1) RETURNING id, done, created_at`, t.Title, ).Scan(&t.ID, &t.Done, &t.CreatedAt)}func (s *Store) GetByID(ctx context.Context, id int64) (*Task, error) { var t Task err := s.db.QueryRowContext(ctx, `SELECT id, title, done, created_at FROM tasks WHERE id = $1`, id, ).Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound // translate to OUR error, not the driver's } if err != nil { return nil, err } return &t, nil}func (s *Store) Delete(ctx context.Context, id int64) error { res, err := s.db.ExecContext(ctx, `DELETE FROM tasks WHERE id = $1`, id) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil}The HTTP layer
package apitype Handler struct{ tasks *task.Store }func NewHandler(t *task.Store) *Handler { return &Handler{tasks: t} }func (h *Handler) get(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { ErrorJSON(w, 400, "invalid id") return } t, err := h.tasks.GetByID(r.Context(), id) // pass the request context down if err != nil { fromError(w, err) // one place maps errors to statuses return } WriteJSON(w, 200, t)}// fromError is the only function that decides HTTP status codes.func fromError(w http.ResponseWriter, err error) { switch { case errors.Is(err, task.ErrNotFound): ErrorJSON(w, 404, "not found") case errors.Is(err, task.ErrValidation): ErrorJSON(w, 422, err.Error()) default: slog.Error("unhandled", "err", err) ErrorJSON(w, 500, "internal server error") }}main.go builds the chain
func main() { if err := run(); err != nil { slog.Error("fatal", "err", err) os.Exit(1) // one exit, at the top, so every defer still runs }}func run() error { db, err := database.Open(os.Getenv("DATABASE_URL")) if err != nil { return err } defer db.Close() store := task.NewStore(db) // build from the inside out h := api.NewHandler(store) mux := http.NewServeMux() h.Routes(mux) srv := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, } return srv.ListenAndServe()}What you gained, and what you did not
- Gained: the store can be tested against a real database with no HTTP involved, and the handlers can be tested with
httptestand a fake store. - Gained: data survives a restart, and the database enforces rules Go code could forget.
- Gained:
main.goshows the whole shape of the program on one screen. - Not gained: a second feature, say
users, means a newinternal/userfolder plus another handler file ininternal/api. Each feature is now spread across folders. That is where this layout starts to strain, and it is what practice project three fixes.
- Splitting into layers means each part has one job and can be tested without the others.
cmd/holds runnable programs.internal/is private and the compiler enforces that.- The storage layer never imports
net/http; the HTTP layer never importsdatabase/sql. - Translate driver errors like
sql.ErrNoRowsinto your own errors at the storage boundary.
net/http, the folders are decoration.- 1Try importing
net/httpinsideinternal/task. Nothing stops you, which is why the rule has to be a habit. - 2Write a store test against a real database, and truncate the table with
t.Cleanup. - 3Add a
userstable and a second store, and notice how many folders you have to touch. - 4Stop Postgres and hit the API. You should get a clean 500 and a useful log line, not a panic.