Interfaces and depending on abstractions
Chapter 5 used an interface called NotificationChannel.
This chapter digs into what interfaces are for.
What an interface is
An interface defines only method names along with argument and return types, and carries no implementation.
In PHP you define one with the interface keyword.
interface Logger
{
public function log(string $message): void;
}
This interface defines a contract: "there is a method named log, it takes a string, and it returns nothing."
A class satisfies the contract by implementing it with implements.
class FileLogger implements Logger
{
public function log(string $message): void
{
// Write to a file (omitted)
}
}
The problem with depending on a concrete class
Look at the OrderService class below.
class OrderService
{
private FileLogger $logger;
public function __construct()
{
$this->logger = new FileLogger();
}
public function placeOrder(Order $order): void
{
// Order processing (omitted)
$this->logger->log('Order accepted');
}
}
OrderService depends directly on the concrete class FileLogger.
The moment you want to send logs somewhere other than a file — CloudWatch, or Slack — you have to rewrite the constructor of OrderService.
Where logs go, which has nothing to do with order processing, has become a reason to change OrderService.
Depending on an abstraction
Instead of the concrete FileLogger, make it depend on the Logger interface.
class OrderService
{
public function __construct(
private readonly Logger $logger,
) {
}
public function placeOrder(Order $order): void
{
// Order processing (omitted)
$this->logger->log('Order accepted');
}
}
OrderService now depends only on the Logger contract, and does not know whether what it is handed is a FileLogger or some other implementation.
Pass a CloudWatchLogger instead of a FileLogger and the log destination changes without touching any OrderService code.
The idea that "a higher-level class depends on an abstraction (an interface) rather than a concrete class" is called the dependency inversion principle (DIP)1.
It is called "inversion" because it replaces the notion that a higher-level class (OrderService) depends directly on a lower-level one (FileLogger) with the arrangement that both depend on an abstraction (Logger).
The mechanism of handing the Logger implementation to OrderService from the outside, as a constructor argument, is called dependency injection.
Chapter 11 covers it in detail alongside the Laravel container.
Summary
- An interface defines a "contract" that carries no implementation
- Depending directly on a concrete class means rewriting the dependent class whenever you want to swap the implementation
- Depending on an interface means not needing to know the implementation details, which makes swapping easy
- This idea is called the dependency inversion principle; the mechanism that actually passes the dependency in from outside is called dependency injection, covered in chapter 11
What to read next
The next chapter covers cohesion, coupling, and SOLID in review. As a wrap-up of the chapters so far, we revisit the principles we have seen against a common yardstick.
Exercises
Rewrite the following PaymentProcessor class to depend on an interface
class StripeGateway
{
public function charge(int $amount): void
{
// Stripe API call (omitted)
}
}
class PaymentProcessor
{
private StripeGateway $gateway;
public function __construct()
{
$this->gateway = new StripeGateway();
}
public function process(int $amount): void
{
$this->gateway->charge($amount);
}
}
Sample answer
interface PaymentGateway
{
public function charge(int $amount): void;
}
class StripeGateway implements PaymentGateway
{
public function charge(int $amount): void
{
// Stripe API call (omitted)
}
}
class PaymentProcessor
{
public function __construct(
private readonly PaymentGateway $gateway,
) {
}
public function process(int $amount): void
{
$this->gateway->charge($amount);
}
}
PaymentProcessor depends only on the PaymentGateway contract.
Switching payment providers away from Stripe means passing in a new class that implements PaymentGateway.