Skip to main content
ArticlesProjects

The Interface With One Implementation

You added it so the thing could be swapped out later. Later arrived and nothing was. Counting implementations is the wrong test, and where the interface lives tells you far more.

Somewhere in the codebase there is an OrderRepositoryInterface. Next to it, in the same folder, there is an EloquentOrderRepository. In a service provider there is a line binding one to the other, and that line has not changed since it was written.

The interface was added so the persistence layer could be swapped out later. It is now later. Nothing has been swapped, nothing is going to be, and every time someone follows a call from a controller they land on a method signature with no body and have to jump again to find out what actually happens.

This is the article where the series has to be honest with itself, because everything so far has argued for moving things apart, and this is the case where the profession’s default advice is wrong often enough to be worth attacking directly.

Name what you are buying

An interface buys you one of three things. Not two, not a general sense of cleanliness. Three, and you should be able to say which one before you add it.

Runtime substitution. You genuinely run more than one implementation. A filesystem driver that is S3 in production and local on a developer machine. A payment gateway you actually have two of because you took on a second market. If this is real, the interface is not a question, it is the mechanism.

Test substitution. You want a fake in a test. This is the reason people give most often, and in Laravel it is the weakest of the three, which I will come back to.

Declaring a direction. You want the thing on the inside to stop depending on the thing on the outside, so you define the contract on the inside and let the outside implement it. This is dependency inversion, it has nothing to do with substitution, and it is the one people are usually reaching for without having the words.

If you cannot say which of those three you are buying, you have not bought anything. The interface is a habit.

Laravel already faked it for you

The test substitution argument deserves attacking on its own, because it is the one that sounds most responsible.

The reasoning goes: my code calls out to something slow or external, so I need to be able to replace it in a test, so I need an interface. In a framework without a container and a well developed set of test doubles that would be sound. Laravel is not that framework.

Http::fake(['api.stripe.com/*' => Http::response(['id' => 'ch_1'], 200)]);
Queue::fake();
Bus::fake();
Mail::fake();
Storage::fake('invoices');
Event::fake([OrderRefunded::class]);
Notification::fake();

Every one of those replaces an edge in a test with no interface, no binding and no second class. The framework already inverted those dependencies for you, and it did it at the facade so you did not have to restructure anything to benefit.

So when somebody says the interface is there for testing, the useful question is: what does the test look like without it? If the answer is one of the lines above, the interface is not paying for testing. It is paying for nothing and being credited for testing.

There is a real exception, and it is your own outbound integration code. If you have wrapped an external API in a client class of your own, Http::fake() fakes the HTTP but not your client’s behaviour, and a fake implementation of your own client can be genuinely more expressive than asserting on request payloads. That is a real reason. It is also a much narrower one than “we use interfaces for testability”.

Counting implementations is the wrong test

Part three argued that counting a model’s lines tells you nothing useful. The same trap is set here, in a different shape.

The obvious metric for an interface is how many classes implement it, and one implementation looks like a smell. But run that metric over a package you trust and it falls apart immediately.

Terminal window
# every interface declared under a path, and how many classes implement it
grep -rlE "^\s*(final\s+)?interface\s" app/ --include=*.php | while read -r f; do
name=$(basename "$f" .php)
impls=$(grep -rlE "implements[^{]*\b${name}\b" app/ --include=*.php | grep -vFx "$f" | wc -l)
printf '%2d %-34s %s\n' "$impls" "$name" "$(dirname "$f")"
done | sort -n

Point that at vendor/laravel/fortify/src and almost every contract comes back with zero implementations. CreatesNewUsers, LoginResponse, ResetsUserPasswords, the whole Contracts directory.

By the naive metric those are the worst interfaces in the ecosystem. They are in fact exactly right, and the reason is that Fortify is not supposed to implement them. You are. Fortify declares what it needs and your application supplies it, so the package depends on your code without knowing anything about it.

Zero implementations inside the thing that declares the interface is not a warning. It is the signature of an interface that is doing its job.

The question is who owns it

Which gives you the measurement that actually works, and it is about location rather than count.

Look at where the interface is defined and where it is implemented, and ask which of those two things is allowed to know about the other.

app/Repositories/OrderRepositoryInterface.php
app/Repositories/EloquentOrderRepository.php

Same directory, same layer, same commit. Nothing has been inverted here. Anything that depended on EloquentOrderRepository now depends on OrderRepositoryInterface, which lives in the same place and changes at the same time for the same reasons. The dependency graph is unchanged and there is one more file in it.

app/Domain/Billing/ChargesCards.php <- interface, defined by the domain
app/Infrastructure/Stripe/StripeCharges.php <- implementation, out at the edge

This one did something. The domain says what it needs in its own vocabulary, the edge supplies it, and the arrow that used to run from the middle out to Stripe now runs from Stripe in toward the middle. Delete the Stripe directory and the domain still compiles. That is a property you can check, and you want it whether or not a second gateway ever exists.

Same construct, same count, completely different value, and the difference is which side of the boundary the file sits on.

I have made the general version of this argument before in The Pattern-First Trap in PHP, which is about not starting from patterns. This is the same idea pointed backwards: you already started from one, so how do you tell whether it earned anything.

When one implementation is right

“Delete your interfaces” is as bad a rule as the one it replaces, so here is when to keep one.

Keep it when the interface is owned by the inner side and implemented by the outer side, as above. The count is irrelevant.

Keep it when it is a published contract. If the implementation lives in a package other people install, the interface is the API and the number of implementations in your repository says nothing.

Keep it when you are enforcing the boundary with something. Pest’s architecture testing can assert that a namespace only depends on what it is allowed to, and an interface is what gives that assertion something to name.

arch('the domain does not reach for the framework')
->expect('App\Domain')
->not->toUse(['Illuminate\Support\Facades', 'App\Models']);

That test is the thing that makes the direction real. Without it the direction is a convention, and conventions decay quietly.

Delete it when the interface and its only implementation live in the same folder, are bound one to one in a provider, and no test uses a different implementation. That combination has no runtime substitution, no direction, and its test substitution claim can be checked in about a minute by grepping the test suite for the interface name.

How to delete one safely

The removal is less frightening than it looks, and PHPStan does most of it.

Replace the type hints with the concrete class, delete the binding, delete the interface, run static analysis. Everything that was relying on the abstraction fails immediately and locally, and there is no runtime surprise waiting, because the container was only ever resolving one thing.

The one thing to watch for is a test that binds a different implementation. If the suite has $this->app->bind(OrderRepositoryInterface::class, FakeOrderRepository::class) anywhere, the interface has been buying test substitution after all, and you should decide whether the fake is better than the framework’s own faking before you take it away.

Terminal window
grep -rn "OrderRepositoryInterface" tests/

If that returns nothing, the abstraction has never been used as an abstraction.

What to do on Monday

Run the interface listing against app/. For each one, answer the question in one sentence: runtime substitution, test substitution, or direction.

The ones where the answer is “direction” are worth checking against their location, because that is the claim most likely to be aspirational. If the interface sits in the same folder as its implementation, the direction was never established, and you can either move it to the side that should own it or admit it was decoration.

The ones where you cannot produce a sentence at all are the ones to delete, and you will find that deleting them is a smaller change than adding them was.

Next in this series: the codebase that was completely fine until somebody added a second one, and why a second anything is the only trigger in this series worth acting on.

Part of a Series

Why Is This Hard To Change?

You are reading Part 5 of 7 in this learning series.

View Full Series

Share

XLinkedIn

Related

Keep Reading

All posts →