Skip to content

Services and DI

A Gas application is a set of services wired together by a dependency injection container. You register a constructor for each type, the container works out the order to build them in, and it runs each service’s lifecycle methods at startup and shutdown.

type Service interface {
Name() string // unique identifier, such as "gas/auth"
Init(ctx context.Context) error // register routes, middleware, subscriptions
Close(ctx context.Context) error // release what the service holds
}

A type that implements Service hands its lifecycle to the container. The container calls Init(ctx) after constructing it, and Close(ctx) at shutdown (singletons) or when its scope closes (scoped services). The contexts both methods receive are described in App lifecycle.

Here is a service with its constructor:

notes.go
// Notes is a service: a constructor, the three Service methods, and the
// routes it owns.
type Notes struct {
router *gas.Router
logger gas.Logger
store *Store
}
// NewNotes is the constructor. Each parameter is a dependency, resolved from
// the container by its exact type.
func NewNotes(router *gas.Router, logger gas.Logger, store *Store) *Notes {
return &Notes{router: router, logger: logger, store: store}
}
func (s *Notes) Name() string { return "notes" }
// Init claims what the service owns. Everything registered here is tagged
// with the service passed as the first argument.
func (s *Notes) Init(context.Context) error {
s.router.Handle(s, http.MethodGet, "/notes", s.list)
return nil
}
// Close releases what the service holds. Notes holds nothing.
func (s *Notes) Close(context.Context) error { return nil }
func (s *Notes) list(ctx gas.Context) error {
return ctx.JSON(http.StatusOK, s.store.All())
}

NewNotes is an ordinary function. Its parameters are its dependencies, and the container fills each one in by its type. Nothing in the service goes looking for its collaborators, so a unit test can call NewNotes with fakes.

Init is where a service claims what it owns: routes, named middleware, and event subscriptions. Each registration takes the service as its owner, which is what lets one service be torn down at runtime; see Ownership and teardown.

Not every registered type is a service. Store declares neither Init nor Close, so the container builds it and hands it out, and that is all:

store.go
// Store is an ordinary dependency. It declares neither Init nor Close, so the
// container builds it and hands it out, and manages nothing else about it.
type Store struct {
notes []string
mu sync.Mutex
}
func NewStore() *Store { return &Store{} }
func (s *Store) All() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.notes...)
}

A registered type that declares Init or Close must implement all three methods; see Service shape. Name() must be unique across the app; see Names.

Registration happens through options passed to gas.NewApp or gas.NewWorker:

main.go
func newApp() *gas.App {
return gas.NewApp(
// Infrastructure goes in under the interface its consumers ask for.
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// Your own types go in under their concrete type.
gas.WithSingletonService[*Store](NewStore),
gas.WithSingletonService[*Notes](NewNotes),
)
}

The type parameter is the key the value is stored under. Every resolution is by exact type; the container never searches its registrations for a type that happens to satisfy an interface (see below).

A constructor is a function that takes its dependencies as parameters and returns the registered type, optionally with an error:

func(DepA, DepB) T
func(DepA, DepB) (T, error)
settings.go
// Settings is loaded by a constructor that can fail.
type Settings struct{ Greeting string }
// NewSettings returns an error as its second result. A non-nil error fails
// the build, and startup with it.
func NewSettings() (*Settings, error) {
return &Settings{Greeting: "hello"}, nil
}

A non-nil error from a constructor fails the build, and with it startup. See Constructor checks for what registration rejects.

main.go
// Clock is a value registered as an instance.
type Clock struct{ Zone string }
// Nonce is a small value object built fresh on every resolution.
type Nonce struct{ Value string }
func NewNonce() *Nonce { return &Nonce{Value: "n-1"} }
// RequestLog collects lines for one request.
type RequestLog struct{ lines []string }
func NewRequestLog() *RequestLog { return &RequestLog{} }
func lifetimes() []gas.Option {
return []gas.Option{
// The general form names the lifetime.
gas.WithService[*Store](NewStore, gas.ServiceLifetimeSingleton),
// The shorthands name it in the function.
gas.WithScopedService[*RequestLog](NewRequestLog),
gas.WithTransientService[*Nonce](NewNonce),
// A pre-built value is a singleton.
gas.WithServiceInstance[*Clock](&Clock{Zone: "UTC"}),
}
}
Lifetime Option Instances Init and Close
Singleton WithSingletonService One, shared by every consumer Init when built at startup, Close at shutdown
Scoped WithScopedService One per Scope; in an App, one per request Init on first resolution in the scope, Close when the scope closes
Transient WithTransientService A new one on every resolution Not allowed: the type cannot implement Service
Instance WithServiceInstance The value you pass, treated as a singleton Init at startup, Close at shutdown

gas.WithService[T](ctor, lifetime) takes the lifetime as a value (gas.ServiceLifetimeSingleton, gas.ServiceLifetimeScoped, or gas.ServiceLifetimeTransient); the three shorthands call it.

Scoped services are covered in Request scopes. They cannot be resolved from the container itself, only from a Scope.

A singleton may not depend on a scoped or transient service; see Captive dependencies. See Instances and transients for the rules on those two lifetimes.

Registering a type again replaces its earlier registration: the last one wins. This is by design. It is how you replace a module’s default, or swap in a fake for a test, without editing the wiring you are overriding. Register the replacement after the options it overrides:

options.go
// FakeMailer records what it would have sent.
type FakeMailer struct{ sent []string }
func NewFakeMailer() *FakeMailer { return &FakeMailer{} }
func (m *FakeMailer) Send(to, _ string) error {
m.sent = append(m.sent, to)
return nil
}
// appOptions is the production wiring.
func appOptions() []gas.Option {
return []gas.Option{
gas.WithSingletonService[Mailer](NewSMTPMailer),
gas.WithSingletonService[*Signup](NewSignup),
}
}
// testOptions registers Mailer again after the production wiring, so the
// fake replaces the SMTP mailer.
func testOptions() []gas.Option {
return append(appOptions(), gas.WithSingletonService[Mailer](NewFakeMailer))
}

A value registered with WithServiceInstance is the exception; see Instances override constructors.

Lookups are by exact type, so register a service under the type its consumers ask for. Here Signup asks for the Mailer interface, so SMTPMailer is registered under Mailer:

main.go
// Mailer is what the signup service asks for.
type Mailer interface {
Send(to, body string) error
}
// SMTPMailer implements Mailer.
type SMTPMailer struct{}
func NewSMTPMailer() *SMTPMailer { return &SMTPMailer{} }
func (m *SMTPMailer) Send(_, _ string) error { return nil }
// Signup depends on the Mailer interface.
type Signup struct{ mailer Mailer }
func NewSignup(m Mailer) *Signup { return &Signup{mailer: m} }
func underInterface() error {
c := gas.NewServiceContainer()
c.RegisterSingletonService[Mailer](NewSMTPMailer)
c.RegisterSingletonService[*Signup](NewSignup)
return c.BuildAll(context.Background())
}

The mistake to avoid is registering it under its concrete type: a constructor that asks for an interface does not find a value registered under a concrete type that implements it, and the build fails:

main.go
func underConcrete() error {
c := gas.NewServiceContainer()
// Registered under its concrete type, so nothing is registered as Mailer.
c.RegisterSingletonService[*SMTPMailer](NewSMTPMailer)
c.RegisterSingletonService[*Signup](NewSignup)
return c.BuildAll(context.Background())
}
building *main.Signup: resolving dep main.Mailer for *main.Signup: no registration for main.Mailer

The container deliberately does not fall back to “the one registration that implements Mailer”: that search is ambiguous as soon as two registrations implement the same interface, and its answer would change as modules are added.

The same rule applies to every module. Module constructors ask for the provider interfaces declared in core (gas.ConfigProvider, gas.Logger, gas.DatabaseProvider, and so on), so infrastructure goes in under those interfaces, and your own services under their concrete types. To reach a backend feature that the interface does not expose, type-assert the provider you were given, or register the backend under its concrete type and alias the interface to it. Registering the concrete type with a second constructor builds a second instance. The module pages list what each constructor resolves.

Sometimes consumers ask for one service under different types. Signup asks for Mailer, and an admin page asks for Outbox, another interface the same mailer implements. Register the service once, then add an alias for each interface:

main.go
// Outbox is what the admin page asks for: the mail still waiting to go out.
type Outbox interface {
Pending() int
}
func (m *SMTPMailer) Pending() int { return 0 }
// aliasOptions registers SMTPMailer once and makes it reachable as both
// interfaces its consumers ask for.
func aliasOptions() []gas.Option {
return []gas.Option{
gas.WithSingletonService[*SMTPMailer](NewSMTPMailer),
gas.WithServiceAlias[*SMTPMailer, Mailer](),
gas.WithServiceAlias[*SMTPMailer, Outbox](),
gas.WithSingletonService[*Signup](NewSignup),
}
}

An alias makes one registration reachable under an interface. Resolving the alias resolves its target, so both return the same instance, with the target’s lifetime. The target’s Init and Close run once, however many aliases it has. gas.WithServiceAlias[Target, Alias]() is the option, and RegisterServiceAlias[Target, Alias]() on the ServiceContainer or the Worker is the imperative form.

See Alias rules for ordering and what registration rejects.

Most code never resolves anything: constructors and handlers declare what they need and the container supplies it. When you do need a value directly, for example in main, a ready hook, or a test, resolve it by type:

main.go
c := app.ServiceContainer()
notes, err := c.Resolve[*Notes]()
if err != nil {
log.Fatal(err)
}
store := c.MustResolve[*Store]() // panics instead of returning an error
if _, err := c.Resolve[*Nonce](); errors.Is(err, gas.ErrNotRegistered) {
fmt.Println("no *Nonce registered on this app")
}

Resolve and MustResolve are generic methods on *ServiceContainer and on *Scope. MustResolve panics with a message that includes the same error text. CanResolve[T]() on the container reports whether T has a registration or instance, or is an alias whose target has one, without building anything.

Registration also has imperative forms, for wiring done after the App or Worker exists: RegisterSingletonService, RegisterScopedService, RegisterTransientService, RegisterService, RegisterServiceInstance, and RegisterServiceAlias, on the ServiceContainer and forwarded on the Worker. They share the container’s registrations with the options and return the same instances.

Errors from the container wrap one of these sentinels, so classify them with errors.Is. The message itself names the types involved.

Sentinel Returned when
gas.ErrNotRegistered A type, or a dependency of one, has no registration or instance; or an alias’s target has none
gas.ErrCircularDependency Constructors depend on each other in a cycle; a constructor or Init resolves the type being built through a container or scope it captured; two goroutines’ singleton builds would wait on each other; or constructors nest more than 256 deep
gas.ErrCaptiveDependency A singleton’s constructor depends on a scoped or transient service
gas.ErrScopeRequired A scoped service is resolved from the container instead of a Scope
gas.ErrInvalidLifetime A registration whose ServiceLifetime is not one of the three is resolved, directly or as a dependency
gas.ErrInvalidServiceShape A registered type declares Init or Close but does not implement Service
gas.ErrNoConstructor Guards an internal invariant; the public API does not return it

At startup these come back from Start (or Run). In an App, startup also fails, before any request is served, when a DI-aware handler declares a parameter type that has no registration; that error names the route, the owning service, and the type.

The sections above cover everyday use. This section is the full contract, for when an exact edge case matters.

A registered type that declares Init or Close must implement all three methods, with the signatures above. One that declares either and falls short, for example an Init without the context.Context parameter, or an Init with no Close, fails startup with an error that wraps gas.ErrInvalidServiceShape and names each missing or mistyped method. Without that check the type would register cleanly and never be initialized or closed.

Name alone does not count: a type that declares only Name() is an ordinary dependency. A registration under a value type whose methods are declared on the pointer is rejected too, with a message telling you to register the pointer type.

A type from another package that declares Close, such as *sql.DB, cannot be given the other two methods. Wrap it in a service of your own instead of registering it directly.

Name() identifies a service at runtime: active services, health and readiness results, the kill switch, and the ownership of routes and subscriptions are all keyed by it. A Worker tracks one active service per name, so a second service with the same name is not tracked, and shutdown does not close it.

The constructor is checked when it is registered. Registration panics if it is not a function, is variadic, returns anything other than one or two values, returns a first value not assignable to the registered type (or, for an interface type, not implementing it), or returns a second value that is not an error. Parameters are not checked at registration, since whether they resolve is only known once everything is registered; a parameter with no registration fails the build.

Instances are pre-built, not pre-initialized. If the value implements Service, the container calls its Init at startup and its Close at shutdown like any other singleton, so do not call Init yourself before registering it. An instance is stored under T, the static type at the call site, so passing an interface-typed variable registers it under that interface and not under its concrete type.

Transient registrations of a type that implements Service panic at registration. The container keeps no reference to a transient, so nothing could ever close it, and every instance would share one Name(). Use a singleton or a scoped registration for anything with a lifecycle.

A singleton may not depend on a scoped or transient service. The singleton would hold the first instance it was given for the life of the process, which for a scoped service means one request’s state leaking into every later request. Startup fails with an error that wraps gas.ErrCaptiveDependency:

captive dependency: singleton *main.Cache depends on scoped *main.RequestLog

The check looks at constructor parameters. A scoped service may depend on singletons and on other scoped services. A dependency registered with WithServiceInstance is a singleton and is always allowed.

There is one exception to that order: a value registered with WithServiceInstance wins over a constructor registration for the same type, whichever was registered last and whatever the constructor’s lifetime. The constructor never runs. To replace an instance, register another instance.

The alias and its target can be registered in either order. The target is checked at startup: if it has no registration or instance, Start fails with an error that wraps gas.ErrNotRegistered, and so does resolving the alias. The captive-dependency rule looks through an alias: a singleton that depends on an alias of a scoped or transient service fails startup with gas.ErrCaptiveDependency.

Registration panics if the alias is not an interface, the target does not implement it, the alias is already registered as a service or instance, or the target is itself an alias. Registering a service under a type that is already an alias panics too. Registering the same alias again replaces its target.

At startup the container builds every singleton. It first validates the service shape and lifetimes of every registration, then runs Init on every Service registered as an instance, in the order they were registered, then builds the singletons in dependency order. A constructor runs only after every dependency it names has been constructed and, if that dependency is a service, initialized, so a service can use its dependencies from its constructor and from Init. The order is the same on every run: singletons with no registered dependencies come first, in the order they were registered, and each of the others follows once everything it depends on has been built.

Because the order is fixed, so is the error startup reports when several registrations are broken. A captive dependency is reported before any build error, and of several, the one in the singleton registered first. Of several build errors, startup reports the first broken singleton in build order, which for singletons that wait on the same dependencies, or on none, is the one registered first. Fix it and the next one surfaces.

Shutdown closes services in the reverse of the order they were initialized, so a service’s dependencies are still open while it closes. The full startup and shutdown sequence is on App lifecycle.

Resolving a singleton that has not been built yet builds it, and its dependencies, on the spot. The container is safe for concurrent resolution: a singleton is built by one goroutine at a time, and another goroutine asking for it while it is being built waits and gets the same instance.