phasync
Context switching / Captive dependencies

Context switching

Captive dependencies

A singleton must never hold a scoped object: it keeps the first request's, for every request after it.

A service may depend only on services that live at least as long as it does. A singleton that takes a scoped object in its constructor holds the one it was given for the rest of the worker's life.

final class ReportService   // a singleton, created by whichever request asks first
{
    public function __construct(private CurrentUser $user)   // a scoped object
    {
    }

    public function title(): string
    {
        return 'Report for ' . $this->user->name;
    }
}

function reports(): ReportService
{
    static $service;

    return $service ??= new ReportService(Scope::get(CurrentUser::class));
}

Two requests, ann first:

Report for ann
Report for ann

Bob's report is titled with Ann's name. Nothing fails; the service is correct for one request and wrong for all the others.

Fix: resolve at use#

The singleton holds no user. It asks for the current one when it needs it:

final class ReportService
{
    public function title(): string
    {
        return 'Report for ' . Scope::get(CurrentUser::class)->name;   // the current request's
    }
}
Report for ann
Report for bob

Scope is the helper from Service lifetimes. Two other fixes work where they fit: pass the user as an argument (title(CurrentUser $user)), or make the service scoped too. An ephemeral service may hold a scoped one: it is gone before the request is.

The same mistake takes other forms: a closure that uses the request and is stored in a singleton, a request saved in a static property, an event listener registered during a request. Each keeps the first request's data.

Previous: Service lifetimes ยท Next: Services that can be shared