Dev Skills

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
# tests
go test ./... -race -cover
go test ./internal/transport/web/ -run TestCreate -v
# the container
docker build -t shortener:latest .
docker images shortener:latest # ~15MB
docker run --rm -p 8080:8080 -e DATABASE_URL="$DATABASE_URL" shortener:latest
# the four checks before any push
gofmt -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
}
Three things people forget in this loop: Close, checking each Scan, and rows.Err() at the end.
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)
}
Clamp rather than reject. A client asking for 5000 gets 100 back, not a 400 they have to handle.

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
}
A DELETE that matches no rows is not an error in SQL. Checking RowsAffected is how you turn it into a 404.

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 lost
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
select {
case err := <-errCh:
return err
case <-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)
}
})
}
}
httptest runs your real handler with no network involved. Adding the sixth case is one line.

The Dockerfile

Dockerfile — create this file
FROM golang:1.22-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 /api ./cmd/api
FROM gcr.io/distroless/static-debian12
COPY --from=build /api /api
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/api"]
Around 15MB, with no shell and no package manager. CGO_ENABLED=0 produces a static binary, which is what lets it run on an image with no C library.

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
The finished layered layout. Every folder answers one question: what KIND of code is this?
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 RowsAffected so 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=0 plus 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
  1. 1Add an expiry date to links and stop resolving them once it passes.
  2. 2Add rate limiting to POST /links so one IP cannot create a thousand links a minute.
  3. 3Send SIGTERM during a slow request and confirm the request finishes before the process exits.
  4. 4Load-test the redirect with hey and see how many requests a second one instance handles.
Check yourselfTake the Project 1 — URL shortener test