Skip to content

Ownership and teardown

Every route, named middleware, and event subscription in Gas records the service that registered it. That bookkeeping is what makes the kill switch possible: one service can be closed at runtime, and everything it registered stops serving, without restarting the process.

Ownership is set by the first argument of each registration call, which is the service doing the registering; from inside a service’s Init, that is the receiver.

Registration Owned by
router.Handle(s, method, path, h), and Get, Post, Put, Patch, Delete, Head s
router.Register(s, name, mw), a named middleware s
bus.SubscribeWithOwner[E](s, fn) s
bus.Subscribe[E](fn) Nobody; no teardown removes it
gas.MiddlewareFunc(fn) or gas.MiddlewareFuncWithName(name, fn) used inline Nobody; no teardown disables it

Ownership is recorded by the service’s Name(). Passing nil as the owner of a route or a named middleware makes the router itself (gas/router) the owner, so closing one of your services never removes it.

auth.go
// Auth owns a named middleware and one route.
type Auth struct{ router *gas.Router }
func NewAuth(router *gas.Router) *Auth { return &Auth{router: router} }
func (s *Auth) Name() string { return "auth" }
func (s *Auth) Init(context.Context) error {
// Register ties the name "require-auth" to this service.
s.router.Register(s, "require-auth", requireAuth)
s.router.Get(s, "/auth/me", func(ctx gas.Context) error {
return ctx.Text(http.StatusOK, "me")
})
return nil
}
func (s *Auth) Close(context.Context) error { return nil }
billing.go
// Billing owns its route, but guards it with auth's middleware.
type Billing struct{ router *gas.Router }
func NewBilling(router *gas.Router) *Billing { return &Billing{router: router} }
func (s *Billing) Name() string { return "billing" }
func (s *Billing) Init(context.Context) error {
s.router.Route("/api", func(sub *gas.Router) {
sub.Use(gas.MiddlewareByName("require-auth"))
sub.Get(s, "/invoices", func(ctx gas.Context) error {
return ctx.Text(http.StatusOK, "invoices")
})
})
return nil
}
func (s *Billing) Close(context.Context) error { return nil }

router.Routes() returns the registered routes grouped by owning service (the HEAD route registered alongside each GET is left out), and router.NamedMiddleware() maps each named middleware to its owner. app.Run() logs the same information at startup: as one table in the development environment, and as one route registered line per route otherwise.

main.go
// Tear auth down at runtime. Its route and its named middleware now
// answer 503, including on billing's route.
if err := app.CloseService[*Auth](ctx); err != nil {
log.Fatal(err)
}

CloseService[T](ctx) takes the type the service was registered under, matched exactly. It is a method on the Worker, so an App has it too. In order, it:

  1. disables everything the service registered on the router (App only; see below),
  2. removes every subscription the service made with SubscribeWithOwner,
  3. calls the service’s Close(ctx),
  4. removes the service from the active services,
  5. emits gas.SystemServiceClosed with the service’s name, and waits for its subscribers.

Routes and subscriptions go first, so they are gone even if Close fails. An error from Close is logged as service close failed and not returned, since the teardown has already happened.

CloseService returns an error when T has no built instance (it was never built, or it is registered under a different type), when the service is not active (it was already closed), or once shutdown has begun. Only singletons, including instances, have a built instance to close.

A closed service no longer appears in ActiveServices(), is left out of health and readiness checks, and is not closed a second time at shutdown.

After the teardown, every route the closed service owns answers 503 Service Unavailable in the unified error shape, including the HEAD route that came with each GET. The body does not name the service:

{
"error": {
"status": 503,
"code": "service_unavailable",
"message": "service unavailable"
}
}

A client that asks for text/html without also accepting application/json gets the message as plain text instead.

Every named middleware the service registered is replaced with a 503 as well, wherever it is referenced: on routes owned by other services that are still running, and inside Group and Route blocks. In the sample, billing is still running, but its route uses auth’s require-auth:

GET /api/invoices -> 200 invoices
GET /auth/me -> 503
{
"error": {
"status": 503,
"code": "service_unavailable",
"message": "service unavailable"
}
}
GET /api/invoices -> 503
{
"error": {
"status": 503,
"code": "service_unavailable",
"message": "service unavailable"
}
}

The router applies the change by building a new routing tree and swapping it in atomically. Requests already in flight finish on the previous tree.

main.go
// Run auth's Init again on the same instance. It registers its route and
// middleware again, which brings both back.
if err := app.RestartService[*Auth](ctx); err != nil {
log.Fatal(err)
}

RestartService[T](ctx) runs Init again on the same instance, under the same init deadline as at startup. Because Init registers the service’s routes, named middleware, and subscriptions again, they serve again: in the sample, GET /api/invoices returns 200 once more. The service rejoins the active services, and gas.SystemServiceInitialized is emitted with its name.

RestartService returns an error when T has no built instance, when the service is already active, once shutdown has begun, or when Init fails; the last one is wrapped as gas: re-init <name>: ....

Other services can respond to gas.SystemServiceClosed, which is how a system degrades on purpose rather than by accident:

status.go
// Status watches for services closing at runtime.
type Status struct {
bus *gas.EventBus
logger gas.Logger
}
func NewStatus(bus *gas.EventBus, logger gas.Logger) *Status {
return &Status{bus: bus, logger: logger}
}
func (s *Status) Name() string { return "status" }
func (s *Status) Init(context.Context) error {
s.bus.SubscribeWithOwner[gas.SystemServiceClosed](s,
func(p gas.SystemServiceClosedPayload) {
if p.ServiceName == "auth" {
s.logger.Warn("auth is down; entering degraded mode").Send()
}
})
return nil
}
func (s *Status) Close(context.Context) error { return nil }

The closed service’s own subscriptions are removed before the event is emitted, so it does not receive its own SystemServiceClosed. All system events are listed on Events.

Rarely, and that is fine. The kill switch earns its place when one subsystem is failing in a way that threatens the rest, when a dependency is down and its routes should fail fast instead of piling up requests, or when one feature has to be drained ahead of a deploy. If you never call CloseService, ownership still makes the route map attributable: every route says which service owns it.