Sometimes, when trying to implement conceptual ideas, it can be challenging to find real-world, practical examples that go beyond the typical “Rectangle/Square” scenarios. As developers, we often want to see how these concepts apply in our day-to-day work, helping us to understand and retain them better.
After reading numerous conceptual and definitional articles but encountering few API-related examples, I decided to write about these principles using real-world API examples, some of which I have used in the past. Below, you will find more code snippets than plain text (say I am better at coding than speaking).
In short, SOLID represents a set of principles aimed at creating software that is easy to reuse, maintain, and understand. It offers five elegant principles to achieve this.
PS: If you’d like to dive deeper into the SOLID principles, I recommend reading Clean Architecture by Robert C. Martin, as well as exploring more practical coding resources such as Refactoring by Martin Fowler. Both of these books offer rich examples and detailed insights into maintaining clean and scalable code.
Let’s explore these principles one by one, with simple definitions, descriptions, and code examples.
1- Single Responsibility Principle (SRP)
Classes (also their methods, by the way) should have only one responsibility. So, if we push a bunch of unrelated jobs into one class, this will break the principle, making that class unhappy. Instead, we can create different classes for different jobs to follow the principle.
Here we have an API endpoint POST /token
for creating tokens, TokenController
class with tokenAction()
method for checking a user by username & password credentials using a User
entity.
Bad practice:
class User extends Entity {
public int $id;
public string $name, $email;
public UserSettings $settings;
// More props & setters/getters.
// @tofix: Does not belong here.
public function validatePassword(string $password): bool {
// Run validation stuff.
}
// @tofix: Does not belong here.
public function sendMail(string $subject, string $body): void {
// Run mailing stuff.
}
}
class TokenControler extends Controller {
// @call POST /token
public function tokenAction(): Payload {
[$username, $password]
= $this->request->post(['username', 'password']);
// @var User (say it's okay, no 404)
$user = $this->repository->getUserByUsername($username);
if ($user->validatePassword($password)) {
if ($user->settings->isTrue('mailOnAuthSuccess')) {
$user->sendMail(
'Success login!',
'New successful login, IP: ' . $this->request->getIp()
);
}
$token = new Token($user);
$token->persist();
return $this->jsonPayload(Status::OK, [
'token' => $token->getValue(),
'expiry' => $token->getExpiry()
]);
}
if ($user->settings->isTrue('mailOnAuthFailure')) {
$user->sendMail(
'Failed login!',
'Suspicious login attempt, IP: ' . $this->request->getIp()
);
}
return $this->jsonPayload(Status::UNAUTHORIZED, [
'error' => 'Invalid credentials.'
]);
}
}
Since we are stuffing User
entity with two unrelated methods (not belong there), there will probably be many reasons to change this class over time.
So here, we can use a better approach by simply moving these unrelated methods into more specific and one-reason-to-change classes.
Better practice:
class User extends Entity {
public int $id;
public string $name, $email;
public UserSettings $settings;
// More props & setters/getters.
}
// Each item is in its own file.
class UserHolder {
public function __construct(
protected readonly User $user
) {}
}
class UserPasswordValidator extends UserHolder {
public function validate(string $password): bool {
// Run validation business using $this->user->password.
}
}
class UserAuthenticationMailer extends UserHolder {
public function sendSuccessMail(string $ip): void {
// Run mailing business using $this->user->email.
}
public function sendFailureMail(string $ip): void {
// Run mailing business using $this->user->email.
}
}
class TokenControler extends Controller {
// @call POST /token
public function tokenAction(): Payload {
// ...
$validator = new UserPasswordValidator($user);
if ($validator->validate($password)) {
if ($user->settings->isTrue('mailOnAuthSuccess')) {
$mailer = new UserAuthenticationMailer($user);
$mailer->sendSuccessMail($this->request->getIp());
}
// ...
}
if ($user->settings->isTrue('mailOnAuthFailure')) {
$mailer ??= new UserAuthenticationMailer($user);
$mailer->sendFailureMail($this->request->getIp());
}
// ...
}
}
Now our code becomes more granular, so flexible and extendable. Testing is now more easy since we can test these unrelated methods separately as their own classes, as separate business owners.
But, wanna see one more benefit of this approach? Let's add one more check for IP validity into tokenAction()
and one more class named UserIpValidator
.
class UserIpValidator extends UserHolder {
public function validate(string $ip): bool {
$ips = new IpList($this->user->settings->get('allowedIps'));
return $ips->blank() || $ips->contains($ip);
}
}
class TokenControler extends Controller {
// @call POST /token
public function tokenAction(): Payload {
// ...
$validator = new UserIpValidator($user);
if (!$validator->validate($this->request->getIp())) {
return $this->jsonPayload(Status::FORBIDDEN, [
'error' => 'Non-allowed IP.'
]);
}
// ...
}
}
2- Open-Closed Principle (OCP)
Classes (also their methods, by the way) should be open for extensions, but closed for modifications (behavioral changes). In other words, we should be able to extend them without changing their behaviors. So, we can use abstractions to follow the principle.
Here we have an API endpoint POST /payment
for accepting payments, PaymentController
class with paymentAction()
method for processing user's payments by their subscriptions, and DiscountCalculator
for applying discounts to gross totals of these payments.
Bad practice:
class DiscountCalculator {
// @see Spaghetti Pattern.
public function calculate(User $user, float $amount): float {
$discount = match ($user->subscription->type) {
'basic' => $amount >= 100.0 ? 10.0 : 0,
'silver' => $amount >= 75.0 ? 15.0 : 0,
default => throw new Error('Invalid subscription type!')
};
return $discount ? $amount / 100 * $discount : $amount;
}
}
class PaymentController extends Controller {
// @call POST /payment
public function paymentAction(): Payload {
[$grossTotal, $creditCard]
= $this->request->post(['grossTotal', 'creditCard']);
// @var User (say it's okay, no 404)
$user = $this->repository->getUserByToken($token_ResolvedInSomeWay);
$calculator = new DiscountCalculator();
$discount = $calculator->calculate($user, $grossTotal);
$netTotal = $grossTotal - $discount;
try {
$payment = new Payment(amount: $netTotal, card: $creditCard);
$payment->charge();
if ($payment->okay()) {
$this->repository->saveUserPayment($user, $payment);
}
return $this->jsonPayload(Status::OK, [
'netTotal' => $netTotal,
'transactionId' => $payment->transactionId
]);
} catch (PaymentError $e) {
$this->logger->logError($e);
return $this->jsonPayload(Status::INTERNAL, [
'error' => 'Payment error.'
'detail' => $e->getMessage()
]);
} catch (RepositoryError $e) {
$this->logger->logError($e);
$payment->cancel();
return $this->jsonPayload(Status::INTERNAL, [
'error' => 'Repository error.',
'detail' => $e->getMessage()
]);
}
}
}
Since DiscountCalculator
class is not closed to changes, we will always need to change this class to support new subscription types whenever the system adds new subscription types for users.
So here, we can use a better approach by simply creating classes related to the subscription types based on an abstraction, and using it in DiscountCalculator
class.
Better practice:
// Each item is in its own file.
abstract class Discount {
public abstract function calculate(float $amount): float;
// In respect of DRY principle.
protected final function calculateBy(
float $amount, float $threshold, float $discount
): float {
if ($amount >= $threshold) {
return $amount / 100 * $discount;
}
return 0.0;
}
}
// These classes can have such constants
// like THRESHOLD, DISCOUNT instead, BTW.
class BasicDiscount extends Discount {
public function calculate(float $amount): float {
return $this->calculateBy(
$amount, threshold: 100.0, discount: 10.0
);
}
}
class SilverDiscount extends Discount {
public function calculate(float $amount): float {
return $this->calculateBy(
$amount, threshold: 75.0, discount: 15.0
);
}
}
class DiscountFactory {
public static function create(User $user): Discount {
// Create a Discount instance by $user->subscription->type.
}
}
class DiscountCalculator {
// @see Delegation Pattern.
public function calculate(Discount $discount, float $amount): float {
return $discount->calculate($amount);
}
}
class PaymentController extends Controller {
// @call POST /payment
public function paymentAction(): Payload {
// ...
$calculator = new DiscountCalculator();
$discount = $calculator->calculate(
DiscountFactory::create($user),
$grossTotal
);
$netTotal = $grossTotal - $discount;
// ...
}
}
Now DiscountCalculator
class uses the real calculator (actually becomes its delegate) and complies the principle. So, if any change becomes required in the future, we never need to change calculate()
method anymore. We can simply add a new related class (e.g. GoldDiscount
for the type of "gold" subscriptions) and update the factory class by this need.
3- Liskov Substitution Principle (LSP)
Subclasses should be able to use all features of the superclasses, and all subclasses should be usable instead of their superclasses (by their same fields & behaviors with parental fields & behaviors). So, if subclasses will not use all features of inherited classes, then there will be unnecessary code blocks, or if subclasses change how their superclasses methods work, then this code will be more error prone.
Here we have an API endpoint POST /file
for working with files, FileController
class with writeAction()
method for writing a file, and File
/ ReadOnlyFile
classes for related works.
Bad practice:
class File {
public function read(string $name): string {
// Read file contents & return all read contents.
}
public function write(string $name, string $contents): int {
// Write file contents & return written size in bytes.
}
}
class ReadOnlyFile extends File {
// @override Changes parent behavior.
public function write(string $name, string $contents): int {
throw new Error('Cannot write read-only file!');
}
}
class FileFactory {
public static function create(string $name): File {
// Create a File instance controlling the name &
// deciding the instance type in some logic way.
}
}
class FileController extends Controller {
// @call POST /file
public function writeAction(): Payload {
// Auth / token check here.
[$name, $contents]
= $this->request->post(['name', 'contents']);
// @var File
$file = FileFactory::create($name);
// We are blindly relying on write() method here,
// & not doing any check or try/catch for errors.
$writtenBytes = $file->write($name, $contents);
return $this->jsonPayload(Status::OK, [
'writtenBytes' => $writtenBytes
]);
}
}
Since writeAction()
, so client code, relies on File
class and its write()
method, this action cannot work as expected as it has no check for any error because of this reliance.
So here, we need to fix the file classes by using abstractions first and then the client code by adding a simple check for writability.
Better practice:
// Each item is in its own file.
interface IFile {
public function isReadable(): bool;
public function isWritable(): bool;
}
interface IReadableFile {
public function read(string $name): string;
}
interface IWritableFile {
public function write(string $name, string $contents): int;
}
// For the sake of DRY.
trait FileTrait {
public function isReadable(): bool {
return $this instanceof IReadableFile;
}
public function isWritable(): bool {
return $this instanceof IWritableFile;
}
}
class File implements IFile, IReadableFile, IWritableFile {
use FileTrait;
public function read(string $name): string {
// Read file contents & return all read contents.
}
public function write(string $name, string $contents): int {
// Write file contents & return written size in bytes.
}
}
class ReadOnlyFile implements IFile, IReadableFile {
use FileTrait;
public function read(string $name): string {
// Read file contents & return all read contents.
}
}
class FileFactory {
public static function create(string $name): IFile {
// Create a File instance controlling the name &
// deciding the instance type in some logic way.
}
}
class FileController extends Controller {
// @call POST /file
public function writeAction(): Payload {
// ...
// @var IFile
$file = FileFactory::create($name);
// Now we have an option to check it,
// whether file is writable or not.
$writtenBytes = null;
if ($file->isWritable()) {
$writtenBytes = $file->write($name, $contents);
}
// ...
}
}
Signals of LSP violation;
- If a subclass throws an error for a superclass behavior it can't fulfill (eg: write() method of File > ReadOnlyFile : ReadOnlyError). Inner (override) issue.
- If a subclass has no implementation for a superclass behavior it can't fulfill (eg: write() method of File > ReadOnlyFile : "Do nothing..."). Inner (override) issue.
- If a subclass method always returns the same (fixed or constant) value for an overridden method. This is a very subtle violation and hard to spot. Inner (override) issue.
- If the clients know about the subtypes, mostly using "instanceof" keyword (eg: delete() method of FileDeleter : "if file instanceof ReadOnlyFile then return"). Outer (client) issue.
4- Interface Segregation Principle (ISP)
Interfaces should not be forced to take much responsibilities than they need, and also classes should not be forced to implement interfaces with features they don't need. This principle is similar to Single Responsibility Principle (SRP), and SRP is about classes but ISP is about interfaces. So, we can create different interfaces for different jobs to follow the principle.
Here we have an API endpoint POST /notify
for notifying users, NotifyController
class with notifyAction()
method for sending notifications to users, and Notifier
class for this work implementing INotifier
which is verbosely filled by methods.
Bad practice:
// Each item is in its own file.
interface INotifier {
public function sendSmsNotification(
string $phone, string $subject, string $message
): void;
public function sendPushNotification(
string $devid, string $subject, string $message
): void;
public function sendEmailNotification(
string $email, string $subject, string $message
): void;
}
class Notifier implements INotifier {
public function sendSmsNotification(
string $phone, string $subject, string $message
): void {
// Send a notification to given phone.
}
public function sendPushNotification(
string $devid, string $subject, string $message
): void {
// Send a notification to given device by id.
}
public function sendEmailNotification(
string $email, string $subject, string $message
): void {
// Send a notification to given email.
}
}
class NotifyController extends Controller {
// @call POST /notify
public function notifyAction(): Payload {
[$subject, $message]
= $this->request->post(['subject', 'message']);
// @var User (say it's okay, no 404)
$user = $this->repository->getUserByToken($token_ResolvedInSomeWay);
$notifier = new Notifier();
if ($user->settings->isTrue('notifyViaSms')) {
$notifier->sendSmsNotification($user->phone, $subject, $message);
}
if ($user->settings->isTrue('notifyViaPush')) {
$notifier->sendPushNotification($user->devid, $subject, $message);
}
if ($user->settings->isTrue('notifyViaEmail')) {
$notifier->sendEmailNotification($user->email, $subject, $message);
}
return $this->jsonPayload(Status::OK);
}
}
Since we are pushing many methods into INotifier
interface, we are far away from this motto: “Many client-specific (or niche) interfaces are better than one general-purpose (or simply saying fat) interface.”
So here, what we need to do is to separate each job with a separate interface that is present for its own job.
Better practice:
// Each item is in its own file.
interface ISmsNotifier {
public function send(
string $phone, string $subject, string $message
): void;
}
interface IPushNotifier {
public function send(
string $devid, string $subject, string $message
): void;
}
interface IEmailNotifier {
public function send(
string $email, string $subject, string $message
): void;
}
class SmsNotifier implements ISmsNotifier {
public function send(
string $phone, string $subject, string $message
): void {
// Send a notification to given phone.
}
}
class PushNotifier implements IPushNotifier {
public function send(
string $devid, string $subject, string $message
): void {
// Send a notification to given device by id.
}
}
class EmailNotifier implements IEmailNotifier {
public function send(
string $email, string $subject, string $message
): void {
// Send a notification to given email.
}
}
class NotifyController extends Controller {
// @call POST /notify
public function notifyAction(): Payload {
// ...
if ($user->settings->isTrue('notifyViaSms')) {
$notifier = new SmsNotifier();
$notifier->send($user->phone, $subject, $message);
}
if ($user->settings->isTrue('notifyViaPush')) {
$notifier = new PushNotifier();
$notifier->send($user->devid, $subject, $message);
}
if ($user->settings->isTrue('notifyViaEmail')) {
$notifier = new EmailNotifier();
$notifier->send($user->email, $subject, $message);
}
// ...
}
}
Let's do it better for NotifyController
, making it more programmatic and less verbosive, using a factory to get notifier instances by a user settings.
class NotifierFactory {
public static function generate(User $user): iterable {
if ($user->settings->isTrue('notifyViaSms')) {
yield [$user->phone, new SmsNotifier()];
}
if ($user->settings->isTrue('notifyViaPush')) {
yield [$user->devid, new PushNotifier()];
}
if ($user->settings->isTrue('notifyViaEmail')) {
yield [$user->email, new EmailNotifier()];
}
}
}
class NotifyController extends Controller {
// @call POST /notify
public function notifyAction(): Payload {
// ...
// Iterate over available notifier instances & call send() for all.
foreach (NotifierFactory::generate($user) as [$target, $notifier]) {
$notifier->send($target, $subject, $message);
}
// ...
}
}
5- Dependency Inversion Principle (DIP)
Changes in the subclasses should not affect the superclasses. In the other words, high-level classes should not depend on low-level classes, both should depend on abstractions. Also abstractions should not depend on details, details (concrete implementations) should depend on abstractions. So, we can use abstractions (mostly interfaces) between high-level and low-level classes to follow the principle.
Here we have an API endpoint POST /log
for logging some application activities, LogController
class with logAction()
method for logging these activities, and Logger
class as a service for these works.
Bad practice:
// Each item is in its own file.
class FileLogger {
public function log(string $data): void {
// Put given log data into file.
}
}
class Logger {
public function __construct(
private readonly FileLogger $logger
) {}
}
class LogController extends Controller {
// @call POST /log
public function logAction(): Payload {
// Auth / token check here.
$logger = new Logger();
$logger->log($this->request->post('log'));
return $this->jsonPayload(Status::OK);
}
}
Since Logger
class is using a specific logger implementation, this is not flexible code at all and will cause problems if any replacement or additional log service becomes required over time. We will need to change Logger
class whenever we want to send the logs to a database or other places.
So here, what can solve this issue dropping that concrete class injection (detailed implementation) from Logger
constructor and using an abstraction (interface) without changing the client code.
Better practice:
// Each item is in its own file.
interface ILogger {
public function log(string $data): void;
}
class FileLogger implements ILogger {
public function log(string $data): void {
// Put given log data into file.
}
}
// For future, maybe.
class DatabaseLogger implements ILogger {
public function log(string $data): void {
// Put given log data into database.
}
}
class Logger {
public function __construct(
private readonly ILogger $logger
) {}
}
Top comments (0)