Your first service
An inline handler is fine for one route. Past that, features are packaged as services: a constructor, three lifecycle methods, and whatever routes, middleware, and subscriptions the feature owns.
The shape
Section titled “The shape”type Service struct { router *gas.Router bus *gas.EventBus db gas.DatabaseProvider}
// New is the constructor. The DI container supplies every parameter.func New(router *gas.Router, bus *gas.EventBus, db gas.DatabaseProvider) *Service { return &Service{router: router, bus: bus, db: db}}
func (s *Service) Name() string { return "notes" }
func (s *Service) Init() error { s.router.Handle(s.Name(), http.MethodGet, "/notes/{id}", s.show) return nil}
func (s *Service) Close() error { return nil }Three things are happening.
New is an ordinary constructor. Its parameters are dependencies, and the
container fills every one of them in. Nothing in the service reaches out to find
its collaborators, which is what makes it testable: pass fakes and you have a
unit test.
Init is where the service claims what it owns, registering routes,
middleware, and event subscriptions. It runs once, after all singletons are
built, and before the server accepts traffic. Everything registered here is
tagged with the service name, which is what makes runtime
teardown possible.
Close releases whatever the service holds. It runs at shutdown in reverse
initialization order, so a service can still use its dependencies while closing.
Handlers can declare dependencies
Section titled “Handlers can declare dependencies”The router accepts plain http.HandlerFunc handlers, and it also accepts typed
handlers that take gas.Context first, dependencies in the middle, and return
error. Each dependency is resolved from that request’s scope.
// Dependencies declared as parameters are resolved from the per-request scope.func (s *Service) show(ctx gas.Context, db gas.DatabaseProvider) error { note, err := findNote(ctx, db, ctx.Param("id")) if err != nil { return gas.NotFound("note not found").WithCause(err) } return ctx.JSON(http.StatusOK, note)}No RequestScope call, no MustResolve, no manual error writing. Returning a
gas.Error renders it at the right status; returning anything else becomes a
500 with the real error logged. See Handle errors.
At startup the router validates that every handler dependency is registered. A typo fails the build of the app, not the first request that hits the route.
Register it
Section titled “Register it”func register() gas.Option { return gas.WithSingletonService[*Service](New)}Your own services go in under their concrete type, since nothing else needs to swap them out. Infrastructure goes in under its provider interface. That distinction matters more than it looks: see Services and DI.
Services do not import each other
Section titled “Services do not import each other”A service receives the router, the event bus, and provider interfaces. It never imports another service. When two features need to coordinate, they do it through events or through an interface declared in core, so either side can be replaced without touching the other.
- Connect a database and give the service somewhere to store notes.
- Handle errors so failures come back in a consistent shape.
- Test a service with mocks and a real handler stack.