DEV Community

Kerem Güneş
Kerem Güneş

Posted on • Edited on

SOLID Principles with API Examples

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.
  }
}
Enter fullscreen mode Exit fullscreen mode
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.'
    ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode
// 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.
  }
}
Enter fullscreen mode Exit fullscreen mode
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());
    }

    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode
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.'
      ]);
    }

    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode
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()
        ]);
      }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
  }
}
Enter fullscreen mode Exit fullscreen mode
class DiscountCalculator {
  // @see Delegation Pattern.
  public function calculate(Discount $discount, float $amount): float {
    return $discount->calculate($amount);
  }
}
Enter fullscreen mode Exit fullscreen mode
class PaymentController extends Controller {
  // @call POST /payment
  public function paymentAction(): Payload {
    // ...

    $calculator = new DiscountCalculator();
    $discount   = $calculator->calculate(
      DiscountFactory::create($user),
      $grossTotal
    );
    $netTotal   = $grossTotal - $discount;

    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
  }
}
Enter fullscreen mode Exit fullscreen mode
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
    ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
  }
}
Enter fullscreen mode Exit fullscreen mode
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);
    }

    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
  }
}
Enter fullscreen mode Exit fullscreen mode
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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
  }
}
Enter fullscreen mode Exit fullscreen mode
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);
    }

    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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()];
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
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);
    }

    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  ) {}
}
Enter fullscreen mode Exit fullscreen mode
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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)