Step 6 — List, delete, and finish the project
The remaining endpoints, then shutdown, tests and a container.
Topic 64 of 98 · 10 min to read · 50 min to build
After this topic you can
- Build list-with-pagination and delete endpoints, returning 404 when nothing was deleted.
- Add graceful shutdown and one test that covers the main flow.
- Package the finished service in a Dockerfile.
Every command for this step
terminal — run these in order
touch internal/transport/web/handler_test.go Dockerfile Makefile# testsgo test ./... -race -covergo test ./internal/transport/web/ -run TestCreate -v# the containerdocker build -t shortener:latest .docker images shortener:latest # ~15MBdocker run --rm -p 8080:8080 -e DATABASE_URL="$DATABASE_URL" shortener:latest# the four checks before any pushgofmt -l .go vet ./...go test -race ./...go run golang.org/x/vuln/cmd/govulncheck@latest ./...What is left
Three endpoints and the production edges. None of it is new material. It applies what the earlier steps set up, which is what finishing a project usually looks like.
Listing, with pagination from the start
Pagination means returning a page of results rather than all of them. Add it now, not later. With no limit, an endpoint that returns fifty rows today returns fifty thousand next year, all built into one response in memory. Adding a limit at that point breaks clients that expect everything.
internal/store/postgres.go — add this method
func (s *Postgres) List(ctx context.Context, limit, offset int) ([]Link, error) { rows, err := s.db.QueryContext(ctx, `SELECT code, long_url, clicks, created_at FROM links ORDER BY created_at DESC LIMIT $1 OFFSET $2`, limit, offset, ) if err != nil { return nil, err } defer rows.Close() // or you leak a pooled connection out := make([]Link, 0, limit) for rows.Next() { var l Link if err := rows.Scan(&l.Code, &l.LongURL, &l.Clicks, &l.CreatedAt); err != nil { return nil, err } out = append(out, l) } return out, rows.Err() // iteration errors only appear here}internal/transport/web/handler.go — the list handler
func (h *Handler) list(w http.ResponseWriter, r *http.Request) { limit := intParam(r, "limit", 20, 1, 100) // clamped, never unbounded offset := intParam(r, "offset", 0, 0, 1_000_000) links, err := h.svc.List(r.Context(), limit, offset) if err != nil { Error(w, 500, "could not list links") return } JSON(w, 200, links)}// intParam reads a query parameter, falling back to def and clamping the range.func intParam(r *http.Request, key string, def, min, max int) int { v, err := strconv.Atoi(r.URL.Query().Get(key)) if err != nil { return def } return Clamp(v, min, max)}Delete: say when nothing happened
internal/store/postgres.go
func (s *Postgres) Delete(ctx context.Context, code string) error { res, err := s.db.ExecContext(ctx,`DELETE FROM links WHERE code = $1`, code) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound // deleting nothing is not success } return nil}Graceful shutdown
Graceful shutdown means: when told to stop, refuse new connections but let the requests already running finish. Every deployment stops the old process, so this code runs on every release.
cmd/api/main.go — replace srv.ListenAndServe()
errCh := make(chan error, 1)go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err }}()quit := make(chan os.Signal, 1) // MUST be buffered or the signal is lostsignal.Notify(quit, os.Interrupt, syscall.SIGTERM)select {case err := <-errCh: return errcase <-quit: slog.Info("shutting down") ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() return srv.Shutdown(ctx)}One test that matters
internal/transport/web/handler_test.go — create this file
func TestCreateRejectsBadURLs(t *testing.T) { h := newTestHandler(t) tests := []struct { name string body string want int }{ {"valid", `{"url":"https://go.dev"}`, 201}, {"empty", `{"url":""}`, 422}, {"not a url", `{"url":"hello"}`, 422}, {"javascript scheme", `{"url":"javascript:alert(1)"}`, 422}, {"malformed json", `nope`, 400}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest("POST", "/links", strings.NewReader(tt.body)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != tt.want { t.Errorf("status = %d, want %d — body %s", rec.Code, tt.want, rec.Body) } }) }}The Dockerfile
Dockerfile — create this file
FROM golang:1.22-alpine AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /api ./cmd/apiFROM gcr.io/distroless/static-debian12COPY --from=build /api /apiUSER nonroot:nonrootEXPOSE 8080ENTRYPOINT ["/api"]The finished layout
shortener/
go.mod
Dockerfile
cmd/
api/
main.goconfig, wiring, shutdown
internal/
model/types only — imports nothing of yours
link.gothe Link struct and its errors
store/persistence only
db.gothe connection pool
memory.gothe map implementation
postgres.gothe SQL implementation
service/the rules — imports model, never http
code.goshort code generation
shortener.govalidation and orchestration
transport/HTTP only — imports service, never store
web/
respond.goJSON helpers
handler.gohandlers and routes
handler_test.go
migrations/
001_links.up.sql
Worth knowing: You have built a working service: validated input, generated ids, a database, a hot path that stays fast, graceful shutdown, a test and a container. Project two adds what is missing here: knowing who the user is.
The short version
- Paginate every list endpoint from the first version, and clamp the limit rather than rejecting it.
- Check
RowsAffectedso deleting nothing returns 404 instead of pretending it worked. srv.Shutdown(ctx)drains in-flight requests. Buffer the signal channel or you lose the signal.CGO_ENABLED=0plus a distroless base gives a ~15MB image with no shell or package manager for an attacker to use.
Remember: Finishing a project is pagination, shutdown, a test and a container. None of it is hard, and skipping it is what separates a demo from a service.
Build this before moving on
Finish all three endpoints, add shutdown, write the handler test, and build the Docker image. Then check the image size with docker images.Then try these in your own editor
- 1Add an expiry date to links and stop resolving them once it passes.
- 2Add rate limiting to
POST /linksso one IP cannot create a thousand links a minute. - 3Send SIGTERM during a slow request and confirm the request finishes before the process exits.
- 4Load-test the redirect with
heyand see how many requests a second one instance handles.
Official documentation