Table of Contents
- 03 — Bookstore REST API
- Overview
- Learning Objectives
- Requirements
- Architecture
- Suggested Project Layout
- Data Model / Database
- API Design
- Tech Stack
- Implementation Milestones
- Testing Strategy
- Deployment
- Documentation Deliverables
- Stretch Goals / Future Improvements
- Lessons-Learned Prompts
- Portfolio & Resume
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/chito 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-performancejackc/pgxdriver and apgxpoolconnection 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/validatorand return a consistent error envelope. - Emit structured, leveled logs with the standard library
log/slog, propagating a request ID throughcontext.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 (GETlist), 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 pagination —
GET /api/v1/books?page=&limit=returns a page of results plus pagination metadata (total count, page, limit, total pages). - Filtering —
GET /api/v1/books?author={id}&year={yyyy}filters by author and/or publication year. - Search —
GET /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 throughcontext. - Graceful shutdown — on
SIGINT/SIGTERM, stop accepting new connections, drain in-flight requests within a timeout, then close the DB pool. - 12-factor config — all 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 INTEGERto avoid floating-point rounding. The API exposes a decimalpriceand 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 aLocation: /api/v1/books/{id}header)400 Bad Request— malformed JSON422 Unprocessable Entity— validation failed (envelope withdetails)404 Not Found— referencedauthor_iddoes not exist409 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, always200if the process is up.GET /readyz— readiness,200only if the DB pool pings successfully, else503.
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?
pgxis the fastest, most actively maintained Postgres driver and is whatsqlctargets natively.sqlcgives you compile-time-checked SQL without an ORM.chiis a thin, idiomatic router that plays nicely with stdlibhttp.Handler.testcontainers-golets 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 aslog.Logger, start anhttp.Serverthat returns204from/healthz. -
Makefiletargets:run,build,test,lint,tidy.
M2 — Database & migrations¶
- Write
000001_init_schemaup/down and000002_add_book_search_indexup/down. -
internal/platform/postgres:pgxpool.New, ping, and aClose. -
make migrate-up/make migrate-downwrappers aroundgolang-migrate. - Wire
/readyzto ping the pool.
M3 — Repository layer (sqlc)¶
- Write
db/queries/books.sqlandauthors.sqlwith-- name:directives. - Configure
sqlc.yaml(enginepostgresql,sqlc-pgxdriver) and runsqlc generate. - Implement
book.Repository/author.Repositoryover 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/BookRepositoryinterfaces (consumer-defined). - Implement business rules (e.g., author must exist before book create).
- Wire
go-playground/validator; centralize translation todetails[].
M5 — Handlers & router¶
- Implement handlers: decode → validate → call service → encode DTO + status.
- Mount routes under
/api/v1with chi sub-routers forbooksandauthors. - 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
contextinto logs and error envelopes. - Graceful shutdown on
SIGINT/SIGTERMwith a drain timeout; close the pool last.
M7 — Tests¶
- Table-driven service unit tests with a mocked repository.
- Handler tests with
httptestand a mocked service. - Repository integration tests with
testcontainers-goPostgres. - Hit the coverage targets below.
M8 — Packaging & docs¶
- Multi-stage
Dockerfileanddocker-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
migrateinit container or amake migrate-upjob) 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, examplecurlcalls for each endpoint.- OpenAPI / Swagger —
api/openapi.yaml(OpenAPI 3) describing every endpoint, schema, and error response; optionally serve Swagger UI at/docs. - ADR —
docs/adr/0001-layered-architecture.mdrecording 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; addETag/Cache-Control. - Full-text search — upgrade
?q=to Postgrestsvector/tsquerywith 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
versioncolumn /If-MatchETagto detect lost updates onPUT. - 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:
- 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?
- Dependency inversion: Your service depends on a
Repositoryinterface it defines, not onpgxdirectly. How did that decision change what you could test, and what would break if the interface lived in the repository package instead? - 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?
- 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?
- Errors & boundaries: How did a Postgres
unique_violationbecome a409JSON envelope? List every layer the error passed through and what each one did to it. - 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,
httptesthandler 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/slogand 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 throughcontext.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.