An internal package shipped with its own container. Eleven services, autowired, compiled on first use — which on a request that does twelve milliseconds of actual work was ninety milliseconds of building an object graph.
The symptom
the profile, one request through the package:
ContainerBuilder::compile 62.1ms
Definition resolution (11 services) 21.4ms
ReflectionClass::__construct × 34 6.2ms
the actual work 12.0ms
────────────────────────────────────────────
101.7ms
and the application already has a container. this is a
second one, inside a package, for eleven objects.Reflection over thirty-four classes to wire eleven services, on every process start, in a package whose entire job is to talk to one API. The container was doing exactly what it is designed to do and there was nothing here for it to do.
Why it happens
A container is the correct answer for an application, where the wiring is large, changes often and is configured by the person deploying it. A library is the opposite on all three counts, and the pattern gets copied across the boundary because it is what the author writes every day.
The fix
What the container was actually doing
eleven service definitions, examined:
9 autowiring. no arguments, no configuration, no
conditional. the definition was the constructor.
1 a decorator, applied when a flag was set
1 a factory that chose between two implementations
based on a configuration value
zero used tags, lazy services, or the compiler pass
that had been written in 2020 and did nothing.Four functions
namespace TurkerDevRates;
function client(Config $config, ClientInterface $http): RatesClient
{
$client = new HttpRatesClient($http, requests(), $config->endpoint);
return $config->cacheTtl > 0
? new CachingRatesClient($client, $config->cache, $config->cacheTtl)
: $client;
}
function requests(): RequestFactoryInterface { /* ... */ }
function parser(): ResponseParser { /* ... */ }
function validator(Config $config): Validator { /* ... */ }
The decorator that was a conditional definition is now a conditional expression, which is four words shorter and readable by somebody who has never seen the container’s configuration format. The factory that chose between implementations is a match.
Keeping the PSR-11 boundary
// the application still wires it however it likes
$container->set(RatesClient::class, fn (Container $c) =>
TurkerDevRatesclient($c->get(Config::class), $c->get(ClientInterface::class))
);
// and a consumer with no container at all
$rates = TurkerDevRatesclient(new Config(...), new Psr18Client());
The package no longer has an opinion about how the application manages objects, which is the actual improvement — the previous version required the consumer to either use the bundled container or reimplement the wiring by reading it.
What we lost
lazy services. and one of them mattered.
the validator loads a 400 KB rule set at construction.
under the container it was lazy, so a request that never
validated never paid for it.
with plain functions, client() calls validator().
fixed by making the rule set lazy INSIDE the validator:
private ?array $rules = null;
private function rules(): array {
return $this->rules ??= $this->load();
}
which is where the laziness belonged anyway — the
container had been compensating for a class that eagerly
loaded something it might not need.This is the pattern worth noticing: a container feature was hiding a design problem in a class. Removing the container surfaced it, and the fix is three lines in the class that had the problem rather than a proxy generated by a framework.
When a container earns its place
keep it when:
the wiring is configured by the deployer
services number in the dozens or hundreds
tags drive a plugin or listener registry
the graph changes with the environment
which is: most applications, and almost no libraries.
the application here has 214 services and a container,
and nothing in this post is an argument against that.Verifying it worked
$ php bench/rates.php
before 101.7ms (89.7 wiring, 12.0 work)
after 12.4ms (0.4 wiring, 12.0 work)
$ composer why-not turkerdev/rates 2.0
# the container dependency, and its 4 transitive
# packages, removed from require
$ vendor/bin/phpunit packages/rates
Tests: 88 passed
# 14 of which no longer need a container fixtureRemoving four transitive dependencies is the secondary benefit and it is not small — a library that pulls in a container pulls in whatever that container depends on, into every application that uses it.
What this costs
Wiring that nobody generates. Adding a service means editing a function, and the function is a file in the package rather than a configuration file the consumer controls — which is correct for a library and would be wrong for an application.
It also removes the ability to override one service from outside without reimplementing the factory. That is a real loss for a consumer who wants a different parser, and the answer — accept an optional parser argument — is a signature change rather than a configuration entry.