Index

Design Before the Pattern Name

Dependencies Should Be Visible at Construction

This line created a database record:

$user = UserFactory::new()->create([
    'external_id' => 123,
]);

It also performed an HTTP request.

The factory did not mention a client. The model did not accept one. A synchronous observer noticed the external identifier, prepared a profile export, and resolved a connector from global framework state. The connector then looked up a remote account.

In tests without a fake, that lookup reached DNS or the network and retried three times with exponential backoff. Tests which had run in under five seconds took more than thirty.

The slow test was only the visible symptom. The design problem was that a local-looking construction operation had an undeclared remote dependency.

The call site promised less than it did

Read only the factory call:

construct attributes
insert one local record
return the model

The actual path was longer:

factory create
    -> model saved event
        -> observer
            -> export preparation
                -> global connector
                    -> remote account lookup
                        -> retry policy

Every arrow after the model event was invisible at the call site.

That matters outside tests too. Network latency, credentials, timeouts, retry policy, and remote availability became prerequisites for local persistence. The caller could not decide whether profile provisioning belonged in the current request, a queue, a transaction boundary, or a later repair.

Dependency injection is often discussed as a constructor-style preference. The stronger point is operational: the signature should reveal which failures and resources an operation can introduce.

An observer does not erase a dependency

Framework events make code easy to attach. They do not make attached work free.

A synchronous observer runs inside the original call stack. If it performs a remote lookup, the save operation now depends on:

  • network resolution;
  • connector configuration and credentials;
  • remote response semantics;
  • timeout and retry policy; and
  • the observer’s exception handling.

None of those disappear because the caller invoked save() rather than a client.

Observers are reasonable for local, invariant lifecycle work whose timing is part of persistence. They become dangerous when they smuggle a new failure domain into a common model operation.

The question is simple:

Would a caller choose to perform this work every time this record is saved?

If the caller might choose differently, the work deserves an explicit application operation.

Put the remote collaborator on the use case

An explicit service can separate local creation from remote provisioning:

final readonly class ProvisionUser
{
    public function __construct(
        private UserRepository $users,
        private RemoteProfileGateway $profiles,
    ) {}

    public function execute(RegisterUser $command): ProvisionedUser
    {
        $user = $this->users->create($command->user);
        $profile = $this->profiles->provision($user->externalId);

        return new ProvisionedUser($user, $profile);
    }
}

Now the constructor tells the composition root that the use case needs local persistence and a remote gateway. The method tells the caller that it is provisioning, not merely constructing a model.

This does not decide the failure policy by itself. The use case still needs to choose among several honest contracts:

remote work is required
    fail the operation and compensate local state

local creation may precede remote work
    persist a pending state and queue provisioning

remote work is best effort
    persist locally, report failure, and retain a repair path

remote state already exists
    look it up explicitly and attach the result

Those are product and reliability decisions. An observer which catches every exception can accidentally choose “best effort” without storing enough state to repair it.

Construction should remain boring

Factories, constructors, and hydrators are used widely because callers expect them to be predictable.

A model factory may legitimately persist related rows when its state says so. A constructor may validate values and allocate memory. A hydrator may parse a document. The problem is not that construction must be mathematically pure.

The problem is surprising capabilities.

Use this check:

OperationCapability a caller can reasonably expect
make()in-memory construction
create()declared local persistence
provision()local and remote effects
synchronize()remote read or write
dispatch()queued or message-bus work

Names do not enforce the boundary, but they let callers choose it. A create() which may block on a remote service has a materially different contract from one which only commits local state.

PRE-09 later addresses a different surprise: a factory’s in-memory make() path which writes related database rows. Here the hidden capability is the network collaborator reached through an observer.

Fail closed in tests

The retained test suite added a global connector rule:

normal test mode
    undeclared HTTP request -> immediate failure

explicit real-API mode
    stray requests permitted

Before the guard, an unfaked request could perform DNS work, wait for timeouts, and run the connector’s retries. After the guard, the connector throws before leaving the process.

This is a useful safety net for three reasons:

  1. a test cannot silently depend on remote availability;
  2. retry delays no longer disguise missing setup as a slow suite; and
  3. the first unexpected request identifies the hidden boundary.

It is not a complete repair. In the retained path, export preparation catches the missing-fake exception. The factory therefore completes quickly, but a test which does not assert remote behavior may still miss that the observer tried to call it.

Tests whose contract includes the remote lookup install a request-specific fake before creating the model:

RemoteHttp::fake([
    LookupAccount::class => FakeResponse::notFound(),
]);

$user = UserFactory::new()->create([
    'external_id' => 123,
]);

Order matters because the model event fires synchronously during create(). Registering the fake afterward cannot intercept work which already happened.

A default fake can hide the same mistake

It is tempting to install one successful account response for the entire test suite. That makes construction fast, but it changes the failure mode from obvious to invisible.

Every test now receives a remote behavior it did not request. A factory can acquire more network dependencies without changing any test setup. Assertions about request count can include calls created by unrelated fixtures.

A better default is prohibition:

no declared fake
    fail immediately

test declares remote behavior
    return the exact response for the exact request type

integration suite declares real boundary
    allow the network in an isolated job

There are exceptions. A framework package may issue a stable internal request which every feature test requires. If a shared fake is necessary, document its scope and assert it cannot match unrelated traffic.

The default should make new capabilities visible, not make them convenient to forget.

Constructor injection is not enough

A class can accept one collaborator explicitly and still resolve five others from the container:

final class ExportProfile
{
    public function __construct(
        private Logger $logger,
    ) {}

    public function execute(User $user): void
    {
        app(RemoteProfileGateway::class)->export($user);
    }
}

The constructor is syntactically injected. The network dependency remains ambient.

Inspect behavior as well as signatures:

  • global container resolution;
  • static facades which can perform I/O;
  • model observers and listeners;
  • service locators and app() helpers;
  • implicit current tenant, actor, request, or locale; and
  • factories which call application services during callbacks.

A later static-analysis control can forbid selected ambient access in stable service directories. That belongs to a separate problem: how to make a bounded rule credible. The first design step is to decide which operation actually owns the collaborator.

Test the capability boundary

The public reproduction runs two synthetic designs.

The hidden design constructs a record, fires a synchronous observer, and reads a remote gateway from a global registry. It proves that installing the fake after construction is too late and that changing global state changes the same factory call.

The explicit design constructs a provisioning service with a repository and gateway. The factory remains local. Tests choose the remote result before they execute the use case.

The fixture does not perform HTTP, run the private framework observer, or prove that constructor injection prevents every ambient dependency. Retained history establishes the three retry attempts and the shift from sub-five-second tests to runs above thirty seconds; it does not retain a suite-wide timing report or prove that production creation has been decoupled.

When construction can cross a network boundary, the dependency is already part of its public contract. Either put that capability in the operation’s name and constructor, or move it to work the caller can choose explicitly.