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.go — NewRouter 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)
Part of #206 (E9). Context: PR #214 review discussion.
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 ConnectSpotService(structspotService+newSpotService)routes.go—NewRouterwires everything onto one*http.ServeMuxerrors.go— shared HTTP error helpersThe 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/httpbecomes a hub coupled to every domain androutes.gogrows 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 aninternal/serverpackage (or stays incmd/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.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/spotwithout connect/proto). Two acceptable shapes:internal/spot+internal/spot/transport), orCross-cutting concerns live at the root, not per-feature
connect.WithInterceptors(...)) passed as sharedoptsto 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
internal/http/routes.go. One service doesn't justify theregistrarindirection.registrarinterface + slice loop, split the composition root out ofinternal/http(which becomes middleware + shared HTTP concerns), and give each feature its ownRegister.Acceptance (when undertaken)
Register(or(path, handler)); domain pkgs stay free of connect/proto.init()-based global route registration.Part of #206 (E9). Context: PR #214 review discussion.