Architecture
Service container
The service container builds your app's services and hands them to the code that needs them, from the application or from the current request's scope.
Introduction#
The container manages your classes' dependencies. You tell it once how to build a service, usually in a service provider, and take the service wherever you need it. Here a controller needs a service that finds podcasts on Apple Music:
import { inject, view } from '@orieljs/framework'import { AppleMusic } from '../../Services/AppleMusic'import { Controller } from './Controller'export class PodcastController extends Controller { readonly #apple = inject(AppleMusic) async show(id: string) { return view('podcasts.show', { podcast: await this.#apple.findPodcast(id) }) }}The controller doesn't build its AppleMusic: it asks the container for one with inject(). In a test, you can give
the container a stand-in, and the controller uses it without a change.
Zero configuration resolution#
A class whose constructor takes no arguments needs no binding: the container builds it with new when you ask for
it. A route in routes/ can take a service you never registered:
import { inject, Route } from '@orieljs/framework'class Service { name = 'service'}Route.get('/', () => inject(Service).name)An unbound class is built afresh each time you ask for it. The controllers, the middleware and the event listeners
of your app are built by the container in the same way, so their fields can use inject().
When to use the container#
With inject() and facades, you can write a whole app without binding anything yourself. You bind a
service when the container can't build it alone, or shouldn't build a new one each time:
- the code that uses it names an interface, which doesn't exist when your app runs: bind a token to an implementation (see Binding interfaces to implementations);
- its constructor takes arguments, which a binding passes it;
- your app should share one instance, or one per request;
- you write a package that other apps install, and it binds its services for them.
Binding#
Binding basics#
Simple bindings#
Your bindings go in a service provider's register(), where this.app is the application, which is the container.
bind() takes the class or token to bind and a factory that returns an instance. The factory receives the container,
which can build what the instance needs:
import { ServiceProvider } from '@orieljs/framework'import { PodcastParser, Transistor } from '../Services/Transistor'export class AppServiceProvider extends ServiceProvider { override register(): void { this.app.bind(Transistor, (app) => new Transistor(app.make(PodcastParser))) }}A bind() builds a new instance each time it is resolved, with the container that resolves it: inside a request,
the request's scope. The concrete can also be a class, which is built with new, and a class can be bound alone:
this.app.bind(Transistor).
Outside a provider, app() returns the application: app().bind(Transistor, …). Bindings belong to the
application: a request's scope refuses them (see Request scopes).
Binding a singleton#
singleton() binds a class or a token that is built once. Every later resolution, in the application and in every
request, returns the same instance:
import { ServiceProvider } from '@orieljs/framework'import { PodcastParser, Transistor } from '../Services/Transistor'export class AppServiceProvider extends ServiceProvider { override register(): void { this.app.singleton(Transistor, (app) => new Transistor(app.make(PodcastParser))) }}A singleton is built by the application, even when a request resolves it first, so its factory receives the application. It lasts as long as your app's process.
Binding scoped singletons#
scoped() binds a class or a token that is built once per scope: once for each request, each job a queue worker
runs, and each command. Within the scope, every resolution returns the same instance; the next scope builds its own.
The framework binds the session and the cookie jar this way:
import { ServiceProvider } from '@orieljs/framework'import { PodcastParser, Transistor } from '../Services/Transistor'export class AppServiceProvider extends ServiceProvider { override register(): void { this.app.scoped(Transistor, (app) => new Transistor(app.make(PodcastParser))) }}The factory receives the scope. A scoped service resolved outside any scope is an error, and so is one resolved for a singleton: a singleton outlives every request, so it can't hold one request's service (see Resolution errors).
Binding instances#
instance() binds an object you have already made. Every resolution returns it, and instance() returns it too:
const service = app().instance(Transistor, new Transistor(new PodcastParser()))On a request's scope, instance() binds the object for that request only. The framework binds each request this way.
Binding interfaces to implementations#
An interface is a type, and types are gone when your app runs, so the container can't take one as a key. Give the
interface a token instead: token() makes a key that carries the interface's type. Name the token after the
interface, as Oriel's contracts do:
import { token } from '@orieljs/framework/contracts'export interface EventPusher { push(event: string, payload: object): Promise<void>}export const EventPusher = token<EventPusher>('App.EventPusher')Then bind the token to an implementation:
import { ServiceProvider } from '@orieljs/framework'import { EventPusher } from '../Contracts/EventPusher'import { RedisEventPusher } from '../Services/RedisEventPusher'export class AppServiceProvider extends ServiceProvider { override register(): void { this.app.singleton(EventPusher, RedisEventPusher) }}The code that needs an EventPusher takes it by the token, and TypeScript types it as the interface:
import { inject } from '@orieljs/framework'import { EventPusher } from '../Contracts/EventPusher'import type { Podcast } from '../Models/Podcast'export class PodcastPublisher { readonly #pusher = inject(EventPusher) async publish(podcast: Podcast): Promise<void> { await this.#pusher.push('podcast.published', { id: podcast.id }) }}A token needs a binding: the container can't build an interface. The token's description is its identity, so two
tokens made with the same description are the same key: give each of yours its own, such as App.EventPusher.
Your providers register after the framework's, so a binding of yours for one of the framework's tokens replaces the framework's own.
Extending bindings#
extend() changes a service each time it is built. The callback receives the instance and the container, and returns
the instance to use, the same one or another that wraps it:
import { ServiceProvider } from '@orieljs/framework'import { DecoratedService, Service } from '../Services/Service'export class AppServiceProvider extends ServiceProvider { override register(): void { this.app.extend(Service, (service, app) => new DecoratedService(service)) }}The extenders run in the order you add them. A singleton or an instance the application already holds is extended at once. Like the bindings, extenders belong to the application, not to a request's scope.
Resolving#
The make method#
make() resolves a class or a token. TypeScript types the result from what you pass:
const transistor = app().make(Transistor)bound() tells you whether something is bound or held as an instance, and resolved() whether it has been resolved
already:
if (app().bound(Transistor)) { // ...}Outside a provider, the app() helper gives you the container. With an argument, it resolves it, as inject() does:
const transistor = app(Transistor)Inside a request, app().make() resolves from the request's scope, so it reaches the request's own services. To have
the container itself, resolve Container: you get the current request's scope inside a request, and the application
outside one.
Injecting with inject()#
inject() resolves a class or a token from the current container. Call it in a field of a class the container
builds, as the PodcastController above does, or anywhere your code runs while the app is running, such as a route's
closure:
import { inject, redirect, Route } from '@orieljs/framework'import type { Podcast } from '../app/Models/Podcast'import { PodcastPublisher } from '../app/Services/PodcastPublisher'Route.post('/podcasts/{podcast}/publish', async (podcast: Podcast) => { await inject(PodcastPublisher).publish(podcast) return redirect().back()})The current container is, in this order:
- while the container builds an object, the container building it, so a field's
inject()resolves from it; - inside a request, a queued job or a command, its scope;
- otherwise, the application, the newest one your process created.
A controller and a middleware are built by the request's scope, so their fields reach the request's services. The
application builds a singleton and an event listener itself, so their fields can't: there, inject() a request's
service inside the method that needs it, which runs in the request.
inject() works only once an application exists. At a module's top level, it runs when the module is first imported,
which can be before your app is created: see Facades.
Request scopes#
A scope is a container for one unit of work. It shares the application's bindings and singletons, and keeps its own scoped services and instances. Oriel opens one for:
- each HTTP request, including the requests your tests make;
- each job a queue worker runs;
- each command
node orielruns.
A scope ends with its request, once the response is sent and the request terminated, or with its job or its command.
Its scoped services and instances end with it, and the next request builds its own. Every inject(), helper and
facade called while it runs resolves from it.
These live in a request's scope:
| Service | How |
|---|---|
The request, from request() | An instance the HTTP kernel binds on the scope |
| The current route | An instance the router binds on the scope |
The session, from session() and the Session facade | Scoped |
The cookie jar, from cookie() and the Cookie facade | Scoped |
The Inertia response factory, from inertia() and the Inertia facade | Scoped |
vite(), with its CSP nonce | Scoped |
A scope refuses bindings, extenders and resolving() callbacks, which belong to the application:
instance() is the way to put an object in it. To run code in a scope of your own, as a script might, create one and
run your code inside it:
const scope = application.createScope()await scope.run(async () => { // inject(), the helpers and the facades resolve from the scope here...})Container events#
resolving() registers a callback that runs each time the container builds a class or a token, after the extenders.
It receives the instance and the container, and can set the instance up before it is handed out:
import { ServiceProvider } from '@orieljs/framework'import { Transistor } from '../Services/Transistor'export class AppServiceProvider extends ServiceProvider { override register(): void { this.app.resolving(Transistor, (transistor, app) => { transistor.timeout = 30 }) }}A singleton is built once, so its callback runs once. An object you bind with instance() isn't built, and runs no
callback. Laravel's resolving() without a class, which runs for everything, isn't in Oriel.
Resolution errors#
When the container can't give you what you ask for, it throws. The messages name what was asked for, by its class name or its token's description:
| Error | When | Message |
|---|---|---|
BindingResolutionError | A token has no binding | Target [App.EventPusher] is not bound. Is its service provider registered in bootstrap/providers.ts? |
BindingResolutionError | A scoped service is resolved outside a scope, or for a singleton | Scoped binding [Session.Store] was resolved outside a scope. Resolve it inside app.createScope().run(...), and don't inject scoped services into singletons. |
ContainerNotAvailableError | inject(), a helper or a facade runs before any application exists | Cannot resolve [App.EventPusher]: no container is available. inject(), helpers and facades only work while the application is running (inside a request, job, command, provider or app.run()). Don't call them at module top level. |
CircularDependencyError | Two services need each other to be built | Circular dependency detected: App.A -> App.B -> App.A |
Error | A binding is registered on a request's scope | Cannot register bindings on a request scope (bind). Register them on the application in a service provider; use instance() for per-request values. |
TypeError | bind() gets a token with no concrete | bind(App.EventPusher) needs a concrete: tokens can't be constructed |
BindingResolutionError, CircularDependencyError and ContainerNotAvailableError are exported from
@orieljs/, so you can catch them by class.