Back to Blog
| 20 min read | Intermediate

SOLID Principles: A Developer's Guide

As a developer working with .NET, TypeScript, or any modern language, understanding SOLID principles is essential for writing maintainable, scalable code. While countless blog posts cover this topic, I wanted to create my own explanation because these principles have consistently improved my development practices.

As a developer working with .NET, TypeScript, or any modern language, understanding SOLID principles is essential for writing maintainable, scalable code. While countless blog posts cover this topic, I wanted to create my own explanation because these principles have consistently improved my development practices.

SOLID is an acronym representing five fundamental design principles introduced by Robert C. Martin that, when applied correctly, make your code more readable, extensible, and easier to maintain. These principles help you avoid common pitfalls that lead to rigid, fragile codebases.

It's important to note that SOLID principles aren't hard and fast rules but rather guidelines to help you make better design decisions. Applying them with common sense and considering your specific context will yield better results than rigid adherence.

Let's go over what these principles are and how we can implement them in practical situations to create more resilient software.


What Is SOLID?

SOLID is an acronym representing five core principles of object-oriented programming (OOP). These principles were introduced by Robert C. Martin (Uncle Bob). They're designed to help developers create software that's easy to understand, flexible, and change-friendly.

Here's the breakdown:

  1. Single Responsibility Principle (SRP)
  2. Open/Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion Principle (DIP)

A little trivia: While Robert C. Martin developed these principles, the acronym SOLID was coined by Michael Feathers.


Why Should You Care About SOLID?

  • Maintainability: Code that follows SOLID tends to be easier to maintain. When requirements change, you'll spend less time untangling complex dependencies.
  • Testability: Smaller, more focused classes are simpler to test in isolation.
  • Readability: Clean, decoupled code is far easier to understand—for both teammates and future you.
  • Flexibility: You can add or modify features without causing a domino effect of breakages everywhere else.

The result? A codebase you can actually enjoy working on rather than dreading the refactor monster lurking in the shadows.


The Five Principles in Detail

1. Single Responsibility Principle (SRP)

Definition: A class should have one and only one reason to change.

Meaning: Each class focuses on a single task. If you have a class that manages user data, don't also make it send emails. Keep those concerns separated.

Problem Example: Imagine a UserManager class that handles creating users, sending welcome emails, and logging activities. If email requirements change, you risk breaking user creation logic. If logging formats change, you must modify the same class again, increasing the risk of introducing bugs.

In C#:

// Bad approach - violating SRP
public class UserManager
{
    public void CreateUser(User user)
    {
        // Insert user data into database
        // Send welcome email
        // Log user creation
    }
}

// Good approach - following SRP
public class UserRepository
{
    public void CreateUser(User user)
    {
        // Insert user data into database
    }
}

public class EmailService
{
    public void SendWelcomeEmail(string to)
    {
        // Logic to send email
    }
}

public class ActivityLogger
{
    public void LogUserCreation(User user)
    {
        // Log user creation
    }
}

In TypeScript:

// Bad approach - violating SRP
class UserManager {
  createUser(user: User): void {
    // Insert user data into database
    // Send welcome email
    // Log user creation
  }
}

// Good approach - following SRP
class UserRepository {
  createUser(user: User): void {
    // Insert user data into database
  }
}

class EmailService {
  sendWelcomeEmail(to: string): void {
    // Logic to send email
  }
}

class ActivityLogger {
  logUserCreation(user: User): void {
    // Log user creation
  }
}

2. Open/Closed Principle (OCP)

Definition: Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

Meaning: You should be able to add new functionalities without needing to change existing, stable code. Typically, this is achieved through inheritance, interfaces, or composition.

Problem Example: Imagine a DiscountManager with a method containing a switch statement for different discount types. Every time you add a new discount type, you must modify the existing method, risking bugs in already working code.

In C#:

// Bad approach - violating OCP
public class DiscountManager
{
    public decimal CalculateDiscount(string discountType, decimal amount)
    {
        switch (discountType)
        {
            case "Percentage":
                return amount * 0.1m;
            case "Fixed":
                return amount - 10m;
            // Every new discount type requires modifying this method
            default:
                return amount;
        }
    }
}

// Good approach - following OCP
public abstract class DiscountCalculator
{
    public abstract decimal CalculateDiscount(decimal amount);
}

public class PercentageDiscountCalculator : DiscountCalculator
{
    public override decimal CalculateDiscount(decimal amount)
    {
        return amount * 0.1m;
    }
}

public class FixedDiscountCalculator : DiscountCalculator
{
    public override decimal CalculateDiscount(decimal amount)
    {
        return amount - 10m;
    }
}

// Add a new discount type without modifying existing code
public class SeasonalDiscountCalculator : DiscountCalculator
{
    public override decimal CalculateDiscount(decimal amount)
    {
        return amount * 0.25m;
    }
}

In TypeScript:

// Bad approach - violating OCP
class DiscountManager {
  calculateDiscount(discountType: string, amount: number): number {
    switch (discountType) {
      case "Percentage":
        return amount * 0.1;
      case "Fixed":
        return amount - 10;
      // Every new discount type requires modifying this method
      default:
        return amount;
    }
  }
}

// Good approach - following OCP
interface DiscountCalculator {
  calculateDiscount(amount: number): number;
}

class PercentageDiscountCalculator implements DiscountCalculator {
  calculateDiscount(amount: number): number {
    return amount * 0.1;
  }
}

class FixedDiscountCalculator implements DiscountCalculator {
  calculateDiscount(amount: number): number {
    return amount - 10;
  }
}

// Add a new discount type without modifying existing code
class SeasonalDiscountCalculator implements DiscountCalculator {
  calculateDiscount(amount: number): number {
    return amount * 0.25;
  }
}

3. Liskov Substitution Principle (LSP)

Definition: Objects should be replaceable by instances of their subtypes without altering the correctness of the program.

Meaning: If you have a base class Animal and a derived class Bird, anywhere you use Animal, you should be able to plug in a Bird without causing errors or unexpected behavior.

Problem Example: Consider a Rectangle class with height and width properties, and a Square class inheriting from it. A square requires that height equals width, but clients using Rectangle expect to set height and width independently, causing unexpected behavior.

Side Note: Who is Liskov?

Barbara Liskov is a pioneering computer scientist who won the Turing Award (often called the "Nobel Prize of Computer Science") in 2008. She formulated what became known as the Liskov Substitution Principle in 1987 in her keynote address titled "Data Abstraction and Hierarchy." The principle was later named after her by Robert C. Martin when he included it in his SOLID principles framework. Liskov's work has been fundamental in the development of programming languages, operating systems, and distributed computing. Her contributions to abstract data types and modular programming set the foundation for many object-oriented programming concepts we use today.

In C#:

// Bad approach - violating LSP
public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }

    public int CalculateArea()
    {
        return Width * Height;
    }
}

public class Square : Rectangle
{
    private int _size;

    public override int Width
    {
        get { return _size; }
        set { _size = value; }
    }

    public override int Height
    {
        get { return _size; }
        set { _size = value; }
    }
}

// Client code:
public void ResizeRectangle(Rectangle rectangle)
{
    rectangle.Width = 5;
    rectangle.Height = 10;
    // For a Square, Width would be 10 at this point, breaking expectations
    Debug.Assert(rectangle.CalculateArea() == 50); // Fails for Square
}

// Good approach - following LSP
public interface IShape
{
    int CalculateArea();
}

public class Rectangle : IShape
{
    public int Width { get; set; }
    public int Height { get; set; }

    public int CalculateArea()
    {
        return Width * Height;
    }
}

public class Square : IShape
{
    public int Size { get; set; }

    public int CalculateArea()
    {
        return Size * Size;
    }
}

In TypeScript:

// Bad approach - violating LSP
class Rectangle {
  width: number;
  height: number;

  calculateArea(): number {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  set width(value: number) {
    super.width = value;
    super.height = value;
  }

  set height(value: number) {
    super.width = value;
    super.height = value;
  }
}

// Client code:
function resizeRectangle(rectangle: Rectangle): void {
  rectangle.width = 5;
  rectangle.height = 10;
  // For a Square, width would be 10 at this point, breaking expectations
  console.assert(rectangle.calculateArea() === 50); // Fails for Square
}

// Good approach - following LSP
interface Shape {
  calculateArea(): number;
}

class Rectangle implements Shape {
  width: number;
  height: number;

  calculateArea(): number {
    return this.width * this.height;
  }
}

class Square implements Shape {
  size: number;

  calculateArea(): number {
    return this.size * this.size;
  }
}

4. Interface Segregation Principle (ISP)

Definition: No client should be forced to depend on methods it does not use.

Meaning: Keep your interfaces small and focused. Avoid "god interfaces" that cram in every possible method.

Problem Example: Imagine a Repository interface with methods for both reading and writing data. A read-only service would be forced to implement write methods it doesn't need, potentially causing issues if those methods are called.

In C#:

// Bad approach - violating ISP
public interface IRepository
{
    User GetUser(int id);
    List<User> GetAllUsers();
    void CreateUser(User user);
    void UpdateUser(User user);
    void DeleteUser(int id);
}

// Read-only service forced to implement unused methods
public class UserReportService : IRepository
{
    public User GetUser(int id) { /* Implementation */ }
    public List<User> GetAllUsers() { /* Implementation */ }
    
    // Methods not needed but forced to implement
    public void CreateUser(User user) { throw new NotImplementedException(); }
    public void UpdateUser(User user) { throw new NotImplementedException(); }
    public void DeleteUser(int id) { throw new NotImplementedException(); }
}

// Good approach - following ISP
public interface IReadableRepository
{
    User GetUser(int id);
    List<User> GetAllUsers();
}

public interface IWritableRepository
{
    void CreateUser(User user);
    void UpdateUser(User user);
    void DeleteUser(int id);
}

// Service only implements what it needs
public class UserReportService : IReadableRepository
{
    public User GetUser(int id) { /* Implementation */ }
    public List<User> GetAllUsers() { /* Implementation */ }
}

In TypeScript:

// Bad approach - violating ISP
interface Repository {
  getUser(id: number): User;
  getAllUsers(): User[];
  createUser(user: User): void;
  updateUser(user: User): void;
  deleteUser(id: number): void;
}

// Read-only service forced to implement unused methods
class UserReportService implements Repository {
  getUser(id: number): User { /* Implementation */ }
  getAllUsers(): User[] { /* Implementation */ }
  
  // Methods not needed but forced to implement
  createUser(user: User): void { throw new Error("Not implemented"); }
  updateUser(user: User): void { throw new Error("Not implemented"); }
  deleteUser(id: number): void { throw new Error("Not implemented"); }
}

// Good approach - following ISP
interface ReadableRepository {
  getUser(id: number): User;
  getAllUsers(): User[];
}

interface WritableRepository {
  createUser(user: User): void;
  updateUser(user: User): void;
  deleteUser(id: number): void;
}

// Service only implements what it needs
class UserReportService implements ReadableRepository {
  getUser(id: number): User { /* Implementation */ }
  getAllUsers(): User[] { /* Implementation */ }
}

5. Dependency Inversion Principle (DIP) - The Tricky One

In Plain Language: "Wire your code to interfaces, not specific implementations."

Definition: Depend upon abstractions, not concrete implementations.

This principle is often tough to grasp at first because it flips our natural way of thinking about code relationships. Here's what it means:

Traditionally, we might think that high-level components (like a checkout service) should directly use lower-level components (like a specific payment processor). But this creates rigid dependencies that are hard to change later.

Instead, DIP suggests both high and low-level components should depend on abstractions (interfaces). This way, neither component cares about the specific details of the other - they just care about the contract between them.

Real-World Car Analogy: Think of your car's steering wheel interface. When you drive:

  • You (high-level) don't need to know how the steering mechanism (low-level) actually turns the wheels.
  • Car manufacturers can change from hydraulic to electric power steering without you needing to learn a new way to drive
  • Mechanics can test your steering system with a diagnostic tool instead of taking the car on the road
  • Different vehicles (sedans, trucks, sports cars) can implement the same steering interface differently, but you know how to drive them all

This standardized interface between you and the steering mechanics gives both sides freedom to change independently of each other. That's exactly what DIP does for your code.

Why It's Initially Confusing:It requires thinking about code relationships differently:

  • You need to identify what parts of your system could change independently
  • You must create abstractions (interfaces) before implementing concrete classes
  • It sometimes feels like extra work until you need to swap implementations

Benefits That Make It Worth Learning:

Makes testing much easier (swap real dependencies with test doubles) Allows you to change implementations without touching client code Enables "plug and play" architecture where components can be swapped Helps keep your system loosely coupled and more maintainable over time

When you first apply DIP, it might feel like overengineering. But as your application grows or requirements change, you'll appreciate the flexibility it provides.

Problem Example: A service directly instantiating and using a concrete email provider class would be tightly coupled to that implementation, making it difficult to switch providers or mock for testing.

In C#:

// Bad approach - violating DIP
public class NotificationService
{
    private readonly SmtpEmailSender _emailSender;

    public NotificationService()
    {
        _emailSender = new SmtpEmailSender();
    }

    public void NotifyUser(string email, string message)
    {
        _emailSender.SendEmail(email, "Notification", message);
    }
}

// Good approach - following DIP
public interface IEmailSender
{
    void SendEmail(string to, string subject, string body);
}

public class SmtpEmailSender : IEmailSender
{
    public void SendEmail(string to, string subject, string body)
    {
        // SMTP implementation
    }
}

public class SendGridEmailSender : IEmailSender
{
    public void SendEmail(string to, string subject, string body)
    {
        // SendGrid implementation
    }
}

public class NotificationService
{
    private readonly IEmailSender _emailSender;

    public NotificationService(IEmailSender emailSender)
    {
        _emailSender = emailSender;
    }

    public void NotifyUser(string email, string message)
    {
        _emailSender.SendEmail(email, "Notification", message);
    }
}

In TypeScript:

// Bad approach - violating DIP
class NotificationService {
  private emailSender: SmtpEmailSender;

  constructor() {
    this.emailSender = new SmtpEmailSender();
  }

  notifyUser(email: string, message: string): void {
    this.emailSender.sendEmail(email, "Notification", message);
  }
}

// Good approach - following DIP
interface EmailSender {
  sendEmail(to: string, subject: string, body: string): void;
}

class SmtpEmailSender implements EmailSender {
  sendEmail(to: string, subject: string, body: string): void {
    // SMTP implementation
  }
}

class SendGridEmailSender implements EmailSender {
  sendEmail(to: string, subject: string, body: string): void {
    // SendGrid implementation
  }
}

class NotificationService {
  private emailSender: EmailSender;

  constructor(emailSender: EmailSender) {
    this.emailSender = emailSender;
  }

  notifyUser(email: string, message: string): void {
    this.emailSender.sendEmail(email, "Notification", message);
  }
}

Putting SOLID into Practice

Let's imagine a .NET application that processes orders. By applying the SOLID principles, you can structure your code in a way that remains clean, easy to test, and adaptable to change. Here's how each principle might look in practice:

1. Single Responsibility

What It Looks Like in Code:

// Each class has one responsibility
public class OrderProcessor
{
    private readonly IPaymentService _paymentService;
    private readonly INotificationService _notificationService;
    private readonly IDiscountCalculator _discountCalculator;

    public OrderProcessor(
        IPaymentService paymentService,
        INotificationService notificationService,
        IDiscountCalculator discountCalculator)
    {
        _paymentService = paymentService;
        _notificationService = notificationService;
        _discountCalculator = discountCalculator;
    }

    public OrderResult ProcessOrder(Order order)
    {
        // Apply discount
        var discountedAmount = _discountCalculator.ApplyDiscount(order);
        
        // Process payment
        var paymentResult = _paymentService.ProcessPayment(order.CustomerId, discountedAmount);
        
        // If payment successful, send notification
        if (paymentResult.Success)
        {
            _notificationService.SendOrderConfirmation(order);
        }
        
        return new OrderResult { Success = paymentResult.Success };
    }
}

In TypeScript:

// Each class has one responsibility
class OrderProcessor {
  constructor(
    private paymentService: PaymentService,
    private notificationService: NotificationService,
    private discountCalculator: DiscountCalculator
  ) {}

  processOrder(order: Order): OrderResult {
    // Apply discount
    const discountedAmount = this.discountCalculator.applyDiscount(order);
    
    // Process payment
    const paymentResult = this.paymentService.processPayment(order.customerId, discountedAmount);
    
    // If payment successful, send notification
    if (paymentResult.success) {
      this.notificationService.sendOrderConfirmation(order);
    }
    
    return { success: paymentResult.success };
  }
}

Why It Helps: Each class has only one job. When changes occur—like updating the email template—you modify only the NotificationService, leaving payment and order logic untouched.

2. Open/Closed

What It Looks Like in Code:

// Abstract discount strategy
public interface IDiscountStrategy
{
    decimal ApplyDiscount(Order order, decimal amount);
}

// Concrete implementations
public class PercentageDiscountStrategy : IDiscountStrategy
{
    private readonly decimal _percentage;

    public PercentageDiscountStrategy(decimal percentage)
    {
        _percentage = percentage;
    }

    public decimal ApplyDiscount(Order order, decimal amount)
    {
        return amount * (1 - _percentage);
    }
}

public class BulkDiscountStrategy : IDiscountStrategy
{
    public decimal ApplyDiscount(Order order, decimal amount)
    {
        return order.Items.Count >= 10 ? amount * 0.85m : amount;
    }
}

// Adding a new discount strategy without modifying existing code
public class LoyalCustomerDiscountStrategy : IDiscountStrategy
{
    private readonly ICustomerRepository _customerRepository;

    public LoyalCustomerDiscountStrategy(ICustomerRepository customerRepository)
    {
        _customerRepository = customerRepository;
    }

    public decimal ApplyDiscount(Order order, decimal amount)
    {
        var customer = _customerRepository.GetCustomer(order.CustomerId);
        return customer.YearsAsMember > 5 ? amount * 0.9m : amount;
    }
}

In TypeScript:

// Abstract discount strategy
interface DiscountStrategy {
  applyDiscount(order: Order, amount: number): number;
}

// Concrete implementations
class PercentageDiscountStrategy implements DiscountStrategy {
  constructor(private percentage: number) {}

  applyDiscount(order: Order, amount: number): number {
    return amount * (1 - this.percentage);
  }
}

class BulkDiscountStrategy implements DiscountStrategy {
  applyDiscount(order: Order, amount: number): number {
    return order.items.length >= 10 ? amount * 0.85 : amount;
  }
}

// Adding a new discount strategy without modifying existing code
class LoyalCustomerDiscountStrategy implements DiscountStrategy {
  constructor(private customerRepository: CustomerRepository) {}

  applyDiscount(order: Order, amount: number): number {
    const customer = this.customerRepository.getCustomer(order.customerId);
    return customer.yearsAsMember > 5 ? amount * 0.9 : amount;
  }
}

Why It Helps: You can add new discount behaviors without altering reliable, tested code. Your core system is "closed" for modification but "open" for extension through new strategy classes.

3. Liskov Substitution

What It Looks Like in Code:

// Base payment handler
public abstract class PaymentHandler
{
    public abstract PaymentResult ProcessPayment(string customerId, decimal amount);
}

// Concrete implementations that can be substituted
public class CreditCardPaymentHandler : PaymentHandler
{
    public override PaymentResult ProcessPayment(string customerId, decimal amount)
    {
        // Credit card processing logic
        return new PaymentResult { Success = true };
    }
}

public class PayPalPaymentHandler : PaymentHandler
{
    public override PaymentResult ProcessPayment(string customerId, decimal amount)
    {
        // PayPal processing logic
        return new PaymentResult { Success = true };
    }
}

// Client code works with any PaymentHandler
public class CheckoutService
{
    private readonly PaymentHandler _paymentHandler;

    public CheckoutService(PaymentHandler paymentHandler)
    {
        _paymentHandler = paymentHandler;
    }

    public OrderResult Checkout(Order order)
    {
        // Any PaymentHandler can be used here
        var paymentResult = _paymentHandler.ProcessPayment(order.CustomerId, order.TotalAmount);
        return new OrderResult { Success = paymentResult.Success };
    }
}

In TypeScript:

// Base payment handler
abstract class PaymentHandler {
  abstract processPayment(customerId: string, amount: number): PaymentResult;
}

// Concrete implementations that can be substituted
class CreditCardPaymentHandler extends PaymentHandler {
  processPayment(customerId: string, amount: number): PaymentResult {
    // Credit card processing logic
    return { success: true };
  }
}

class PayPalPaymentHandler extends PaymentHandler {
  processPayment(customerId: string, amount: number): PaymentResult {
    // PayPal processing logic
    return { success: true };
  }
}

// Client code works with any PaymentHandler
class CheckoutService {
  constructor(private paymentHandler: PaymentHandler) {}

  checkout(order: Order): OrderResult {
    // Any PaymentHandler can be used here
    const paymentResult = this.paymentHandler.processPayment(order.customerId, order.totalAmount);
    return { success: paymentResult.success };
  }
}

Why It Helps: This ensures the system remains stable even if a new payment handler is introduced. If your code expects a PaymentHandler, it can accept any of its subclasses without special-casing or throwing unexpected errors.

4. Interface Segregation

What It Looks Like in Code:

// Segregated interfaces
public interface IOrderReader
{
    Order GetOrder(int orderId);
    List<Order> GetCustomerOrders(string customerId);
}

public interface IOrderWriter
{
    void CreateOrder(Order order);
    void UpdateOrderStatus(int orderId, OrderStatus status);
}

// A service that only needs to read orders
public class OrderReportGenerator
{
    private readonly IOrderReader _orderReader;

    public OrderReportGenerator(IOrderReader orderReader)
    {
        _orderReader = orderReader;
    }

    public OrderReport GenerateReport(string customerId)
    {
        var orders = _orderReader.GetCustomerOrders(customerId);
        // Generate report logic
        return new OrderReport();
    }
}

// Repository implementing both interfaces
public class OrderRepository : IOrderReader, IOrderWriter
{
    public Order GetOrder(int orderId) { /* Implementation */ }
    public List<Order> GetCustomerOrders(string customerId) { /* Implementation */ }
    public void CreateOrder(Order order) { /* Implementation */ }
    public void UpdateOrderStatus(int orderId, OrderStatus status) { /* Implementation */ }
}

In TypeScript:

// Segregated interfaces
interface OrderReader {
  getOrder(orderId: number): Order;
  getCustomerOrders(customerId: string): Order[];
}

interface OrderWriter {
  createOrder(order: Order): void;
  updateOrderStatus(orderId: number, status: OrderStatus): void;
}

// A service that only needs to read orders
class OrderReportGenerator {
  constructor(private orderReader: OrderReader) {}

  generateReport(customerId: string): OrderReport {
    const orders = this.orderReader.getCustomerOrders(customerId);
    // Generate report logic
    return new OrderReport();
  }
}

// Repository implementing both interfaces
class OrderRepository implements OrderReader, OrderWriter {
  getOrder(orderId: number): Order { /* Implementation */ }
  getCustomerOrders(customerId: string): Order[] { /* Implementation */ }
  createOrder(order: Order): void { /* Implementation */ }
  updateOrderStatus(orderId: number, status: OrderStatus): void { /* Implementation */ }
}

Why It Helps: Classes that only need read access to orders can implement (and depend upon) IOrderReader without being forced to include write or delete methods they don't use. This keeps your code lean and focused.

5. Dependency Inversion

What It Looks Like in Code:

// Payment processing abstraction
public interface IPaymentProcessor
{
    PaymentResult ProcessPayment(string customerId, decimal amount);
}

// Different implementations
public class StripePaymentProcessor : IPaymentProcessor
{
    public PaymentResult ProcessPayment(string customerId, decimal amount)
    {
        // Stripe-specific implementation
        return new PaymentResult { Success = true };
    }
}

public class PayPalPaymentProcessor : IPaymentProcessor
{
    public PaymentResult ProcessPayment(string customerId, decimal amount)
    {
        // PayPal-specific implementation
        return new PaymentResult { Success = true };
    }
}

// High-level module depends on abstraction
public class OrderService
{
    private readonly IPaymentProcessor _paymentProcessor;

    public OrderService(IPaymentProcessor paymentProcessor)
    {
        _paymentProcessor = paymentProcessor;
    }

    public OrderResult PlaceOrder(Order order)
    {
        // Use payment processor without knowing concrete implementation
        var paymentResult = _paymentProcessor.ProcessPayment(order.CustomerId, order.TotalAmount);
        return new OrderResult { Success = paymentResult.Success };
    }
}

// Dependency injection setup
public void ConfigureServices(IServiceCollection services)
{
    // Register services
    services.AddScoped<IPaymentProcessor, StripePaymentProcessor>();
    services.AddScoped<OrderService>();
}

In TypeScript:

// Payment processing abstraction
interface PaymentProcessor {
  processPayment(customerId: string, amount: number): PaymentResult;
}

// Different implementations
class StripePaymentProcessor implements PaymentProcessor {
  processPayment(customerId: string, amount: number): PaymentResult {
    // Stripe-specific implementation
    return { success: true };
  }
}

class PayPalPaymentProcessor implements PaymentProcessor {
  processPayment(customerId: string, amount: number): PaymentResult {
    // PayPal-specific implementation
    return { success: true };
  }
}

// High-level module depends on abstraction
class OrderService {
  constructor(private paymentProcessor: PaymentProcessor) {}

  placeOrder(order: Order): OrderResult {
    // Use payment processor without knowing concrete implementation
    const paymentResult = this.paymentProcessor.processPayment(order.customerId, order.totalAmount);
    return { success: paymentResult.success };
  }
}

// Dependency injection setup (using a hypothetical DI container)
const container = new DIContainer();
container.register(PaymentProcessor, StripePaymentProcessor);
container.register(OrderService);
const orderService = container.resolve(OrderService);

Why It Helps: You can easily swap in different implementations (e.g., Stripe, PayPal, or a mock for testing) without having to alter the OrderService. This makes testing simpler too—just inject a mock or fake implementation in your test project.

Wrapping It Up

SOLID principles aren't just theoretical concepts—they're practical guidelines that can dramatically improve your codebase. When applied appropriately, they lead to:

  • Code that's easier to understand and maintain
  • More testable components that can be isolated
  • Systems that adapt gracefully to changing requirements
  • Fewer bugs and regressions when adding new features

Remember that SOLID principles are guidelines, not rigid rules. Use your judgment to apply them in ways that make sense for your specific context. Sometimes pragmatism should win over strict adherence—the goal is better code, not perfect theoretical compliance.

Key Takeaways

  • SOLID helps you write cleaner, more flexible object-oriented code.
  • Both .NET and TypeScript offer features (interfaces, abstract classes, dependency injection) that make applying SOLID simpler.
  • Well-structured code pays dividends when projects grow or when you revisit them months later.
  • These principles can be applied incrementally—you don't have to refactor everything at once.

Have a good one.

References

Tags

#Software Engineering #Best Practices #Intermediate
Casey Spaulding
Casey Spaulding

.NET Full Stack Engineer. 21 years in the Navy, then software.

Ask Casey AI

Ask Me Anything

Hi! I'm Casey's AI assistant. Ask me anything about his work, skills, or projects!

10 messages remaining