Skip to content

Services and dependency scopes

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.

  • 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.
  • 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.

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 call app.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.

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;
}
}