Navigation

Programming

SOLID Principles: Practical Examples and Implementation

In 2014, my 2,400-line UserController was a mess. Learning SOLID principles turned my code—and career—around. Now it’s clean, testable, and maintainable. No more 3 AM calls. Just calm, confident coding.
Jul 04, 2025
10 min read
SOLID Principles: Practical Examples and Implementation

“Your code is garbage.”

That’s what my senior dev told me during my first code review at a startup in 2014. He wasn’t wrong. My UserController was 2,400 lines long and handled everything from authentication to sending birthday emails. When he mentioned SOLID principles, I nodded like I knew what he meant. (Spoiler: I didn’t.)

That night, I went home and Googled frantically. Ten years and countless refactoring sessions later, those five principles have saved my sanity more times than I can count. Let me show you what I’ve learned, with real scars to prove it.

S - Single Responsibility Principle

“A class should have one, and only one, reason to change.”

Sounds simple, right? Wrong. I once worked on an e-commerce site where the Order class was basically doing everything except making coffee. 1,847 lines of pure chaos. Want to change the email template? Better pray you don’t break payment processing. Need to update tax calculations? Hope you like testing PDF generation too.

Here’s what that monster looked like:

// Before: A class doing too much
class Order
{
    public function calculateTotal()
    {
        // Calculate order total
    }
    
    public function saveToDatabase()
    {
        // Database logic
    }
    
    public function sendConfirmationEmail()
    {
        // Email logic
    }
    
    public function generateInvoicePDF()
    {
        // PDF generation
    }
    
    public function logOrderActivity()
    {
        // Logging logic
    }
}

After applying SRP, each class had its own job:

// After: Each class has a single responsibility
class Order
{
    private $items = [];
    private $discount = 0;
    
    public function calculateTotal(): float
    {
        $subtotal = array_sum(array_map(fn($item) => $item->getPrice(), $this->items));
        return $subtotal - $this->discount;
    }
}

class OrderRepository
{
    public function save(Order $order): void
    {
        // Only handles database persistence
    }
}

class OrderMailer
{
    public function sendConfirmation(Order $order, Customer $customer): void
    {
        // Only handles email notifications
    }
}

class InvoiceGenerator
{
    public function generate(Order $order): PDF
    {
        // Only handles PDF generation
    }
}

The payoff was immediate. That same CEO who used to email me at midnight about “small changes” that broke everything? Now his invoice format changes take 10 minutes instead of 10 hours. And when we switched from SendGrid to AWS SES, I changed exactly one file. One! I actually got to leave work at 5 PM that day.

O - Open/Closed Principle

“Software entities should be open for extension but closed for modification.”

I learned this one during the great payment gateway disaster of 2018. We started with just PayPal. Simple, clean, working. Then the CEO read about Stripe on TechCrunch. Then marketing wanted Apple Pay. Then someone’s nephew convinced them we needed crypto payments. By month six, our payment code looked like spaghetti that had been through a blender:

// Before: Modifying existing code for each new payment method
class PaymentProcessor
{
    public function processPayment($amount, $gateway)
    {
        if ($gateway === 'paypal') {
            // PayPal logic
        } elseif ($gateway === 'stripe') {
            // Stripe logic
        } elseif ($gateway === 'bitcoin') {
            // Bitcoin logic
        }
        // This grows forever!
    }
}

The solution? Make it extensible:

// After: Open for extension, closed for modification
interface PaymentGateway
{
    public function charge(float $amount): PaymentResult;
    public function refund(string $transactionId): RefundResult;
}

class PayPalGateway implements PaymentGateway
{
    public function charge(float $amount): PaymentResult
    {
        // PayPal-specific implementation
        return new PaymentResult(/* ... */);
    }
    
    public function refund(string $transactionId): RefundResult
    {
        // PayPal refund logic
    }
}

class StripeGateway implements PaymentGateway
{
    public function charge(float $amount): PaymentResult
    {
        // Stripe-specific implementation
        return new PaymentResult(/* ... */);
    }
    
    public function refund(string $transactionId): RefundResult
    {
        // Stripe refund logic
    }
}

class PaymentProcessor
{
    private PaymentGateway $gateway;
    
    public function __construct(PaymentGateway $gateway)
    {
        $this->gateway = $gateway;
    }
    
    public function processPayment(float $amount): PaymentResult
    {
        return $this->gateway->charge($amount);
    }
}

Last month, we added Square payments. Time to implement: 2 hours. Bugs introduced in existing payment methods: zero. My stress level: also zero. That’s the power of being closed for modification - the working code stays working.

L - Liskov Substitution Principle

“Objects of a superclass should be replaceable with objects of its subclasses without breaking the application.”

This one’s a mind-bender. I thought I understood it until our production server threw a 500 error during a board meeting demo. Turns out, our “clever” user hierarchy was about as stable as a house of cards in a hurricane:

// Before: Violating LSP
class User
{
    public function canEditPost(Post $post): bool
    {
        return $post->author_id === $this->id;
    }
}

class AdminUser extends User
{
    public function canEditPost(Post $post): bool
    {
        return true; // Admins can edit everything
    }
}

class GuestUser extends User
{
    public function canEditPost(Post $post): bool
    {
        throw new Exception("Guests cannot edit posts!");
        // This breaks LSP - unexpected behavior!
    }
}

See the problem? Our post editing logic expected all users to either return true or false. But GuestUser threw an exception, crashing the whole page. The CEO was not amused. I spent that night refactoring while eating cold pizza:

// After: Respecting LSP
interface User
{
    public function canEditPost(Post $post): bool;
}

class RegisteredUser implements User
{
    public function canEditPost(Post $post): bool
    {
        return $post->author_id === $this->id;
    }
}

class AdminUser implements User
{
    public function canEditPost(Post $post): bool
    {
        return true;
    }
}

class GuestUser implements User
{
    public function canEditPost(Post $post): bool
    {
        return false; // Consistent behavior, no exceptions
    }
}

Now any code working with the User interface works predictably with all implementations.

I - Interface Segregation Principle

“Clients should not be forced to depend on interfaces they don’t use.”

Oh boy, this one hit me like a truck. We were building a CMS, and someone (okay, it was me) decided to create the mother of all interfaces. Every piece of content would implement IContent. Seemed logical at the time. Narrator: it was not logical.

// Before: Fat interface forcing unnecessary implementations
interface Content
{
    public function getTitle();
    public function getBody();
    public function getAuthor();
    public function getPublishedDate();
    public function getVideoUrl();      // Not all content has video
    public function getImageGallery();   // Not all content has galleries
    public function getPodcastFile();    // Not all content is audio
}

class Article implements Content
{
    // Forced to implement getVideoUrl() even though articles don't have videos
    public function getVideoUrl()
    {
        return null; // Meaningless implementation
    }
    
    // More meaningless implementations...
}

The solution is smaller, focused interfaces:

// After: Segregated interfaces
interface Content
{
    public function getTitle(): string;
    public function getAuthor(): Author;
    public function getPublishedDate(): DateTime;
}

interface TextContent
{
    public function getBody(): string;
}

interface VideoContent
{
    public function getVideoUrl(): string;
    public function getDuration(): int;
}

interface GalleryContent
{
    public function getImages(): array;
}

class Article implements Content, TextContent
{
    // Only implements what it actually uses
    public function getTitle(): string { /* ... */ }
    public function getAuthor(): Author { /* ... */ }
    public function getPublishedDate(): DateTime { /* ... */ }
    public function getBody(): string { /* ... */ }
}

class VideoPost implements Content, VideoContent
{
    // Implements content basics plus video-specific methods
    public function getVideoUrl(): string { /* ... */ }
    public function getDuration(): int { /* ... */ }
}

D - Dependency Inversion Principle

“High-level modules should not depend on low-level modules. Both should depend on abstractions.”

This principle was my “aha!” moment. For years, I was hard-wiring everything together like I was building a Frankenstein monster. Want to test something? Better spin up a real database. Need to switch email providers? Time to grep through 47 files.

// Before: High-level module depends on low-level details
class OrderService
{
    private $database;
    
    public function __construct()
    {
        $this->database = new MySQLDatabase(); // Hard-coded dependency!
    }
    
    public function createOrder($data)
    {
        // What if we want to switch to PostgreSQL?
        $this->database->insert('orders', $data);
        
        // Hard-coded email service
        $mailer = new SMTPMailer();
        $mailer->send($data['email'], 'Order confirmed');
    }
}

Here’s the same code following DIP:

// After: Depending on abstractions
interface OrderRepositoryInterface
{
    public function save(Order $order): void;
}

interface MailerInterface
{
    public function send(string $to, string $subject, string $body): void;
}

class OrderService
{
    private OrderRepositoryInterface $repository;
    private MailerInterface $mailer;
    
    public function __construct(
        OrderRepositoryInterface $repository,
        MailerInterface $mailer
    ) {
        $this->repository = $repository;
        $this->mailer = $mailer;
    }
    
    public function createOrder(array $data): Order
    {
        $order = new Order($data);
        $this->repository->save($order);
        
        $this->mailer->send(
            $order->getCustomerEmail(),
            'Order Confirmation',
            $this->buildConfirmationEmail($order)
        );
        
        return $order;
    }
}

// Concrete implementations
class MySQLOrderRepository implements OrderRepositoryInterface
{
    public function save(Order $order): void
    {
        // MySQL-specific implementation
    }
}

class SendGridMailer implements MailerInterface
{
    public function send(string $to, string $subject, string $body): void
    {
        // SendGrid API calls
    }
}

The magic happens in your dependency injection container:

// In your service container or bootstrap
$container->bind(OrderRepositoryInterface::class, MySQLOrderRepository::class);
$container->bind(MailerInterface::class, SendGridMailer::class);

The first time I switched databases with a one-line config change, I nearly cried. No more all-nighters rewriting queries. No more “works on my machine” because my machine has MySQL but production has Postgres. Just change the binding, run the tests, ship it.

Real-World Benefits I’ve Experienced

After adopting SOLID principles, I’ve noticed:

  1. Testing stopped being a nightmare: Remember staying late to set up test databases? Me neither, because now I just mock everything. My test suite runs in 12 seconds instead of 12 minutes.

  2. New devs actually understand the code: Last month, our junior dev fixed a bug in the payment system on her second day. She said “it was obvious where to look.” Music to my ears.

  3. 3 AM calls disappeared: When your email service has nothing to do with your payment processing, email outages don’t take down checkouts. Revolutionary concept, I know.

  4. Refactoring became fun: Yes, fun. When you can swap out entire subsystems without touching the rest of your app, it feels like playing with LEGO blocks.

  5. Deployments got boring: Boring is good. Boring means no surprises. Boring means I can deploy on Friday afternoon (though I still don’t, I’m not crazy).

Common Mistakes to Avoid

  1. Going SOLID crazy: I once created 47 interfaces for a blog engine. FORTY-SEVEN. My coworker asked if I was being paid per interface. Don’t be that guy.

  2. The “one method per class” disease: Single responsibility doesn’t mean single method. I’ve seen classes like UserEmailGetter with one getEmail() method. That’s not SOLID, that’s silly.

  3. Interface for everything: You don’t need IUserRepository if you only have one UserRepository. Wait until you actually need the flexibility.

  4. Principles over pragmatism: Sometimes a 200-line class that works is better than 20 classes that confuse everyone. Know when to break the rules.

My SOLID Checklist

Before I push that commit button, I run through my mental checklist:

  • Can I explain this class to a rubber duck in one sentence without saying “and also”?
  • If the CEO wants to add yet another payment method, will I cry?
  • Could I swap implementations without the rest of the code having a meltdown?
  • Are my interfaces lean, or did I go full enterprise architect?
  • Can I test this thing without Docker, three databases, and a prayer?

The Journey Continues

Here’s the truth: SOLID principles won’t make you a 10x developer. They won’t get you a raise (probably). They definitely won’t impress anyone at parties. But they will do something much more valuable - they’ll let you sleep at night.

Remember that senior dev who called my code garbage? We’re friends now. Last week, he code-reviewed my latest PR and said, “This is clean.” Three words. Best code review of my life.

My advice? Start with S. Just S. Make your next class do one thing well. Then try O on your next feature. Before you know it, you’ll be writing code that doesn’t make you want to quit programming and become a barista.

Trust me, future you will appreciate it. Especially at 3 AM when production isn’t on fire because your code is SOLID.

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Programming