Services and dependency scopes
What it is
Section titled “What it is”gisila has a deliberately small dependency system with two scopes:
| Scope | Register with | Lifetime | Read with |
|---|---|---|---|
| Request | app.registerService<S>(factory) |
One instance per request that asks for it | a controller parameter of type S, ctx.service<S>() |
| App | app.registerSingleton<T>(instance) or app.registerSingletonFactory<T>(create) |
From init() to stop() |
app.singleton<T>(), ctx.singleton<T>(), service.singleton<T>() |
Services extend Service and get the request’s RequestContext injected as ctx, so they can read the principal, the database session, the cache and other services without parameters being threaded through every call.
There is no container, no reflection and no annotations: registration is a map from type to factory.
When to use it
Section titled “When to use it”- Services for business logic that needs request state: who is calling, the per-request database session, a transaction, other services.
- Singletons for expensive, shareable objects: database pools you manage yourself, HTTP clients, Redis clients, a queue producer, configuration objects.
When not to use it
Section titled “When not to use it”- Don’t put per-request data in a singleton or a controller field: concurrent requests would share it.
- Don’t make a service for stateless helpers (formatting, hashing). A top-level function is simpler.
- Don’t look up services from a singleton: singletons outlive requests. Pass what they need as arguments.
- For complex graphs, a full DI package is still an option. Build the objects yourself in
main()and register the results as singletons.
How it works
Section titled “How it works”On every request the app installs a service resolver on the context. ctx.service<S>() checks a per-request cache; on a miss it calls the factory, calls attach(ctx), caches and returns the instance. The same type requested twice in one request (by the controller and by another service) is the same object. An unregistered type throws StateError (a 500).
Singletons registered with an instance are available immediately. Factory singletons are created in registration order during init() (which start() calls); a factory can read earlier singletons. On stop(), dispose callbacks run in reverse order, after onStop hooks.
Two timing rules follow:
- Controllers are constructed before
start(), so they can’t callapp.singleton<T>()for a factory singleton in their constructor. Read it per request instead (ctx.singleton<T>(), or inside a service). - Service factory closures run per request, after
init(), so() => MailService(app.singleton<SmtpClient>())is fine.
In tests, construct a service and call attach with a hand-made context, or go through GisilaTestClient, which uses the real resolver.
Example
Section titled “Example”Future<void> main() async { final app = GisilaApp(config: AppConfig(database: GisilaOrmDatabaseProvider(db)));
// App scope. final queue = GisilaQueue.redis(namespace: 'shop', host: 'redis'); app.registerSingleton<HttpClient>(HttpClient(), dispose: (c) => c.close()); app.registerSingleton<Jobs>(Jobs(queue), dispose: (_) => queue.close());
// Request scope. app ..registerService<CartService>(CartService.new) ..registerService<CheckoutService>(CheckoutService.new);
app.registerController(attacher: CheckoutController().attachToApp); await app.start();}
/// Task handles, registered once on the queue (package:gisila_queue).class Jobs { Jobs(GisilaQueue queue) : sendReceipt = queue.task<void>('emails.receipt', (ctx) async => null); final TaskRef<void> sendReceipt;}
class CheckoutService extends Service { Future<Order> checkout() async { final cart = await ctx.service<CartService>().current(); // same request, same CartService final order = await _saveOrder(db<Database>(), cart); await singleton<Jobs>().sendReceipt.delay([order.id]); return order; }}