Skip to content

Services and DI

Services are registered with the DI container through constructors. The container resolves dependencies, topologically sorts them, validates lifetime rules, and calls Init() on every Service it holds.

gas.WithService[*auth.Service](auth.New, gas.ServiceLifetimeSingleton)

Shorthands infer the lifetime from the name:

gas.WithSingletonService[*auth.Service](auth.New)
gas.WithScopedService[*RequestLog](NewRequestLog)
gas.WithTransientService[*Nonce](NewNonce)

Pre-built values are registered as instances and treated as singletons. Pre-built means pre-constructed, not pre-initialized: if the value implements gas.Service, the container still calls Init() at startup and Close() at shutdown, so do not call Init() yourself first.

gas.WithServiceInstance[gas.ConfigProvider](cfg)

Register under the type your consumers ask for

Section titled “Register under the type your consumers ask for”

The type parameter is the key the service is stored under, and lookups are by exact type. The container deliberately does not scan its registrations for something that happens to satisfy an interface, because that search is ambiguous as soon as two services implement the same one.

// migrate asks for gas.DatabaseProvider, but the database is registered
// under its concrete type.
gas.WithSingletonService[*database.Service](database.New())
gas.WithSingletonService[*migrate.Service](migrate.New())
building *migrate.Service: resolving dep gas.DatabaseProvider for *migrate.Service:
no registration for gas.DatabaseProvider

As a rule, register infrastructure under its provider interface and your own services under their concrete type:

main.go
app := gas.NewApp(
// Infrastructure every module draws on.
gas.WithServiceInstance[gas.ConfigProvider](cfg),
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// Modules, registered under the provider interface your services inject.
gas.WithSingletonService[gas.StorageProvider](storages3.New()),
)

To reach a backend feature the interface does not expose, type-assert the provider you were injected rather than also registering the concrete type. Each module documents the assertion for its backend.

Lifetime Created Init() runs Notes
Singleton Once, shared everywhere During BuildAll() The default for infrastructure
Scoped Once per Scope On first resolution in the scope One instance per HTTP request
Transient Fresh on every resolution Never Cannot implement Service

Why transient services cannot implement Service

Section titled “Why transient services cannot implement Service”

Because nothing would ever call Close() on them. Singletons are recorded in the container and scoped instances in their Scope, which is what lets each be torn down. A transient is handed to the caller and no reference is kept, by design. Allowing a managed lifecycle there would mean either retaining every instance, making “transient” an unbounded leak, or never closing them at all. Service names would collide too, since the kill-switch addresses services by Name() and every transient instance shares one.

The rule is enforced with a panic at registration, because it is decidable from the type alone.

A registered type that declares Init or Close must implement all three methods:

type Service interface {
Name() string // Unique identifier, e.g. "gas/auth"
Init() error // Register routes, middleware, subscriptions
Close() error // Clean up internal resources
}

Anything short of that is rejected at startup with an error naming the missing or mistyped methods, rather than registering cleanly and then never being initialized. A type declaring none of them, or only Name(), is an ordinary dependency and is unaffected.

svc, err := gas.Resolve[*auth.Service](container)
svc := gas.MustResolve[*auth.Service](container)

When the type is not known at compile time, register and resolve against the container with a type token:

c := w.ServiceContainer()
c.RegisterSingletonService(gas.TypePtr[*auth.Service](), auth.New)
svc := c.MustResolve(gas.TypePtr[*auth.Service]()).(*auth.Service)

The token is dereferenced once, so TypePtr[*T]() registers under *T. Both forms share registrations and return the same instances.