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.
The Service interface
Section titled “The Service interface”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 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 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.
Registering
Section titled “Registering”Registration happens through options passed to gas.NewApp or gas.NewWorker:
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).
Constructors
Section titled “Constructors”A constructor is a function that takes its dependencies as parameters and returns the registered type, optionally with an error:
func(DepA, DepB) Tfunc(DepA, DepB) (T, error)// 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.
Lifetimes
Section titled “Lifetimes”// 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.
Overriding a registration
Section titled “Overriding a registration”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:
// 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.
Register under the type consumers ask for
Section titled “Register under the type consumers ask for”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:
// 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:
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.MailerThe 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.
One service under several types
Section titled “One service under several types”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:
// 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.
Resolving
Section titled “Resolving”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:
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.
Resolution errors
Section titled “Resolution errors”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.
Behavior details
Section titled “Behavior details”The sections above cover everyday use. This section is the full contract, for when an exact edge case matters.
Service shape
Section titled “Service shape”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.
Constructor checks
Section titled “Constructor checks”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 and transients
Section titled “Instances and transients”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.
Captive dependencies
Section titled “Captive dependencies”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.RequestLogThe 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.
Instances override constructors
Section titled “Instances override constructors”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.
Alias rules
Section titled “Alias rules”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.
Build order
Section titled “Build order”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.
Concurrent resolution
Section titled “Concurrent resolution”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.