Skip to content

Designing a small Go service ​

The useful question is not “which Go framework should I use?” It is: where should each decision live so a change does not leak through the whole service?

The four boundaries ​

text
HTTP request
    │
    ▼
Transport ── decode, authenticate, encode
    │
    ▼
Application ── authorize, enforce use-case rules
    │
    ▼
Repository ── express reads and state transitions
    │
    ▼
PostgreSQL ── persist constraints and transactions

This is not a requirement to create four packages for every endpoint. It is a rule about ownership:

  • HTTP status codes and headers belong to the transport boundary.
  • “Only an administrator may create a user” belongs to the application boundary.
  • SQL and database-specific errors belong to the repository boundary.
  • Uniqueness, foreign keys, and transactional integrity also belong in the database.

A compact project shape ​

text
cmd/api/main.go          process startup and shutdown
internal/app/            use cases and HTTP composition
internal/store/          PostgreSQL queries and transactions
internal/auth/           password, session, and policy helpers
internal/migrations/     versioned schema changes

Start compact. Split a package when it has a distinct reason to change, not because a diagram says every box must be a directory.

Keep handlers boring ​

A handler should make the request lifecycle visible:

go
func (h *Handler) createNote(w http.ResponseWriter, r *http.Request) {
    user := auth.UserFromContext(r.Context())

    var input CreateNoteInput
    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        writeError(w, http.StatusBadRequest, "invalid_request")
        return
    }

    note, err := h.notes.Create(r.Context(), user.ID, input)
    if err != nil {
        h.writeApplicationError(w, err)
        return
    }

    writeJSON(w, http.StatusCreated, note)
}

Decoding, calling one use case, and mapping the result are enough. Validation that defines the business object should be reusable outside HTTP.

Put invariants in more than one layer ​

Application validation gives precise errors. Database constraints protect state under concurrency and from other writers.

For a unique username:

  1. Normalize and validate before querying.
  2. Declare a unique constraint in PostgreSQL.
  3. Translate the constraint violation into a stable application error.

Checking first without a constraint leaves a race. Relying only on a constraint produces a poor API unless the error is translated.

Configuration is part of the interface ​

Read runtime configuration once during startup, validate it, and pass typed dependencies inward. Avoid reading environment variables from handlers: it hides configuration errors until a particular request path runs.

At minimum, fail startup when these are invalid:

  • listen address;
  • database URL;
  • public origin and cookie security mode;
  • session duration;
  • required secrets.

Shutdown is a state transition ​

Graceful shutdown is not just catching SIGTERM. The service must stop accepting new work, give active requests a bounded completion window, and then close database resources.

The timeout matters. An unbounded shutdown can block a deployment forever; an immediate exit can interrupt a committed response or leave clients retrying an ambiguous operation.

Review checklist ​

  • Can a reader find startup, routing, business rules, and SQL quickly?
  • Are authentication and authorization separate decisions?
  • Do database constraints protect the same invariants as application validation?
  • Are retryable and non-retryable failures distinguishable?
  • Does the health endpoint test the dependency it claims to represent?
  • Can the process shut down within a known bound?

The architecture is successful when these questions stay easy to answer as the service grows.

Original notes, written for understanding.