Skip to content

Table of Contents

03 — Bookstore REST API

Level: Intermediate · Estimated effort: 3–5 days · Track: Backend / Web Services

Overview

This project builds a production-shaped Bookstore CRUD REST API in Go. Unlike the earlier "single main.go" exercises, the focus here is on structure: a clean layered architecture (handler → service → repository), a real relational datastore (PostgreSQL), versioned schema migrations, type-safe queries, input validation, structured logging, and a test suite that exercises each layer with the right tool.

You will expose JSON endpoints for managing books and their authors, with listing, pagination, filtering, and search. The emphasis is not on the domain (it is deliberately simple) but on the engineering patterns you will reuse in every Go service you write afterward: dependency inversion across layers, the repository pattern, the 12-factor config approach, graceful shutdown, and integration testing against a real database via testcontainers-go.

By the end you will have a service that looks and behaves like something you could ship, plus the vocabulary to talk about it in an interview.


Learning Objectives

By completing this project you will be able to:

  • Design and build a layered HTTP service where each layer depends only on the layer below it through interfaces, not concrete types.
  • Use go-chi/chi to build a composable router with middleware (request ID, panic recovery, structured request logging).
  • Talk to PostgreSQL through database/sql-style code backed by the high-performance jackc/pgx driver and a pgxpool connection pool.
  • Generate type-safe query code from raw SQL using sqlc, eliminating hand-written scanning boilerplate and entire classes of runtime errors.
  • Manage schema evolution with versioned, reversible migrations using golang-migrate.
  • Validate request payloads declaratively with go-playground/validator and return a consistent error envelope.
  • Emit structured, leveled logs with the standard library log/slog, propagating a request ID through context.Context.
  • Implement graceful shutdown and 12-factor configuration from environment.
  • Write table-driven unit tests, HTTP handler tests with net/http/httptest, and repository integration tests against a throwaway Postgres container.
  • Separate domain models, persistence models, and API DTOs — and articulate why that separation matters.

Requirements

Functional

  • Books CRUD
  • Create a book (POST), read one (GET /{id}), read many (GET list), update (PUT), and delete (DELETE).
  • A book has a title, ISBN, price, publication year, and a required reference to an existing author.
  • Authors CRUD
  • Create, read (one + list), update, and delete authors.
  • Deleting an author that still has books must fail with a clear 409 Conflict (referential integrity), unless cascading is explicitly requested.
  • List with paginationGET /api/v1/books?page=&limit= returns a page of results plus pagination metadata (total count, page, limit, total pages).
  • FilteringGET /api/v1/books?author={id}&year={yyyy} filters by author and/or publication year.
  • SearchGET /api/v1/books?q={term} performs a case-insensitive search across title (and, as a stretch, author name).
  • Sorting?sort=title|-price|year (a leading - means descending). Only a whitelisted set of sort keys is accepted.

Non-Functional

  • Latency — typical single-row reads complete in < 50 ms locally; list endpoints in < 150 ms at limit=50. All DB calls are context-aware and honor request cancellation/deadlines.
  • Structured logs with request IDs — every request is logged once (method, path, status, duration, request ID) as structured JSON via slog. The request ID is generated by middleware and threaded through context.
  • Graceful shutdown — on SIGINT/SIGTERM, stop accepting new connections, drain in-flight requests within a timeout, then close the DB pool.
  • 12-factor configall configuration (port, DB DSN, log level, timeouts) comes from environment variables with sane defaults; no secrets in code.
  • Migrations are versioned — schema changes ship as numbered up/down SQL files in version control; the schema is never mutated by hand.
  • Errors are consistent — every error response uses a single JSON envelope shape.

Architecture

The service is strictly layered. Requests flow down through the layers; each layer talks to the one below it through an interface, so any layer can be tested in isolation with a fake of the layer beneath it. Logging and middleware are cross-cutting.

flowchart TD
    Client([HTTP Client]) -->|JSON over HTTP| Router

    subgraph HTTP["HTTP Layer (net/http + go-chi/chi)"]
        Router[chi.Router]
        MW["Middleware\n• RequestID\n• Recoverer\n• Request Logger\n• Timeout"]
        Router --> MW
    end

    MW --> Handler

    subgraph App["Application Layers"]
        Handler["Handler\n(decode, DTO map,\nstatus codes)"]
        Service["Service\n(business rules,\nvalidation)"]
        Repo["Repository\n(pgx / sqlc queries)"]
        Handler -->|service iface| Service
        Service -->|repo iface| Repo
    end

    Repo -->|pgxpool| DB[(PostgreSQL)]

    subgraph Cross["Cross-cutting concerns"]
        SLOG["log/slog\nstructured logger"]
        CTX["context.Context\n(request ID, deadline)"]
    end

    SLOG -.observes.- MW
    SLOG -.observes.- Handler
    SLOG -.observes.- Service
    SLOG -.observes.- Repo
    CTX -.flows through.- Handler
    CTX -.flows through.- Service
    CTX -.flows through.- Repo

Dependency rule: dependencies point inward/downward and are expressed as interfaces defined by the consumer. The handler defines (or consumes) a BookService interface; the service defines a BookRepository interface. main.go wires the concrete implementations together (composition root).


Suggested Project Layout

Follows the spirit of golang-standards/project-layout: public entrypoints under cmd/, all private code under internal/.

03-rest-api-bookstore/
├── cmd/
│   └── api/
│       └── main.go                # composition root: config, pool, router, server, shutdown
├── internal/
│   ├── book/                      # the "book" feature, fully self-contained (vertical slice)
│   │   ├── handler.go             # HTTP handlers, decode/encode, status codes
│   │   ├── service.go             # business logic + validation; defines repo interface
│   │   ├── repository.go          # pgx/sqlc-backed implementation of repo interface
│   │   ├── model.go               # domain struct, persistence row, API DTOs + mappers
│   │   ├── service_test.go        # table-driven unit tests with mocked repo
│   │   ├── handler_test.go        # httptest handler tests with mocked service
│   │   └── repository_test.go     # integration tests (testcontainers Postgres)
│   ├── author/                    # mirrors internal/book
│   │   ├── handler.go
│   │   ├── service.go
│   │   ├── repository.go
│   │   └── model.go
│   ├── platform/
│   │   └── postgres/
│   │       ├── postgres.go        # pgxpool.New, ping, health check
│   │       └── tx.go              # transaction helper (optional)
│   ├── middleware/
│   │   ├── requestid.go           # generate + inject request ID
│   │   ├── logger.go              # slog-based request logging
│   │   └── recover.go             # panic recovery -> 500 + error envelope
│   ├── config/
│   │   └── config.go              # env parsing, defaults, validation
│   └── httperr/
│       ├── errors.go              # sentinel/domain errors + HTTP mapping
│       └── respond.go             # JSON encode helpers + error envelope
├── db/
│   └── queries/                   # sqlc input: raw SQL with -- name: directives
│       ├── books.sql
│       └── authors.sql
├── internal/db/                   # sqlc OUTPUT (generated): models.go, *.sql.go, db.go
├── migrations/                    # golang-migrate files
│   ├── 000001_init_schema.up.sql
│   ├── 000001_init_schema.down.sql
│   ├── 000002_add_book_search_index.up.sql
│   └── 000002_add_book_search_index.down.sql
├── api/
│   └── openapi.yaml               # OpenAPI 3 spec
├── deploy/
│   ├── Dockerfile                 # multi-stage build
│   └── docker-compose.yml         # api + postgres
├── sqlc.yaml                      # sqlc codegen config
├── Makefile                       # build, test, migrate, sqlc, lint, run
├── .env.example
├── go.mod
└── go.sum

Data Model / Database

Two tables: authors and books, with a foreign key from books.author_id to authors.id. Primary keys are UUIDs; timestamps are tracked for auditing.

Migration 000001_init_schema.up.sql

CREATE EXTENSION IF NOT EXISTS "pgcrypto";  -- for gen_random_uuid()

CREATE TABLE authors (
    id          UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT         NOT NULL,
    bio         TEXT         NOT NULL DEFAULT '',
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE TABLE books (
    id          UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
    title       TEXT         NOT NULL,
    isbn        TEXT         NOT NULL UNIQUE,
    price_cents INTEGER      NOT NULL CHECK (price_cents >= 0),
    published   INTEGER      NOT NULL CHECK (published BETWEEN 1450 AND 2100),
    author_id   UUID         NOT NULL REFERENCES authors(id) ON DELETE RESTRICT,
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE INDEX idx_books_author_id ON books (author_id);
CREATE INDEX idx_books_published ON books (published);

Money note: prices are stored as price_cents INTEGER to avoid floating-point rounding. The API exposes a decimal price and converts at the boundary.

Migration 000002_add_book_search_index.up.sql

-- Case-insensitive trigram search on title for the ?q= search param.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_books_title_trgm ON books USING gin (title gin_trgm_ops);

Each *.up.sql has a matching *.down.sql that reverses it (DROP INDEX, DROP TABLE, in reverse order).

Three models, one concept

Keep these separate so a change in one boundary (DB column rename, JSON field rename) does not ripple through the whole codebase.

// model.go

// Domain model — the rich, internal representation the service reasons about.
// No JSON or db tags; pure business type.
type Book struct {
    ID        uuid.UUID
    Title     string
    ISBN      string
    PriceCents int64
    Published int
    AuthorID  uuid.UUID
    CreatedAt time.Time
    UpdatedAt time.Time
}

// Persistence model — what the repository scans into. With sqlc this is the
// GENERATED `db.Book` row type (lives in internal/db). Shown here for clarity:
//
//   type Book struct {
//       ID         pgtype.UUID
//       Title      string
//       Isbn       string
//       PriceCents int32
//       ...
//   }
// The repository maps db.Book <-> domain Book.

// API DTOs — the wire contract. JSON + validator tags live ONLY here.
type CreateBookRequest struct {
    Title     string  `json:"title"      validate:"required,max=300"`
    ISBN      string  `json:"isbn"       validate:"required,isbn"`
    Price     float64 `json:"price"      validate:"required,gt=0"`
    Published int     `json:"published"  validate:"required,gte=1450,lte=2100"`
    AuthorID  string  `json:"author_id"  validate:"required,uuid4"`
}

type BookResponse struct {
    ID        string  `json:"id"`
    Title     string  `json:"title"`
    ISBN      string  `json:"isbn"`
    Price     float64 `json:"price"`
    Published int     `json:"published"`
    AuthorID  string  `json:"author_id"`
    CreatedAt string  `json:"created_at"`
    UpdatedAt string  `json:"updated_at"`
}

Mapping functions (toDomain, toResponse, (req).toDomain) live next to the structs. The service never sees JSON tags; the repository never sees validator tags.


API Design

Base path: /api/v1. All requests/responses are application/json. IDs are UUIDs.

Consistent error envelope

Every non-2xx response uses this shape:

{
  "error": {
    "code": "validation_failed",
    "message": "Request body failed validation",
    "request_id": "01J9Z6P8K2QG7XB3M9F1ABCDEF",
    "details": [
      { "field": "title", "rule": "required", "message": "title is required" },
      { "field": "price", "rule": "gt",       "message": "price must be greater than 0" }
    ]
  }
}

details is present only for validation errors; otherwise omitted.

Books

Method Path Description Success
GET /api/v1/books List/search books (paginated) 200
POST /api/v1/books Create a book 201
GET /api/v1/books/{id} Get one book 200
PUT /api/v1/books/{id} Replace/update a book 200
DELETE /api/v1/books/{id} Delete a book 204

GET /api/v1/books query params:

Param Type Default Notes
page int 1 1-based page number
limit int 20 1–100; values above 100 are clamped
author uuid filter by author id
year int filter by publication year
q string case-insensitive title search
sort string title one of title,price,year; prefix - for desc

Example: GET /api/v1/books?author=7c9e...&page=2&limit=50&sort=-price

List response (200):

{
  "data": [
    {
      "id": "b1d2...","title": "The Go Programming Language","isbn": "9780134190440",
      "price": 39.99,"published": 2015,"author_id": "7c9e...",
      "created_at": "2026-06-26T10:00:00Z","updated_at": "2026-06-26T10:00:00Z"
    }
  ],
  "pagination": { "page": 2, "limit": 50, "total": 137, "total_pages": 3 }
}

POST /api/v1/books request body → CreateBookRequest. Responses:

  • 201 Created + BookResponse (and a Location: /api/v1/books/{id} header)
  • 400 Bad Request — malformed JSON
  • 422 Unprocessable Entity — validation failed (envelope with details)
  • 404 Not Found — referenced author_id does not exist
  • 409 Conflict — duplicate ISBN

GET /api/v1/books/{id}200 + BookResponse, or 404 if not found.

PUT /api/v1/books/{id}200 + updated BookResponse; 404/422/409 as above.

DELETE /api/v1/books/{id}204 No Content; 404 if not found.

Authors

Method Path Description Success
GET /api/v1/authors List authors (paginated) 200
POST /api/v1/authors Create an author 201
GET /api/v1/authors/{id} Get one author 200
PUT /api/v1/authors/{id} Update an author 200
DELETE /api/v1/authors/{id} Delete an author 204

DELETE /api/v1/authors/{id} returns 409 Conflict if the author still has books (maps from the Postgres FK ON DELETE RESTRICT violation).

Status code conventions

Situation Code
Read/update success 200
Create success 201
Delete success 204
Malformed JSON / bad query 400
Validation failed 422
Resource not found 404
Uniqueness / FK conflict 409
Unexpected server error 500

Health

  • GET /healthz — liveness, always 200 if the process is up.
  • GET /readyz — readiness, 200 only if the DB pool pings successfully, else 503.

Tech Stack

Concern Choice Import path
HTTP server stdlib net/http net/http
Router + middleware go-chi/chi github.com/go-chi/chi/v5
DB driver / pool jackc/pgx (+ pgxpool) github.com/jackc/pgx/v5, github.com/jackc/pgx/v5/pgxpool
Type-safe queries sqlc (codegen tool) github.com/sqlc-dev/sqlc (CLI)
Migrations golang-migrate github.com/golang-migrate/migrate/v4
Structured logging stdlib log/slog log/slog
Validation go-playground/validator github.com/go-playground/validator/v10
Assertions / mocks testify github.com/stretchr/testify
Integration testing testcontainers-go (real Postgres in a container) github.com/testcontainers/testcontainers-go, .../modules/postgres
UUIDs google/uuid github.com/google/uuid
Containerization Docker + docker-compose

Why these? pgx is the fastest, most actively maintained Postgres driver and is what sqlc targets natively. sqlc gives you compile-time-checked SQL without an ORM. chi is a thin, idiomatic router that plays nicely with stdlib http.Handler. testcontainers-go lets repository tests run against a real Postgres, so you catch SQL/driver issues a mock would hide.


Implementation Milestones

M1 — Scaffolding & config

  • go mod init, add dependencies, create the directory layout.
  • internal/config: parse env (PORT, DATABASE_URL, LOG_LEVEL, READ_TIMEOUT…) with defaults.
  • cmd/api/main.go: build a slog.Logger, start an http.Server that returns 204 from /healthz.
  • Makefile targets: run, build, test, lint, tidy.

M2 — Database & migrations

  • Write 000001_init_schema up/down and 000002_add_book_search_index up/down.
  • internal/platform/postgres: pgxpool.New, ping, and a Close.
  • make migrate-up / make migrate-down wrappers around golang-migrate.
  • Wire /readyz to ping the pool.

M3 — Repository layer (sqlc)

  • Write db/queries/books.sql and authors.sql with -- name: directives.
  • Configure sqlc.yaml (engine postgresql, sqlc-pgx driver) and run sqlc generate.
  • Implement book.Repository / author.Repository over the generated code; map row ↔ domain.
  • Map driver errors (unique violation, FK violation, no rows) to domain errors in httperr.

M4 — Service layer & validation

  • Define BookService/BookRepository interfaces (consumer-defined).
  • Implement business rules (e.g., author must exist before book create).
  • Wire go-playground/validator; centralize translation to details[].

M5 — Handlers & router

  • Implement handlers: decode → validate → call service → encode DTO + status.
  • Mount routes under /api/v1 with chi sub-routers for books and authors.
  • Implement list/pagination/filter/search/sort query parsing with whitelisting.

M6 — Logging, middleware & shutdown

  • RequestID, Recoverer, and slog request-logging middleware.
  • Thread request ID through context into logs and error envelopes.
  • Graceful shutdown on SIGINT/SIGTERM with a drain timeout; close the pool last.

M7 — Tests

  • Table-driven service unit tests with a mocked repository.
  • Handler tests with httptest and a mocked service.
  • Repository integration tests with testcontainers-go Postgres.
  • Hit the coverage targets below.

M8 — Packaging & docs

  • Multi-stage Dockerfile and docker-compose.yml (api + postgres).
  • api/openapi.yaml, README.md, and an ADR for the layering choice.

Testing Strategy

Each layer is tested with the tool that fits its seam. The interfaces between layers are the testing seams.

Service — unit tests (table-driven, mocked repo)

The service depends on a BookRepository interface. In tests, inject a hand-written fake or a testify mock so no database is touched. Cover happy paths and every error branch (author-not-found, duplicate ISBN, validation failures).

func TestBookService_Create(t *testing.T) {
    tests := []struct {
        name    string
        input   CreateBookRequest
        setup   func(m *mockRepo)
        wantErr error
    }{
        {name: "ok", input: validReq(), setup: func(m *mockRepo) {
            m.On("AuthorExists", mock.Anything, mock.Anything).Return(true, nil)
            m.On("Insert", mock.Anything, mock.Anything).Return(savedBook(), nil)
        }, wantErr: nil},
        {name: "missing author", input: validReq(), setup: func(m *mockRepo) {
            m.On("AuthorExists", mock.Anything, mock.Anything).Return(false, nil)
        }, wantErr: httperr.ErrAuthorNotFound},
        // ...duplicate isbn, validation, repo error...
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            repo := &mockRepo{}
            tt.setup(repo)
            svc := NewService(repo, validator.New())
            _, err := svc.Create(context.Background(), tt.input)
            require.ErrorIs(t, err, tt.wantErr)
            repo.AssertExpectations(t)
        })
    }
}

Handlers — net/http/httptest

Construct the handler with a mocked service, build requests with httptest.NewRequest, record with httptest.NewRecorder, and assert on status code, headers (Location), and JSON body (including the error envelope shape). Use chi.NewRouteContext to inject URL params like {id}.

Repository — integration tests (testcontainers-go)

Spin up a real Postgres container, run migrations against it, then exercise the repository's actual SQL. This catches things mocks never could: constraint violations, trigram search behavior, NULL handling, and pgx scanning.

func setupPostgres(t *testing.T) *pgxpool.Pool {
    ctx := context.Background()
    pg, err := postgres.Run(ctx, "postgres:16-alpine",
        postgres.WithDatabase("bookstore_test"),
        postgres.WithUsername("test"), postgres.WithPassword("test"),
        testcontainers.WithWaitStrategy(wait.ForListeningPort("5432/tcp")),
    )
    require.NoError(t, err)
    t.Cleanup(func() { _ = pg.Terminate(ctx) })
    dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
    runMigrations(t, dsn)
    pool, err := pgxpool.New(ctx, dsn)
    require.NoError(t, err)
    t.Cleanup(pool.Close)
    return pool
}

Coverage targets

Layer Target Why
Service ≥ 90 % pure logic, cheap to test exhaustively
Handlers ≥ 80 % cover status mapping + error envelope
Repository key paths integration tests are slower; cover CRUD + constraints
Overall ≥ 80 % go test -coverprofile in CI

Tag integration tests with //go:build integration so unit tests stay fast; run the full suite with go test -tags=integration ./....


Deployment

Multi-stage Dockerfile

# ---- build stage ----
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/api ./cmd/api

# ---- run stage ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/api /api
COPY --from=build /src/migrations /migrations
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/api"]

docker-compose (deploy/docker-compose.yml)

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: bookstore
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
    ports: ["5432:5432"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d bookstore"]
      interval: 5s
      timeout: 3s
      retries: 5
  api:
    build: { context: .., dockerfile: deploy/Dockerfile }
    environment:
      PORT: "8080"
      DATABASE_URL: "postgres://app:secret@db:5432/bookstore?sslmode=disable"
      LOG_LEVEL: "info"
    ports: ["8080:8080"]
    depends_on:
      db: { condition: service_healthy }

Configuration (12-factor)

All config via environment (.env.example documents them): PORT, DATABASE_URL, LOG_LEVEL, READ_TIMEOUT, WRITE_TIMEOUT, SHUTDOWN_TIMEOUT, DB_MAX_CONNS.

Migrations: on startup vs separate step

  • Recommended: run migrations as a separate step (a migrate init container or a make migrate-up job) before the API starts. This keeps the API replicas stateless and avoids races when scaling horizontally.
  • Acceptable for this project: run migrate.Up() once at boot behind a flag (RUN_MIGRATIONS=true) for a single instance. Document the trade-off.

Health & readiness

  • /healthz (liveness) for the orchestrator's restart decisions.
  • /readyz (readiness) gates traffic until the DB pool is reachable; compose/k8s use it to decide when the container is ready.

Documentation Deliverables

  • README.md — what it is, quickstart (docker compose up), env table, Make targets, example curl calls for each endpoint.
  • OpenAPI / Swaggerapi/openapi.yaml (OpenAPI 3) describing every endpoint, schema, and error response; optionally serve Swagger UI at /docs.
  • ADRdocs/adr/0001-layered-architecture.md recording why handler/service/repo layering and the repository pattern were chosen, alternatives considered (e.g. fat handlers, ORM), and consequences.
  • godoc — package- and exported-symbol-level comments so go doc ./... reads cleanly.

Stretch Goals / Future Improvements

  • Authentication & authorization — JWT or API-key middleware; per-route scopes.
  • Caching — read-through cache (in-memory or Redis) for hot GET /books/{id} with invalidation on write; add ETag/Cache-Control.
  • Full-text search — upgrade ?q= to Postgres tsvector/tsquery with ranking, including author name.
  • OpenAPI-first codegen — generate request/response types and the server interface from openapi.yaml (e.g. oapi-codegen) and keep code in sync.
  • Cursor (keyset) pagination — replace offset pagination with a stable cursor for large, frequently-mutated datasets.
  • Optimistic concurrency — add a version column / If-Match ETag to detect lost updates on PUT.
  • Observability — OpenTelemetry traces/metrics, Prometheus endpoint, structured error rates.
  • CI/CD — GitHub Actions running lint + unit + integration (with the testcontainers Postgres) on every PR.

Lessons-Learned Prompts

After finishing, write a few paragraphs answering these:

  1. Layering: Where did you draw the line between "business logic" (service) and "HTTP concerns" (handler)? Name one piece of logic that was tempting to put in the handler — why does it belong in the service?
  2. Dependency inversion: Your service depends on a Repository interface it defines, not on pgx directly. How did that decision change what you could test, and what would break if the interface lived in the repository package instead?
  3. Three models: You kept domain, persistence (sqlc rows), and API DTOs separate. Describe a concrete change (a renamed column, a new JSON field) and trace exactly which files it touched. Was the separation worth the extra mapping code?
  4. Testing seams: You mocked the repo for service tests but used a real Postgres for repository tests. What bug class would a mocked repository have hidden, and what does a handler test with a mocked service actually prove?
  5. Errors & boundaries: How did a Postgres unique_violation become a 409 JSON envelope? List every layer the error passed through and what each one did to it.
  6. Operability: What happens to an in-flight request when the process receives SIGTERM? Walk through graceful shutdown and explain why the DB pool is closed last.

Portfolio & Resume

Resume Bullets

  • Built a production-style REST API in Go (net/http + go-chi) with a strict handler→service→repository layered architecture and dependency inversion via interfaces, backed by PostgreSQL through pgx/pgxpool and sqlc-generated type-safe queries.
  • Achieved 80%+ test coverage with a layered strategy — table-driven unit tests with mocked dependencies, httptest handler tests, and integration tests against a real Postgres using testcontainers-go — and versioned schema migrations via golang-migrate.
  • Engineered the service for operations: structured JSON logging with log/slog and per-request IDs, graceful shutdown, 12-factor config, health/readiness probes, and a multi-stage Docker image deployed via docker-compose.

Interview Talking Points

  • Layered architecture & dependency rule: how each layer depends only on an interface to the layer below, why the composition root lives in main.go, and how that makes the service testable and the persistence/HTTP details swappable.
  • Repository pattern: isolating data access behind an interface; mapping driver errors (unique/FK violations, no rows) to domain errors and then to HTTP status codes at the boundary.
  • testcontainers-go vs mocks: why repository tests run against a real Postgres (catch real SQL/constraint behavior) while service/handler tests use mocks for speed and focus — i.e., choosing the right seam per layer.
  • log/slog & request context: structured, leveled logging with a request ID propagated through context.Context, and how that ties logs back to individual requests and error envelopes.
  • Migrations & schema discipline: versioned, reversible migrations with golang-migrate, and the trade-offs between running them on startup vs as a separate deploy step.