Skip to content

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.

notes/service.go
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.

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.

notes/service.go
// 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.

main.go
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.

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.

  1. Connect a database and give the service somewhere to store notes.
  2. Handle errors so failures come back in a consistent shape.
  3. Test a service with mocks and a real handler stack.