Skip to content

Adopt per-feature handler packages with an explicit registration pattern as services grow #217

Description

@kylejb

Context / current state

As of PR #214, all delivery lives in internal/http:

  • handler.go — REST handlers as funcs (AuthMe, Buoy, Buoys, Status)
  • spot.go — the Connect SpotService (struct spotService + newSpotService)
  • routes.goNewRouter wires everything onto one *http.ServeMux
  • errors.go — shared HTTP error helpers

The domain model is pure in internal/spot (no connect/proto/noaa deps).

This is the right call with one service. The concern is scaling: as more Connect services land (feedback #213/M1-3, forecast E3, etc.), internal/http becomes a hub coupled to every domain and routes.go grows without bound. This ticket captures the target structure and the registration ("stitching") strategy, to revisit later.

Principle: an explicit composition root, not magic

One place builds shared deps once (config, logger, db pool, NDBC client) and wires each feature onto the mux. Today that's NewRouter; it likely becomes an internal/server package (or stays in cmd/api). The key property: wiring is explicit and greppable — you can answer "what routes exist and what do they depend on?" by reading one function.

Mechanism: each feature exposes a registration entry point

Connect helps here: NewXxxServiceHandler(impl) already returns (path, http.Handler). So each feature owns how it routes; the root owns which features exist.

// internal/spot/transport.go  (feature owns this)
func (s *Service) Register(mux *http.ServeMux) {
    mux.Handle(spotv1connect.NewSpotServiceHandler(s, opts...))
}

// composition root — explicit list, compile-time safe
type registrar interface{ Register(*http.ServeMux) }

func NewRouter(deps Deps) http.Handler {
    mux := http.NewServeMux()
    for _, f := range []registrar{
        spot.New(deps),
        feedback.New(deps),
    } {
        f.Register(mux)
    }
    return middleware.Wrap(mux) // CORS, logging, recovery
}

The root still imports each feature (intentional — keeps the dependency graph visible) but no longer knows routing details.

Domain purity

Keep the domain importable without the RPC stack (M0-2's sqlc repository and M1-1's rating engine will want internal/spot without connect/proto). Two acceptable shapes:

  • pure domain pkg + sibling transport pkg (e.g. internal/spot + internal/spot/transport), or
  • domain + handler in one feature pkg, as long as the domain logic stays independently testable.

Cross-cutting concerns live at the root, not per-feature

  • HTTP-level (CORS, request logging, panic recovery): wrap the mux once with middleware.
  • RPC-level (auth, tracing, error mapping): Connect interceptors (connect.WithInterceptors(...)) passed as shared opts to every handler, so auth isn't reimplemented per service.

This is the real payoff: features stop reinventing plumbing.

What to avoid

init()-based global registries (packages auto-registering into a global on import). Looks elegant but is implicit, sensitive to import order, and hurts testability — you can't build a router with a subset of routes for a test. Explicit beats magic.

Staging

  • Now (M0/M1): keep explicit calls in internal/http/routes.go. One service doesn't justify the registrar indirection.
  • At ~3 services: introduce the registrar interface + slice loop, split the composition root out of internal/http (which becomes middleware + shared HTTP concerns), and give each feature its own Register.

Acceptance (when undertaken)

  • Composition root builds shared deps once and registers all features explicitly.
  • Each feature owns its handler + Register (or (path, handler)); domain pkgs stay free of connect/proto.
  • Cross-cutting concerns centralized (HTTP middleware + Connect interceptors); no per-feature duplication.
  • No init()-based global route registration.

Part of #206 (E9). Context: PR #214 review discussion.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    devOpsPull Requests related to DevOps worktech debtHousekeeping

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions