Frontend Labs
⋯

66. Architecture and package boundaries: services, workers, and seams

66.1 A boring service layout

cmd/server/main.go
internal/config
internal/httpapi
internal/users
internal/postgres
internal/worker
internal/observability
migrations

cmd/server wires dependencies. internal/httpapi owns HTTP transport. internal/users owns business rules. internal/postgres owns database details. internal/worker owns background jobs. This is not a rule; it is a starting shape that keeps transport, business logic, and persistence from collapsing into one package.

66.2 Keep business logic away from HTTP

Prefer:

func (s *Service) Register(ctx context.Context, email string) (User, error)

over:

func (s *Service) Register(w http.ResponseWriter, r *http.Request)

The first can be called from HTTP, CLI, tests, workers, gRPC, or a migration. The second hard-couples business logic to one transport.

66.3 Background workers are part of architecture

type Worker struct {
	log   *slog.Logger
	store Store
	jobs  <-chan Job
}

func (w *Worker) Run(ctx context.Context) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case job, ok := <-w.jobs:
			if !ok {
				return nil
			}
			if err := w.handle(ctx, job); err != nil {
				w.log.ErrorContext(ctx, "job failed", "id", job.ID, "err", err)
			}
		}
	}
}

Workers need the same discipline as HTTP handlers: context, logging, metrics, retries, idempotency, and graceful shutdown.

66.4 Idempotency and retries

If an operation may be retried, give it an idempotency key and store the result:

type PaymentRequest struct {
	ID             string // idempotency key from caller
	AccountID      string
	AmountCents    int64
}

Retries without idempotency are how "charge the card once" becomes "charge the card three times."


Generated from real-life-golang-guide.md · 8 Oct 2026 16:36