Dependency injection
Gebweb wires application objects through a constructor-injection container. The framework constructs controller, repository, and discovery-scanned classes for you by walking their declared constructor parameters and resolving each one to a registered service. You can hand-register services with explicit factories or instances, and you can read services out of the container directly when DI is not in the loop.
This chapter walks the full surface: what the framework constructs, the lifecycle model, the register / resolve methods, how parameter binding interacts, and the patterns you reach for in tests.
Automatic discovery
When an app first starts handling requests (serve, listen,
TestClient, or dispatcher), Gebweb sweeps every loaded class once
and wires the ones carrying a marker, so most registration is
automatic:
import gebweb;
@Controller
class HealthController {
@Get("/health")
func check(): dict<string, any> { return {"status": "ok"}; }
}
@Service
class Clock { func now(): int { return 0; } }
class DocPolicy {
@Policy("Doc")
func edit(CurrentUser u, Doc d): bool { return u.id == d.ownerId; }
}
let app = gebweb.app();
gebweb.serve(app, ":8080");
The sweep handles @Controller (mounted), @Policy methods
(registered as authorization policies), @Service (DI container), and
@Tag("event.subscriber" | "job.handler" | "message.handler" | "scheduled") handlers. It runs at most once per app and logs a
one-line summary at startup.
@Controller classes are auto-mounted only when gebweb.app() is
called with no controller list. If you pass an explicit list
(gebweb.app([A, B])), that is the complete controller set and no
other @Controller classes are mounted, but services, policies, and
tagged handlers are still discovered. A controller that appears in both
the explicit list and the scan is registered once.
Calling gebweb.discover(app) yourself is optional and idempotent; it
returns a DiscoveryReport listing the wired controllers, services,
policies, and tagged classes.
Caching the sweep
For large apps the scan can be cached to trim startup. It is opt-in because it writes a file:
gebweb.serve(app, ":8080", {"discoveryCache": true});
/* or a custom path: {"discoveryCache": ".cache/discovery.json"} */
The first run writes a manifest to .gebweb-cache/discovery.json (add
it to .gitignore); later runs reload it and skip the scan when the set
of loaded classes is unchanged, matched by a fingerprint. Adding or
removing a class invalidates the cache automatically; a decorator-only
change on an existing class does not, so delete the file (or redeploy
clean) after one. gebweb.discoverCached(app, path) runs the same
cached sweep directly.
What the framework constructs
The container kicks in whenever Gebweb needs an instance:
- Controllers passed as class references to
gebweb.app([...]). An entry likegebweb.app([UserController])resolvesUserControllerthrough the container. - Repositories referenced by
@ApiResource(the framework resolves the repo class declared by the resource's staticrepositoryClass()method). - Classes discovered through
reflect.classes()for messaging / job / scheduled-task / event subscribers when a framework feature is enabled. The scanner asks the container for instances of each handler class.
In all three paths the container walks the constructor's typed parameter list and resolves each parameter before instantiating the class.
class Database {
db.Connection conn;
func Database(db.Connection conn) {
this.conn = conn;
}
}
class UserRepo {
Database database;
func UserRepo(Database database) {
this.database = database;
}
}
class UserController {
UserRepo repo;
func UserController(UserRepo repo) {
this.repo = repo;
}
@Get("/users")
func index(): list<User> {
return this.repo.findAll();
}
}
let conn = db.Connection({
"driver": "postgres",
"dsn": "...",
"maxOpenConns": 16,
});
let app = gebweb.app([]);
gebweb.register(app, Database, func(): Database {
return Database(conn);
});
gebweb.addControllers(app, [UserController]);
A singleton db.Connection is the right default: it wraps a connection
pool, and concurrent requests query it in parallel. Size maxOpenConns
to your expected request concurrency (geblang 1.19.0 applies pool
options at connect time). Native module classes such as db.Connection
are type names rather than first-class class values, so register an
application wrapper such as Database, as above.
Lifecycle model
A service's lifecycle answers one question: when does Gebweb reuse the instance? The app owns its DI container, so "shared" means shared within that app. A new app gets a new container.
| Lifecycle | How to select it | What a resolve returns |
|---|---|---|
| Shared within the app (default) | Constructor autowiring, register(app, T, factory), registerInstance(app, T, value), or a YAML service with shared: true |
The same instance each time. An autowired constructor or factory runs on the first resolve; registerInstance supplies an existing value. |
| Per HTTP request | registerPerRequest(app, T, factory) |
The same instance for all resolves during one dispatched request; the next request gets a new one. |
| Fresh on every resolve | A services: entry with shared: false in services.yaml |
A new instance each time that service id is resolved. |
Use a shared binding for a database pool, cache client, or other object designed
for concurrent use. Use a per-request binding for state that must be isolated
between requests. registerInstance is a way to supply a shared value, often
in a test; it is not a separate lifecycle.
The request dispatcher opens the per-request scope automatically. Two resolves
of the same per-request type during one request return the same object. A
resolve outside a request, such as during startup or a direct test call to
gebweb.resolve, calls the factory afresh every time. This is a consequence
of being outside a request scope; it does not turn that binding into a
general-purpose transient service.
For example, if RequestContext is per request, two handlers or services that
resolve it while handling request A see one object. Request B gets another
object. Two direct gebweb.resolve(app, RequestContext) calls during startup
also get different objects because no request scope is open.
Controller class references in gebweb.app([...]) or
gebweb.addControllers(app, [...]) are constructed during route registration,
before a request scope exists. If a controller constructor stores a per-request
dependency, it keeps that startup object across requests. Resolve per-request
state while handling the request, or pass request values to a handler, instead
of storing it on a shared controller.
Registering services
gebweb.register(app, T, factory)
Lazy factory. The factory takes no arguments and returns the
service value. It is invoked the first time the container is
asked for T; the result is cached for subsequent resolves.
class Database {
db.Connection conn;
func Database(db.Connection conn) { this.conn = conn; }
}
let conn = db.connect("postgres", gebweb.getParameter(app, "db.url") as string);
gebweb.register(app, Database, func(): Database {
return Database(conn);
});
Use a factory when construction has side effects (opens a connection, reads a file), depends on runtime configuration that the container cannot infer, or produces a value the container cannot assemble via reflection.
gebweb.registerInstance(app, T, instance)
Skip the factory. The container caches the supplied instance directly. Common for tests:
let stub = StubMailer();
gebweb.registerInstance(app, gebweb.MailerContract, stub);
Also useful when you have an already-constructed value that multiple services should share (a long-lived broker connection, a Bedrock client built at startup).
gebweb.registerPerRequest(app, T, factory)
The dispatcher opens a fresh scope for each HTTP request. Within that scope,
the factory runs once and all resolves of T share its result. The scope is
discarded when dispatch finishes.
class RequestContext {
list<string> events;
func RequestContext() {
this.events = [];
}
}
gebweb.registerPerRequest(app, RequestContext, func(): RequestContext {
return RequestContext();
});
The same RequestContext instance is shared by collaborators resolved within
one request; another request gets a fresh instance. To use request data, accept
a gebweb.Request in a handler or middleware and pass the needed values
explicitly.
Outside a request scope the factory runs on every resolve (transient behaviour). That makes test code straightforward: exercise the same service from several test methods and each gets its own instance.
di.registerInterfaceInstance(c, "Pkg.Iface", instance)
Register an instance keyed by an interface type name. Necessary
because Geblang interfaces cannot be referenced as first-class
values at runtime, so the class-keyed registerInstance(c, T, ...)
form does not work for interface-typed parameters.
This is mostly an internal hook: gebweb uses it under the bonnet
to register framework-supplied services that expose an interface
(gebweb.useLlm(app, client) registers under "llm.Client").
Application code reaches for it directly when a custom service
also lives behind an interface:
interface Notifier {
func send(string subject, string body): void;
}
class EmailNotifier implements Notifier {
/* ... */
}
/* Note: di is gebweb.di. Pull it through an import alias. */
import gebweb.di as di;
di.registerInterfaceInstance(app.container, "Notifier", EmailNotifier());
Constructor parameters typed Notifier now resolve to the
registered instance:
class AlertService {
Notifier notifier;
func AlertService(Notifier notifier) { this.notifier = notifier; }
}
let svc = gebweb.resolve(app, AlertService) as AlertService;
Constructor autowiring
When the container resolves a class T, it does the following:
- If
Thas a per-request binding, return the instance cached for the current request or call its factory and cache the result for that request. Outside a request, call the factory without caching. - Otherwise, if
Tis already cached, return it. IfThas a registered factory, call it and cache the result. - Otherwise inspect
T's constructor parameters via reflection. For each parameter, in order:- If the parameter has a
@Param("key")decorator, resolve it from the app's parameter store. - Else if the parameter's declared type is an interface name
registered via
registerInterfaceInstance, return that instance. - Else resolve the parameter's declared class type
recursively through the same rules. Cycles raise a
RuntimeErrornaming the resolution stack.
- If the parameter has a
- Invoke
T(args...)with the resolved arguments and cache.
Parameter resolution does not care about parameter order beyond
declaration order; you can mix class-typed and @Param-typed
parameters in the same constructor.
@Param("key") for primitive config
@Param("key") pulls from the app's parameter store instead of
the type system. Use it for primitives that the container cannot
auto-build: database URLs, secrets, feature flags, region names.
class S3Bucket {
string bucket;
string region;
func S3Bucket(@Param("s3.bucket") string bucket,
@Param("aws.region") string region) {
this.bucket = bucket;
this.region = region;
}
}
gebweb.parameter(app, "s3.bucket", "uploads-prod");
gebweb.parameter(app, "aws.region", "us-east-1");
let svc = gebweb.resolve(app, S3Bucket) as S3Bucket;
A missing key raises a RuntimeError naming the key, so typos
surface immediately rather than producing a null deep in your
service.
Interface-typed parameters
If a constructor depends on an interface, register an instance under the interface name (as above) and the container does the rest:
class Pipeline {
Notifier notifier;
llm.Client llm;
func Pipeline(Notifier notifier, llm.Client llm) {
this.notifier = notifier;
this.llm = llm;
}
}
di.registerInterfaceInstance(app.container, "Notifier", EmailNotifier());
gebweb.useLlm(app, gebweb.llmClient({"provider": "anthropic", "apiKey": "..."}));
let p = gebweb.resolve(app, Pipeline) as Pipeline;
gebweb.useLlm registers the LLM client under "llm.Client"
internally; the user-side custom Notifier is wired the same
way.
Resolving manually
gebweb.resolve(app, T) returns the cached or freshly built
instance of T. Use it from places the autowiring chain cannot
reach (a one-off script, the inside of a controller method that
needs an extra service it does not want as a constructor
dependency):
class ImportController {
@Post("/import")
func run(ImportRequest body): dict<string, any> {
let parser = gebweb.resolve(this.app, CsvParser) as CsvParser;
return parser.parse(body.payload);
}
}
gebweb.resolve returns any; cast to the expected type at the
call site.
There is no resolve-by-interface-name helper for runtime
lookup. Some framework registrations expose a typed getter alongside
registration, such as gebweb.llm(app) for the LLM client. Custom
services follow the same pattern: pair useFoo with foo for
read-back.
Testing patterns
The DI container is the seam tests use to swap real
dependencies for controllable stubs. The pattern is the same
across handler tests, repository tests, and integration tests
against TestClient:
class WidgetControllerTest extends test.Test {
@test
func deleteReturns204(): void {
let app = gebweb.app();
let stub = StubWidgetRepo();
gebweb.registerInstance(app, WidgetRepo, stub);
gebweb.addControllers(app, [WidgetController]);
let client = gebweb.TestClient(app);
let r = client.delete("/widgets/w-1");
r.assertStatus(204);
this.assertEquals(["w-1"], stub.deleted);
}
}
Three guidelines:
- Register stubs BEFORE adding controller class references, since
addControllersconstructs those controllers during route registration.@ApiResourcerepositories resolve lazily on the first matching request, so they can be registered after resource routes. Once the container has cached an instance, subsequentregisterInstancecalls for the same key overwrite the binding but instances already injected into other services keep the original. Start withgebweb.app(), register the stub, then callgebweb.addControllers. - Prefer
registerInstanceoverregisterin tests when you do not need lazy construction. The instance form makes the setup linear and inspectable. - For per-request services, your test does not enter a request
scope unless you go through
TestClient. A directgebweb.resolve(app, RequestContext)from test code returns a fresh instance each time, which is usually what tests want.
Common patterns
Pass the active app into a controller
Controllers do not get the GebwebApp injected unless they
declare it. Pattern: declare a ?gebweb.GebwebApp app field and
let the framework call the controller's bindApp(app) method
(the gebweb.Controller base class implements this hook
already). Controllers extending gebweb.Controller can then
call gebweb.resolve(this.app, T) from any handler.
Chained services
A service that depends on another service just declares it in the constructor. The container resolves transitively:
class SearchIndex {
db.Connection conn;
func SearchIndex(db.Connection conn) { this.conn = conn; }
}
class SearchService {
SearchIndex index;
Notifier notifier;
func SearchService(SearchIndex index, Notifier notifier) {
this.index = index;
this.notifier = notifier;
}
}
/* db.Connection is registered; Notifier is registered by
* interface name; SearchIndex is built automatically by walking
* its ctor; SearchService is built by resolving its two deps. */
let svc = gebweb.resolve(app, SearchService) as SearchService;
You do not need to register SearchIndex or SearchService
explicitly; the container constructs them lazily when something
asks for them.
Wrapping an external client
External clients (LLM, S3, broker) usually come in as instances
from a useXxx helper. The helper both stores the client on the
app and registers it for DI. Your services depend on the client
type normally:
gebweb.useLlm(app, gebweb.llmClient({"provider": "anthropic", "apiKey": "..."}));
class Summariser {
llm.Client llm;
func Summariser(llm.Client llm) { this.llm = llm; }
}
Declarative configuration
Beyond the programmatic surface in this chapter, gebweb reads a
config/services.yaml file at app construction time (when the
file exists). The YAML loader can register services with arg
overrides, tag them, bind interfaces to chosen implementations,
load parameters with env/secret/cross-reference markers, and
merge per-environment overlays. See
services.yaml for the full surface; the
two paths interoperate, so anything you can do in YAML you can
also do via gebweb.register* / di.bindInterface / di.tagService.
Reference
| API | Purpose |
|---|---|
gebweb.register(app, T, factory) |
App-singleton factory. |
gebweb.registerInstance(app, T, instance) |
App-singleton instance. |
gebweb.registerPerRequest(app, T, factory) |
Per-request scope (transient outside a scope). |
di.registerInterfaceInstance(c, "Pkg.Iface", instance) |
Register under an interface name. c is app.container. |
di.bindInterface(c, "Pkg.Iface", "service.id") |
Bind an interface to a registered service id (lazy resolve through gebweb.service). |
gebweb.resolve(app, T) |
Build or return the cached instance of T. |
gebweb.service(app, id) |
Resolve a service by its registered id (@Service or YAML). |
gebweb.parameter(app, key, value) |
Set a primitive value in the parameter store. |
gebweb.getParameter(app, key) |
Read a value from the parameter store. |
@Param("key") |
Pull the value at key into the annotated constructor parameter. |
@Service("custom.id") |
Mark a class for the discovery sweep; the id defaults to the class name. |
gebweb.useLlm / gebweb.useMailer / gebweb.useStorage / gebweb.useCacheStore / gebweb.useViews / gebweb.useMessageQueue / ... |
Built-in use* helpers register their service on the app and (where applicable) plug it into the DI container under the right key. Look for a paired gebweb.<noun>(app) getter for read-back without going through DI. |
Errors
| Symptom | Likely cause |
|---|---|
cannot resolve X: parameter 'p' of type 'T' is not a known class |
T has no factory, no class registration, and no interface-name registration. Either add gebweb.register(app, T, ...) or di.registerInterfaceInstance(c, "T", ...). |
gebweb parameter 'key' is not registered |
A @Param("key") constructor parameter was hit but gebweb.parameter(app, "key", ...) was never called. |
DI cycle detected resolving X (stack: X -> Y -> X) |
Circular dependency. Break the cycle by injecting an interface and registering at startup, or by passing one of the two services as a factory closure rather than a constructor parameter. |