0%

SOLID Principle Intro

This article is a detailed, mid-level–friendly introduction on SOLID principles with practical, real-world C# examples.

Mastering SOLID Principles in C# — A Practical Guide for Mid-Level Engineers

SOLID is a set of five core design principles that help developers write clean, maintainable, and extensible software. As systems grow, codebases naturally become harder to manage—SOLID helps you fight that entropy.

This article walks through each SOLID principle with realistic C# examples that apply to typical enterprise work: APIs, services, repositories, domain logic, validation, and more.

1. S — Single Responsibility Principle (SRP)

A class should have one reason to change.

SRP is often misunderstood as “a class does only one thing.”
It actually means: a class should have one responsibility (axis of change).

Bad Example — A service doing too much

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class OrderService
{
public void PlaceOrder(Order order)
{
// 1. Validate
if (order.Items.Count == 0) throw new Exception("Empty order");

// 2. Save to DB
using var conn = new SqlConnection("...");
conn.Open();
// ... Insert SQL here

// 3. Send confirmation email
using var client = new SmtpClient("server");
client.Send("noreply@shop.com", order.CustomerEmail, "Order placed", "Thank you!");
}
}

Why this is bad:

  • Business logic, persistence, and email sending all mixed
  • Modifying one behavior risks breaking others
  • Hard to test

✅ Good Example — Split responsibilities

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public interface IOrderRepository
{
void Save(Order order);
}

public interface IEmailService
{
void SendOrderConfirmation(Order order);
}

public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IEmailService _email;

public OrderService(IOrderRepository repository, IEmailService email)
{
_repository = repository;
_email = email;
}

public void PlaceOrder(Order order)
{
Validate(order);
_repository.Save(order);
_email.SendOrderConfirmation(order);
}

private void Validate(Order order)
{
if (!order.Items.Any())
throw new Exception("Empty order");
}
}

Now each class has one reason to change:

  • Validation → change rules
  • Repo → change storage
  • Email → change messaging provider

2. O — Open/Closed Principle (OCP)

Classes should be open for extension, but closed for modification.

This avoids editing existing classes when adding new behaviors—reducing risk and testing load.

Use polymorphism, strategies, or configuration instead of if/else growth.

❌ Bad Example — Growing conditional logic

1
2
3
4
5
6
7
8
public decimal CalculateDiscount(Customer customer)
{
if (customer.Type == "Regular") return 0m;
if (customer.Type == "Premium") return 0.1m;
if (customer.Type == "VIP") return 0.2m; // Added later

return 0m;
}

Every new customer type requires modifying this method.

✅ Good Example — Strategy pattern for extension

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public interface IDiscountStrategy
{
decimal GetDiscount();
}

public class RegularCustomerDiscount : IDiscountStrategy
{
public decimal GetDiscount() => 0m;
}

public class PremiumCustomerDiscount : IDiscountStrategy
{
public decimal GetDiscount() => 0.10m;
}

public class VipCustomerDiscount : IDiscountStrategy
{
public decimal GetDiscount() => 0.20m;
}

public class DiscountService
{
private readonly IDiscountStrategy _strategy;

public DiscountService(IDiscountStrategy strategy)
{
_strategy = strategy;
}

public decimal Calculate() => _strategy.GetDiscount();
}

Adding a new customer type no longer edits existing code—just add a new class.

3. L — Liskov Substitution Principle (LSP)

Subtypes must be replaceable for their base types without breaking behavior.

If a derived class breaks the expected behavior of its parent, that violates LSP.

❌ Bad Example — Overriding to break rules

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class FileStorage
{
public virtual void Save(string data)
{
File.WriteAllText("data.txt", data);
}
}

public class ReadOnlyFileStorage : FileStorage
{
public override void Save(string data)
{
throw new NotSupportedException("Read-only storage cannot save.");
}
}

This breaks expectations—FileStorage.Save() must always save.

✅ Good Example — Remove inheritance and use composition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface IWritableStorage
{
void Save(string data);
}

public interface IReadOnlyStorage
{
string Read();
}

public class FileStorage : IWritableStorage, IReadOnlyStorage
{
public void Save(string data) { /*...*/ }
public string Read() { /*...*/ }
}

public class ReadOnlyFileStorage : IReadOnlyStorage
{
public string Read() { /*...*/ }
}

Now no class violates the expectation of its interface.

4. I — Interface Segregation Principle (ISP)

Clients should not be forced to depend on methods they do not use.

❌ Bad Example — Fat interface

1
2
3
4
5
6
7
8
9
public interface IRepository<T>
{
T Get(int id);
IEnumerable<T> GetAll();
void Add(T entity);
void Delete(int id);
void Update(T entity);
void BulkInsert(IEnumerable<T> entities); // Some repos don’t need this
}

Now all implementations must support operations they may not need.

✅ Good Example — Split into smaller interfaces

1
2
3
4
5
6
7
8
9
10
11
12
public interface IReadRepository<T>
{
T Get(int id);
IEnumerable<T> GetAll();
}

public interface IWriteRepository<T>
{
void Add(T entity);
void Update(T entity);
void Delete(int id);
}

You can now have:

1
2
3
4
public class ReadOnlyCustomerRepository : IReadRepository<Customer>
{
// Only read operations implemented
}

5. D — Dependency Inversion Principle (DIP)

Depend on abstractions, not concretions.

High-level modules shouldn’t depend on low-level modules.

❌ Bad Example — Hard-coded dependency

1
2
3
4
5
6
7
8
9
public class NotificationService
{
private readonly SmtpClient _smtp = new SmtpClient("server");

public void Send(string email)
{
_smtp.Send("noreply@shop.com", email, "Hi", "Message");
}
}

You cannot unit test this or swap the provider.

✅ Good Example — Inject abstractions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public interface IEmailSender
{
void Send(string to, string subject, string message);
}

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

public class NotificationService
{
private readonly IEmailSender _email;

public NotificationService(IEmailSender email)
{
_email = email;
}

public void Notify(string to)
{
_email.Send(to, "Hello", "Message");
}
}

Now NotificationService depends on a stable abstraction, not a volatile concrete class.

Putting It All Together — A SOLID Mini Architecture

A typical order workflow using all SOLID principles:

1
2
3
Controller -> Service -> Repository -> DB
-> Validator
-> EmailProvider

Benefits:

  • Easy unit tests
  • Plug-in new providers (Email, DB, Logger, Payment Gateway)
  • Open to extension, closed for risky modification
  • Each class small and purposeful

Final Thoughts

SOLID is not about dogmatically splitting classes—it’s about managing change.
Mid-level engineers gain massive productivity by learning these principles deeply:

  • SRP → reduces coupling
  • OCP → reduces risk
  • LSP → ensures reliability
  • ISP → keeps interfaces clean
  • DIP → enables flexibility