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."