2026-04-27 · 9 min read · by Byte Vader
Why use interfaces
Why interfaces are never a waste of time, with real-world examples, common pitfalls, and a few opinions.

Frequently my everyday tasks require me to do a code review for my fellow developers, and oftentimes the discussion leads us to interfaces. Apart from the basic interface-related questions like what is an interface and why you should use it, the discussion leads us to when to use an interface and is it really necessary in the particular code situation. I'm sure that developers who are dealing with interfaces ask themselves these questions pretty often. So if you would like to clear up some terms and see real-world examples of how and when to use interfaces, you are at the right place.
You probably know what the interface is, but just to be sure let's repeat. The interface is a syntax that enforces us to have methods implemented in the concrete class. By having an interface, we are sure that we will have exact methods with the exact number and type of arguments and the same return type implemented inside all classes that implement that interface (i.e. concrete implementations).
We can use the following code as an example interface:
interface ApiClient
{
public function userDetails(UserId $userId): UserDetails;
}And this would be the concrete implementation:
final class RestApiClient implements ApiClient
{
public function __construct(private Client $client, private RequestFactory $requestFactory)
{
}
public function userDetails(UserId $userId): UserDetails
{
$request = $this->requestFactory->userDetails($userId);
$this->client->request(
(string) $request->method(),
(string) $request->urlPath(),
$request->options->toArray(),
);
}
}In the real world, we're building applications to meet business needs. Therefore we need to interact with the database, store and retrieve data from a cache, hash passwords, verify password hash, send messages to the bus, implement integrations with some 3rd party systems, and a lot more.
All of those tasks can be done in many ways. For example, passwords can be hashed with bcrypt, sodium (which again can use many different functions), by some hasher integrated inside the framework, etc. Cache mechanisms can be Redis, Memcache, file system cache, or some other. Message bus can be asynchronous and implemented with e.g. RabbitMQ, Redis, Kafka or it can be synchronous.
Most applications that we're working on will eventually get bigger, ie we'll add new functionalities to the application. As new requirements ie functionalities appear, the application grows, and sometimes we need to change one of the existing functionalities. E.g. we need to contact a different API to retrieve the weather forecast. Or the client calls us and reports that the application is slow when some of the screens are opened. After debugging and investigating we find out that we have a slow database query and the only solution to the problem is to rewrite the query and stop using ORM. Or, debugging leads us to the conclusion that we can achieve better application performance by switching the ORM. Maybe we need to move our user handling logic to a separate service so we need to make an HTTP request to that service to store users.
Whatever the conclusion, it will result in introducing a change in the existing application functionality, and that is where interfaces can significantly help us.
Let's talk about some real-world examples of that slow query, e.g. user management.
In order to handle user management, we need to implement a lot of different tasks in our application. We probably split those tasks into multiple classes, where each class has some functionality/responsibility. By doing that we can call that functionality from any part of the application. To have the ability to call given functionality from any part of the application we need to inject a class that contains the given functionality into the other class or function which will then call that functionality.
If we have a repository that saves user data to some persistent storage, then we need to inject that repository inside each part of the application, which needs to save user data. Where would that repository be injected? In all the classes that are doing some of these operations:
- creating a new user
- updating user data
- activating the user
So if we need to change the way we're saving users because that query is slow, then we need to create a new repository class and replace all places where the old implementation is used. That can be a lot of work, right? Fear not, that is why some smart developers have "invented" interfaces. I will demonstrate to you why using interfaces is never a waste of time.
Interfaces to the rescue
Instead of injecting a concrete implementation of the repository class, we can define an interface for it and we can have one concrete implementation that uses ORM. Then we type-hint interface in all places where we need to have a user repository and we pass concrete implementation which uses ORM. Using a service container inside the application would make things even easier, because we can alias the interface to the concrete implementation that uses ORM. Then, if we need to change the implementation because any of the above reasons we just create the new concrete implementation that implements the same interface and pass the new concrete class to all places that type-hinted the interface.
interface UserRepository
{
public function save(User $user): void;
}final class DoctrineUserRepository extends ServiceEntityRepository implements UserRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function save(User $user): void
{
$this->_em->persist($user);
$this->_em->flush();
}
}final class CreateUserCommandHandler
{
public function __construct(private UserRepository $userRepository)
{
}
public function __invoke(CreateUserCommand $command): void
{
$this->userRepository->save(new User($command->id, $command->email));
}
}Here we have an interface type hinted inside of the command handler. It expects any implementation of the given interface and it calls the save method. So we can make a new implementation like in the example below:
final class DatabaseUserRepository implements UserRepository
{
public function __construct(private Connection $connection)
{
}
public function save(User $user): void
{
$this->connection->prepare('INSERT INTO users (id, email) VALUES (:id, :email)')->execute([
'id' => (string) $user->id(),
'email' => (string) $user->email(),
]);
}
}Now, we can inject this implementation inside all the classes that type hinted repository interface.
If we use a service container, just make a new alias for the given interface.
If we don't use a service container we just need to pass a new concrete implementation to our command handler.
$handler = new CreateUserCommandHandler(
new DatabaseUserRepository(
new Connection(/*...*/)
)
);And we're done. The application now works faster and the client is happy.
And we're sure that all methods inside the handler will work as they worked before.
Now you can imagine what it takes to switch the implementation if the user repository is injected in let's say twenty places. We just need to run tests for all classes where we switched the implementation and they should all be green.
Speaking of the tests
When speaking about the tests, all the classes where we injected the interface are pretty easy to test. Either we mock the interface, and we're sure that we'll have all methods from the interface available:
final class CreateUserCommandHandlerTest extends TestCase
{
private MockObject $userRepositoryMock;
private CreateUserCommandHandler $createUserCommandHandler;
protected function setUp(): void
{
$this->userRepositoryMock = $this->createMock(UserRepository::class);
$this->createUserCommandHandler = new CreateUserCommandHandler($this->userRepositoryMock);
}
public function testSomething(): void
{
$this->userRepositoryMock->expects(self::once())->method('save');
// ...
}
}Or we can make a stub class inside our test:
final class CreateUserCommandHandlerTest extends TestCase
{
private UserRepositoryStub $userRepositoryStub;
private CreateUserCommandHandler $createUserCommandHandler;
protected function setUp(): void
{
$this->userRepositoryStub = new UserRepositoryStub();
$this->createUserCommandHandler = new CreateUserCommandHandler($this->userRepositoryStub);
}
public function testSomething(): void
{
// ...
}
}
final class UserRepositoryStub implements UserRepository
{
public function save(User $user): void
{
// ...
}
}So for what functionalities should I use the interface?
That's a completely valid question, especially when you think of the old saying "to a man with a hammer, everything looks like a nail". There should be an interface for each part of the application which:
- Uses vendor code
- Talks to the external system (persistent database, cache, another service over the network, message queue, etc.)
- Makes read/write operations on the filesystem
- Uses any part of the native language code which uses host functions for execution (e.g. DateTime)
You might think you don't need an interface for all vendor code because, for example, you'll never change Carbon usage. The reality is that Carbon used to throw an exception when you made a new instance with invalid data (e.g. new CarbonImmutable('12345')). In the newer versions of the Carbon given code example would return false. So it would be better if we make an interface and concrete implementation that uses Carbon.
interface ClockGenerator
{
public function generateFromCurrentTime(): Clock;
}final class ClockGeneratorUsingCarbon implements ClockGenerator
{
public function generateFromCurrentTime(): Clock
{
$now = new CarbonImmutable();
Assertion::isInstanceOf($now, CarbonImmutable::class);
return new Clock($now->getTimestamp());
}
}This way we need to change only one class (or create the new one if we're changing the date-time vendor).
A couple of more advices
There is no such thing as too many interfaces. Use them wherever the concrete implementation might change in the future, but be reasonable and don't use them in places where you're sure that concrete implementation won't change or where there are no dependencies on a given code (or class). E.g. use them in places like vendor-dependent code, repositories, network calls, etc. Rethink using them in controllers, command handlers, entities, etc.
It would be good to use service containers with auto wiring and aliasing for easier interface usage. You can inject the interface in classes that need some dependency and the service container will resolve the concrete implementation.
Last but not least, the bigger the interface, the less change-resistant the implementation. But that is something you probably know because you're following SOLID principles all over your code.
Conclusion
After I demonstrated what interfaces are, how to use them, and how easy it is to introduce them in the codebase I think you have the idea why I'm such an interface advocate.
Be aware that certain functionalities can change implementation in the future, even if it is a "small" application. When using the interface, we can change the implementation without touching classes that use the given implementation. We're sure that calling code is correct. Think about the ability to test your code if your dependencies are injected with concrete implementation. Also, by creating the interface you'll probably have a better idea of what your class needs to be able to do.
Happy coding!