Designing the Service

Turn one requirement into a route list, a Postgres schema and a deployment shape before writing any code.

SyntaxPOST /notes -> 201 + Location: /notes/{id} | GET /notes/{id} -> 200 or 404

Design is the part that happens before the first line of code. It produces three written things: a list of endpoints, a database schema, and a deployment shape. All three are cheap to change now. All three are expensive to change once the service holds real data.

From requirement to endpoints

The requirement for this capstone is small: people save short notes and read them back. Find the nouns in that sentence. There is one, a note. A noun becomes a resource, which means a thing with its own URL. Verbs become HTTP methods, never parts of the path. So POST /notes creates one and GET /notes/{id} returns one. A path like /createNote is the common mistake. For every route, write down the request body, the success status and each failure status. That list is the contract the handlers, the tests and the client all work from.

The schema

A schema is the set of tables and columns the rows live in. Decide the type of the identifier first, because it appears in every URL. Then put the rules the data must always obey into the database itself: NOT NULL, a length check, a unique index. Application code can be bypassed by a migration, a script or a second service. The database cannot.

The deployment shape

The shape is how the running thing is arranged. Here: one stateless process, meaning it keeps nothing on local disk it will need again; every setting read from the environment; three copies behind one address; Postgres outside the pods; two paths the cluster can probe. Writing that down now decides what the code must honour later. A session kept in memory, for instance, is already ruled out.

Example

Example · go
// Command design prints the API contract for the notes service. It is the
// design written as code: the resource, the storage contract, the routes.
package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

// Note is the one resource this service exposes.
type Note struct {
	ID      string    `json:"id"`
	Text    string    `json:"text"`
	Created time.Time `json:"created_at"`
}

// ErrNotFound is what the store returns when no row has that id.
var ErrNotFound = errors.New("note not found")

// Store is everything the service needs from Postgres, and nothing more.
// Deciding it before writing any SQL keeps the rules free of database detail.
type Store interface {
	Create(ctx context.Context, text string) (Note, error)
	Get(ctx context.Context, id string) (Note, error)
	List(ctx context.Context, limit int) ([]Note, error)
	Delete(ctx context.Context, id string) error
}

// route is one line of the contract. Two field names on one line is
// shorthand for two lines of the same type: "pattern string" followed by
// "failure string". The two forms declare exactly the same fields.
type route struct {
	pattern, failure string
	success          int
}

func main() {
	routes := []route{
		{pattern: "POST /notes", failure: "400 invalid body", success: 201},
		{pattern: "GET /notes", failure: "400 bad limit", success: 200},
		{pattern: "GET /notes/{id}", failure: "404 unknown id", success: 200},
		{pattern: "DELETE /notes/{id}", failure: "404 unknown id", success: 204},
		{pattern: "GET /healthz", failure: "none", success: 200},
		{pattern: "GET /readyz", failure: "503 store unreachable", success: 200},
	}
	for _, r := range routes {
		fmt.Printf("%-20s -> %d  (%s)\n", r.pattern, r.success, r.failure)
	}
}

// Output:
// POST /notes          -> 201  (400 invalid body)
// GET /notes           -> 200  (400 bad limit)
// GET /notes/{id}      -> 200  (404 unknown id)
// DELETE /notes/{id}   -> 204  (404 unknown id)
// GET /healthz         -> 200  (none)
// GET /readyz          -> 200  (503 store unreachable)

When to use it

  • Turn a one-line feature request into a route list a frontend developer can build against before the backend exists.
  • Settle the identifier type, the required columns and the constraints in a migration file before the first query is written.
  • Decide up front that the service keeps no local state, so it can run as several replicas behind one address.

More examples

The schema, as the first migration

Three decisions are visible here. The identifier is a uuid generated by the database: gen_random_uuid is built into Postgres 13 and later, with no extension to install. The column is called body rather than text, because text is also a type name in Postgres and a column with that name makes later queries hard to read; the Go field stays Text. The CHECK constraint repeats the rule the service already enforces, because the service is not the only thing that will ever write to this table. Write the schema at design time, as a numbered migration file, not as commands typed into a database.

Example · sql
-- 0001_create_notes.sql
-- Applied before the service accepts its first request.
CREATE TABLE notes (
    id         uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    body       text        NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    -- btrim removes whitespace from both ends, so "   " is rejected too.
    CONSTRAINT body_not_blank CHECK (length(btrim(body)) > 0)
);

-- The list endpoint returns newest first, so give it an index to walk
-- instead of sorting the whole table on every request.
CREATE INDEX notes_created_at_idx ON notes (created_at DESC, id DESC);

One route written out in full

Writing the request and both responses out as text settles questions that are otherwise settled by accident in the handler: the create call answers 201 rather than 200, it returns the created note so the client does not need a second request, and it sets a Location header pointing at the new resource. The failure body has the same shape for every endpoint, so a client writes one error path. Do this for each route in the list before building anything; it becomes the assertions in the tests.

Example · http
POST /notes HTTP/1.1
Host: notes.example.com
Content-Type: application/json

{"text":"buy milk"}


HTTP/1.1 201 Created
Content-Type: application/json
Location: /notes/8e2f1d44-1f2a-4a1e-9c3b-0b5f9c7a1d20

{"id":"8e2f1d44-1f2a-4a1e-9c3b-0b5f9c7a1d20","text":"buy milk","created_at":"2026-03-01T12:00:00Z"}


HTTP/1.1 400 Bad Request
Content-Type: application/json

{"error":"invalid","fields":[{"field":"text","message":"must not be blank"}]}

The deployment shape, sketched

The shape is a set of constraints on the code, not a deployment file yet. Three replicas means no handler may keep anything in memory that a later request needs. Configuration from the environment means no build produces an environment-specific image. A readiness path means the program has to know whether its database pool is working. Agree these first, because each one is a rewrite if it arrives after the handlers do.

Example · yaml
# Not the final manifest, just the shape, agreed before any code is written.
service: notes
replicas: 3            # more than one, so no request is lost during a deploy
listens_on: 8080       # above 1024, because the container runs as non-root
state: none            # nothing on local disk, so any replica serves any request
config_from_env:
  - PORT
  - DATABASE_URL       # from a Secret, never baked into the image
  - LOG_LEVEL
database: postgres     # its own thing, outside the pods
probes:
  liveness: /healthz   # process only
  readiness: /readyz   # checks the database pool
limits:
  memory: 256Mi

Discussion

  • Be the first to comment on this lesson.