Dev Skills

Practice 2 — CRUD, split into layers

The same task list, now in packages with a real database behind it.

Topic 56 of 98 · 10 min to read · 50 min to build
After this topic you can
  • 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

taskapi/
go.mod
cmd/every runnable program lives under here
api/this one builds the ./api binary
main.goconnects the pieces, holds no logic
internal/private — no other project can import this
task/the Task type and its storage
task.gothe struct and its validation
store.gothe SQL. Knows nothing about HTTP.
store_test.gotested against a real database
api/the HTTP layer
handler.godecode, call the store, encode
routes.gowhich path goes to which handler
database/opening the connection pool
db.go
migrations/the schema, as numbered .sql files
001_tasks.up.sql
Each folder has a single job, and imports only ever point one way: handler → store → database.

What each folder is for

FolderHoldsMay importMust never import
cmd/api/one main.go that builds everything and starts the servereverything—
internal/task/the Task type and all its SQLdatabase/sqlnet/http
internal/api/handlers and routesnet/http, internal/taskdatabase/sql
internal/database/opening and configuring the pooldatabase/sqlanything of yours
migrations/the schema as .sql files— (not Go code)—
Watch out: That last column is the rule the whole layout rests on. If internal/task ever imports net/http, you can no longer test your storage without HTTP, and the split has bought you nothing.

The schema

migrations/001_tasks.up.sql
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.

internal/task/store.go
package task
type 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
}
ErrNotFound is defined in this package. The HTTP layer turns it into a 404 without ever knowing SQL exists.

The HTTP layer

internal/api/handler.go
package api
type 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

cmd/api/main.go
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()
}
This is dependency injection. There is no framework doing it: you pass things into constructors, in order, by hand.

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 httptest and a fake store.
  • Gained: data survives a restart, and the database enforces rules Go code could forget.
  • Gained: main.go shows the whole shape of the program on one screen.
  • Not gained: a second feature, say users, means a new internal/user folder plus another handler file in internal/api. Each feature is now spread across folders. That is where this layout starts to strain, and it is what practice project three fixes.
The short version
  • 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 imports database/sql.
  • Translate driver errors like sql.ErrNoRows into your own errors at the storage boundary.
Remember: A layer boundary is only real if the import rule holds. The moment your storage code imports net/http, the folders are decoration.
Build this before moving on
Rebuild practice project one in this layout, backed by Postgres in Docker. All five routes must behave identically to the map version — same statuses, same JSON.
Then try these in your own editor
  1. 1Try importing net/http inside internal/task. Nothing stops you, which is why the rule has to be a habit.
  2. 2Write a store test against a real database, and truncate the table with t.Cleanup.
  3. 3Add a users table and a second store, and notice how many folders you have to touch.
  4. 4Stop Postgres and hit the API. You should get a clean 500 and a useful log line, not a panic.
Check yourselfTake the Practice projects test