A hundred and forty interfaces in the source tree, of which a hundred and twenty-eight had exactly one implementation. Navigating the codebase meant landing on a signature and then finding the class that actually did something, every time.
The symptom
$ ./bin/interface-census
interfaces: 140
with 1 implementation: 128
with 1 impl + 1 test double: 41
with 2+ real implementations: 12
$ ./bin/interface-census --detail | head -4
OrderRepositoryInterface 1 impl, 1 fake
PricingServiceInterface 1 impl, 0 fakes
InvoiceNumberGenerator 1 impl, 0 fakes
MailerInterface 3 implsThe second line is the interesting one: eighty-seven interfaces with a single implementation and no test double at all. Those exist for no reason that survives being asked out loud.
Why it happens
A habit acquired from a testing style where every collaborator was mocked, and a rule — “depend on abstractions” — applied without the second half of the sentence. An interface per class is the shape you get when the rule is followed mechanically.
The fix
The three reasons an interface earns its place
1 there is genuinely more than one implementation,
now, in production. not "might be later".
2 it crosses a boundary the dependency must not
cross the other way — a module's public API, a
port to an external system.
3 the implementation is expensive or
non-deterministic and a test needs a substitute
that is REAL enough to be trusted, meaning it
shares a contract test.
and the reason that is not one:
"so it can be mocked" — a mock is not an
implementation, it is an assertion about a call.The third reason is the one that requires care, because it is the one everybody claims. A test double justifies an interface only if the double is behaviourally trustworthy, which in practice means a contract test — and eighty-seven of ours had no double at all, so the question did not arise.
A test double is not a second implementation, unless it is
// a mock: an assertion dressed as an object
$repo = $this->createMock(OrderRepository::class);
$repo->method('find')->willReturn($order);
// this proves nothing about OrderRepository. it works
// on a concrete class too, and it is a smell either way.
// a fake: a real implementation with different storage
final class InMemoryOrders implements OrderRepository
{
/** @var array<string, Order> */
private array $orders = [];
public function find(OrderId $id): ?Order
{
return $this->orders[(string) $id] ?? null;
}
}
// this IS a second implementation, and it earns the
// interface — provided a contract test keeps it honest.
Deleting ninety of them
# mechanical: the interface has one implementor and no
# other implementors anywhere, including tests
for iface in $(./bin/interface-census --single --no-fake); do
impl=$(./bin/implementor "$iface")
# rename the implementation to the interface's name,
# delete the interface, update the container binding
./bin/collapse-interface "$iface" "$impl"
done
# 87 collapsed automatically. 3 needed hand editing:
# they were type-hinted in a docblock generic, which the
# script did not rewrite.
and the two that were load-bearing after all:
ClockInterface one implementation, no fake —
because the test used a mock. it
should have a fake, so the
interface stayed and the fake
was written.
StorageInterface one implementation in this
application, and the package it
lives in is consumed by two
others with different storage.
the census only looked at one
repository.The storage one is the failure of the analysis rather than of the rule: a census run over a single repository cannot see consumers elsewhere. Any interface in a published package is out of scope for this exercise entirely, and that had to be added as an exclusion.
The boundary interfaces we kept
50 kept, and why:
12 genuine multiple implementations
14 module public APIs — the layer rules depend on
them
11 ports to external systems, each with a fake and
a contract test
8 PSR interfaces, which are not ours
5 published in packages consumed elsewhere
none of them is an interface for a class that lives
next to it in the same directory.What became harder
// mocking a concrete class still works
$pricing = $this->createMock(PricingService::class);
// but the constructor runs unless you suppress it, and
// a final class cannot be mocked at all — which is the
// point, because both of those are pressure toward
// either a real object or a proper fake.
// what 14 tests became:
$pricing = new PricingService(new FixedRateTable([
'gold' => 10, 'silver' => 5,
]));
// a real object with test data. shorter, and it fails
// when PricingService changes behaviour.
Fourteen tests that had mocked a pricing service now construct a real one with a small rate table, which is both shorter and a genuinely better test — the mock had been asserting that a method was called, and the replacement asserts that the price is right.
Verifying it worked
$ ./bin/interface-census
interfaces: 50 # was 140
with 1 implementation: 0
with 1 impl + a contract-tested fake: 11
with 2+ real implementations: 12
$ vendor/bin/phpunit
Tests: 1,414 passed
$ grep -rc 'createMock' tests/ | awk -F: '{s+=$2} END {print s}'
41 # was 88
$ vendor/bin/deptrac
0 violations # the 14 module
# APIs still holdHalving the mock count is the secondary outcome and probably the more valuable one — every removed mock is a test that now exercises real behaviour rather than asserting on a call that was made.
What this costs
A rule that somebody will apply too literally in the other direction. “Delete interfaces with one implementation” is as mechanical as the habit it replaced, and applied without the three reasons it will remove a module boundary because the module currently has one implementation of its port.
The census script is also now infrastructure with a known blind spot: it sees one repository. Anything published for external consumption has to be excluded by hand, which is a list that will go stale — and the failure is a deleted interface in a package somebody else depends on.