The interface with one implementation, and when that is fine

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 impls

The 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 hold

Halving 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.