SOLID principles are often taught as fundamental rules, but at the architect level, they’re guidelines that require judgment, trade-offs, and contextual application.
Beyond the Textbook: SOLID in Practice
Rigid adherence to any principle can be as harmful as ignoring it entirely.
The real skill lies in understanding:
- When each principle adds value
- What you’re trading off by applying it
- How principles interact and sometimes conflict
- Why context matters
Single Responsibility Principle (SRP)
“A class should have one, and only one, reason to change.”
SRP isn’t about doing one thing—it’s about having one reason to change. At the architectural level, this translates to:
- Modules should have one axis of change
- Services should have one business capability
- Teams should own one bounded context
When SRP Adds Value
✅ Good Application:
// Each class has a single reason to change
public class OrderProcessor
{
private readonly IPaymentGateway _paymentGateway;
private readonly IInventoryService _inventoryService;
private readonly INotificationService _notificationService;
public async Task<OrderResult> ProcessOrder(Order order)
{
// Orchestrates but doesn't implement business logic
await _inventoryService.ReserveItems(order.Items);
var paymentResult = await _paymentGateway.Charge(order.Payment);
await _notificationService.SendConfirmation(order.CustomerId);
return new OrderResult { Success = true, OrderId = order.Id };
}
}
// Payment logic changes? Modify PaymentGateway
public class PaymentGateway : IPaymentGateway
{
public async Task<PaymentResult> Charge(PaymentInfo payment)
{
// Payment-specific logic
}
}
// Inventory logic changes? Modify InventoryService
public class InventoryService : IInventoryService
{
public async Task ReserveItems(List<OrderItem> items)
{
// Inventory-specific logic
}
}
When SRP Creates Overhead
⚠️ Over-Application:
// Too granular - 10 classes for simple user registration
public class UserEmailValidator { }
public class UserPasswordHasher { }
public class UserDatabaseWriter { }
public class UserEmailSender { }
public class UserSessionCreator { }
public class UserAuditLogger { }
public class UserCacheUpdater { }
public class UserMetricsRecorder { }
public class UserWelcomeMessageSender { }
public class UserRegistrationOrchestrator
{
// Coordinates 9 other classes for a simple operation
}
The Problem:
- Complexity explosion: 10 classes instead of 2
- Debugging nightmare: Tracing through multiple layers
- Performance overhead: Unnecessary abstraction layers
- Maintenance burden: More files, more dependencies
✅ Pragmatic Balance:
// Two cohesive classes instead of 10 fragments
public class UserRegistrationService
{
private readonly IUserRepository _userRepo;
private readonly IEmailService _emailService;
public async Task<User> RegisterUser(UserRegistrationRequest request)
{
ValidateEmail(request.Email);
var passwordHash = HashPassword(request.Password);
var user = await _userRepo.Create(new User
{
Email = request.Email,
PasswordHash = passwordHash
});
await _emailService.SendWelcomeEmail(user);
LogRegistration(user);
return user;
}
private void ValidateEmail(string email) { /* validation */ }
private string HashPassword(string password) { /* hashing */ }
private void LogRegistration(User user) { /* logging */ }
}
Architectural SRP: Service Boundaries
At the service level, SRP manifests as bounded contexts:
❌ God Service (Multiple Responsibilities)
┌─────────────────────────────────────┐
│ Monolithic OrderService │
│ - Order Processing │
│ - Payment Processing │
│ - Inventory Management │
│ - Shipping Logistics │
│ - Customer Management │
│ - Pricing & Discounts │
└─────────────────────────────────────┘
Changes to ANY domain require modifying this service
✅ Bounded Contexts (Single Responsibility per Service)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Orders │ │ Payments │ │ Inventory │
│ Service │ │ Service │ │ Service │
└──────────────┘ └──────────────┘ └──────────────┘
Each service has ONE reason to change
Open/Closed Principle (OCP)
“Software entities should be open for extension but closed for modification.”
The Real Challenge
OCP sounds simple but has a hidden cost: premature abstraction. The architect’s dilemma:
- Apply OCP too early → Over-engineered, complex system
- Apply OCP too late → Risky changes, tight coupling
Strategic Extension Points
✅ Worth the Abstraction:
// Payment processing - high variability, frequent additions
public interface IPaymentProvider
{
Task<PaymentResult> ProcessPayment(PaymentRequest request);
Task<RefundResult> ProcessRefund(string transactionId);
}
// Easy to add new providers without modifying existing code
public class StripeProvider : IPaymentProvider { }
public class PayPalProvider : IPaymentProvider { }
public class SquareProvider : IPaymentProvider { }
public class CryptoProvider : IPaymentProvider { }
public class PaymentService
{
private readonly Dictionary<PaymentMethod, IPaymentProvider> _providers;
// Closed for modification, open for extension
public async Task<PaymentResult> Process(PaymentRequest request)
{
var provider = _providers[request.Method];
return await provider.ProcessPayment(request);
}
}
Why This Works:
- Payment providers added frequently
- Each provider has unique integration requirements
- Failure in one provider shouldn’t affect others
- Business value justifies abstraction overhead
❌ Premature Abstraction:
// Over-engineered for a simple, stable operation
public interface IUserNameFormatter
{
string Format(User user);
}
public class FirstLastFormatter : IUserNameFormatter { }
public class LastFirstFormatter : IUserNameFormatter { }
public class InitialsFormatter : IUserNameFormatter { }
public class UserDisplayService
{
private readonly IUserNameFormatter _formatter;
// Injecting formatter for something that rarely changes
}
The Problem:
- Name formatting rarely changes
- No business requirement for multiple formats
- Abstraction adds complexity without value
- Simple
$"{user.FirstName} {user.LastName}"would suffice
Plugin Architecture: OCP at Scale
// Extension point for business rules
public interface IOrderValidator
{
Task<ValidationResult> Validate(Order order);
int Priority { get; }
}
// Core system never changes
public class OrderValidationPipeline
{
private readonly IEnumerable<IOrderValidator> _validators;
public OrderValidationPipeline(IEnumerable<IOrderValidator> validators)
{
// Automatically discovers all validators via DI
_validators = validators.OrderBy(v => v.Priority);
}
public async Task<ValidationResult> ValidateOrder(Order order)
{
foreach (var validator in _validators)
{
var result = await validator.Validate(order);
if (!result.IsValid) return result;
}
return ValidationResult.Success;
}
}
// New validation rules added without modifying core
public class FraudDetectionValidator : IOrderValidator
{
public int Priority => 1;
public async Task<ValidationResult> Validate(Order order) { }
}
public class InventoryValidator : IOrderValidator
{
public int Priority => 2;
public async Task<ValidationResult> Validate(Order order) { }
}
public class CreditLimitValidator : IOrderValidator
{
public int Priority => 3;
public async Task<ValidationResult> Validate(Order order) { }
}
When to Apply OCP: The Decision Framework
High Variability + High Business Value = Apply OCP
├── Payment processors: ✅ Abstract
├── Notification channels: ✅ Abstract
├── Authentication providers: ✅ Abstract
└── Reporting formats: ✅ Abstract
Low Variability + Low Business Value = Skip OCP
├── Date formatting: ❌ Keep simple
├── String utilities: ❌ Keep simple
├── Configuration reading: ❌ Keep simple
└── Logging statements: ❌ Keep simple
Liskov Substitution Principle (LSP)
“Subtypes must be substitutable for their base types without altering program correctness.”
Why LSP Violations Are Dangerous
LSP violations create silent bugs that appear later in production when an unexpected subtype is used.
Classic Violation: Rectangle/Square
// Textbook violation
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area() => Width * Height;
}
public class Square : Rectangle
{
public override int Width
{
get => base.Width;
set => base.Width = base.Height = value; // Violates LSP
}
public override int Height
{
get => base.Height;
set => base.Width = base.Height = value; // Violates LSP
}
}
// This code works with Rectangle but breaks with Square
public void ResizeShape(Rectangle rect)
{
rect.Width = 5;
rect.Height = 10;
Assert.Equal(50, rect.Area()); // Fails if rect is Square!
}
The Problem:
- Client expects independent width/height
- Square violates this expectation
- Tests pass with Rectangle, fail with Square
✅ LSP-Compliant Design:
// Use composition instead of inheritance
public interface IShape
{
int Area();
IShape Resize(int width, int height);
}
public class Rectangle : IShape
{
public int Width { get; private set; }
public int Height { get; private set; }
public Rectangle(int width, int height)
{
Width = width;
Height = height;
}
public int Area() => Width * Height;
public IShape Resize(int width, int height)
{
return new Rectangle(width, height);
}
}
public class Square : IShape
{
public int SideLength { get; private set; }
public Square(int sideLength)
{
SideLength = sideLength;
}
public int Area() => SideLength * SideLength;
public IShape Resize(int width, int height)
{
// Square has its own valid behavior
if (width != height)
throw new InvalidOperationException("Square requires equal dimensions");
return new Square(width);
}
}
Real-World LSP: Repository Pattern
❌ LSP Violation:
public interface IRepository<T>
{
Task<T> GetById(int id);
Task<IEnumerable<T>> GetAll();
Task Add(T entity);
Task Update(T entity);
Task Delete(int id);
}
// Violates LSP - throws for read-only repositories
public class ReadOnlyProductRepository : IRepository<Product>
{
public Task<Product> GetById(int id) { /* works */ }
public Task<IEnumerable<Product>> GetAll() { /* works */ }
// Violates LSP - unexpected exceptions
public Task Add(Product entity)
=> throw new NotSupportedException("Read-only repository");
public Task Update(Product entity)
=> throw new NotSupportedException("Read-only repository");
public Task Delete(int id)
=> throw new NotSupportedException("Read-only repository");
}
✅ LSP-Compliant:
// Segregate interfaces by capability
public interface IReadRepository<T>
{
Task<T> GetById(int id);
Task<IEnumerable<T>> GetAll();
}
public interface IWriteRepository<T>
{
Task Add(T entity);
Task Update(T entity);
Task Delete(int id);
}
// Full repository implements both
public class ProductRepository : IReadRepository<Product>, IWriteRepository<Product>
{
// All methods implemented
}
// Read-only repository only implements what it supports
public class ReadOnlyProductRepository : IReadRepository<Product>
{
// Only read methods - no LSP violation possible
}
// Clients declare exactly what they need
public class ProductSearchService
{
private readonly IReadRepository<Product> _repository; // Only reads
public ProductSearchService(IReadRepository<Product> repository)
{
_repository = repository;
}
}
public class ProductManagementService
{
private readonly IReadRepository<Product> _readRepo;
private readonly IWriteRepository<Product> _writeRepo;
// Explicit about capabilities
}
Architectural LSP: Microservices
❌ LSP Violation in Service Design
PaymentService interface expects synchronous confirmation
├── StripePaymentService: Returns immediately ✅
├── BankTransferService: Takes 3 days ❌
└── CryptoPaymentService: Requires mining confirmation ❌
Client code assumes immediate confirmation - breaks with some implementations
✅ LSP-Compliant Service Design
PaymentService returns PaymentStatus with clear state
├── Completed: Payment confirmed
├── Pending: Awaiting confirmation
├── RequiresCallback: Will notify via webhook
└── Failed: Payment rejected
All implementations follow same contract - no surprises
Interface Segregation Principle (ISP)
“Clients should not be forced to depend on interfaces they don’t use.”
The Architect’s Problem: Interface Bloat
Large interfaces force clients to take dependencies they don’t need, leading to:
- Unnecessary coupling
- Fragile code (changes affect unrelated clients)
- Testing complexity (mocking unused methods)
Fat Interface Example
❌ Interface Bloat:
// God interface - everything for everyone
public interface IUserService
{
// Authentication
Task<User> Authenticate(string username, string password);
Task<string> GenerateToken(User user);
Task RevokeToken(string token);
// Profile management
Task<User> GetProfile(int userId);
Task UpdateProfile(User user);
Task UploadAvatar(int userId, byte[] image);
// Password management
Task ChangePassword(int userId, string oldPass, string newPass);
Task ResetPassword(string email);
Task ValidatePasswordStrength(string password);
// User administration
Task<IEnumerable<User>> GetAllUsers();
Task<User> CreateUser(User user);
Task DeleteUser(int userId);
Task AssignRole(int userId, string role);
// Analytics
Task<UserStats> GetUserStatistics(int userId);
Task<IEnumerable<UserActivity>> GetActivityLog(int userId);
// Notifications
Task SendNotification(int userId, string message);
Task<IEnumerable<Notification>> GetNotifications(int userId);
}
// Authentication controller forced to depend on EVERYTHING
public class AuthController
{
private readonly IUserService _userService; // 90% unused
public async Task<IActionResult> Login(LoginRequest request)
{
// Only uses Authenticate and GenerateToken
var user = await _userService.Authenticate(request.Username, request.Password);
var token = await _userService.GenerateToken(user);
return Ok(new { token });
}
}
Problems:
- AuthController depends on 18 methods but uses 2
- Changes to profile methods require redeploying auth
- Testing requires mocking 18 methods
- Tight coupling across unrelated features
✅ Segregated Interfaces:
// Small, focused interfaces
public interface IAuthenticationService
{
Task<User> Authenticate(string username, string password);
Task<string> GenerateToken(User user);
Task RevokeToken(string token);
}
public interface IProfileService
{
Task<User> GetProfile(int userId);
Task UpdateProfile(User user);
Task UploadAvatar(int userId, byte[] image);
}
public interface IPasswordService
{
Task ChangePassword(int userId, string oldPass, string newPass);
Task ResetPassword(string email);
Task ValidatePasswordStrength(string password);
}
public interface IUserAdministrationService
{
Task<IEnumerable<User>> GetAllUsers();
Task<User> CreateUser(User user);
Task DeleteUser(int userId);
Task AssignRole(int userId, string role);
}
// Controllers depend only on what they need
public class AuthController
{
private readonly IAuthenticationService _authService; // Only 3 methods
public async Task<IActionResult> Login(LoginRequest request)
{
var user = await _authService.Authenticate(request.Username, request.Password);
var token = await _authService.GenerateToken(user);
return Ok(new { token });
}
}
public class ProfileController
{
private readonly IProfileService _profileService; // Only profile methods
private readonly IPasswordService _passwordService; // Only password methods
}
ISP in Distributed Systems
// ❌ Monolithic API client
public interface IOrderApiClient
{
Task<Order> GetOrder(int id);
Task<IEnumerable<Order>> SearchOrders(OrderQuery query);
Task<Order> CreateOrder(CreateOrderRequest request);
Task CancelOrder(int id);
Task<OrderStatus> GetStatus(int id);
Task<IEnumerable<OrderItem>> GetOrderItems(int id);
Task<Invoice> GenerateInvoice(int id);
Task<ShippingLabel> GenerateShippingLabel(int id);
Task<IEnumerable<OrderNote>> GetNotes(int id);
Task AddNote(int id, string note);
}
// Every service depends on entire API surface
// ✅ Segregated API contracts
public interface IOrderQueryService
{
Task<Order> GetOrder(int id);
Task<IEnumerable<Order>> SearchOrders(OrderQuery query);
Task<OrderStatus> GetStatus(int id);
}
public interface IOrderCommandService
{
Task<Order> CreateOrder(CreateOrderRequest request);
Task CancelOrder(int id);
}
public interface IOrderDocumentService
{
Task<Invoice> GenerateInvoice(int id);
Task<ShippingLabel> GenerateShippingLabel(int id);
}
// Reporting service only depends on queries
public class OrderReportingService
{
private readonly IOrderQueryService _orderQuery; // Clean dependency
}
Dependency Inversion Principle (DIP)
“High-level modules should not depend on low-level modules. Both should depend on abstractions.”
The Strategic Impact
DIP is the most architecturally significant SOLID principle. It enables:
- Testability: Mock dependencies easily
- Flexibility: Swap implementations without changing clients
- Decoupling: High-level policy independent of low-level details
Dependency Inversion Layers
❌ Without DIP (Tightly Coupled)
┌─────────────────────────────┐
│ Business Logic │
│ (High-level policy) │
└──────────┬──────────────────┘
│ depends on
▼
┌─────────────────────────────┐
│ Database/HTTP/File │
│ (Low-level details) │
└─────────────────────────────┘
Changes in infrastructure force changes in business logic
✅ With DIP (Inverted Dependencies)
┌─────────────────────────────┐
│ Business Logic │
│ (High-level policy) │
└──────────┬──────────────────┘
│ depends on
▼
┌─────────────────────────────┐
│ Abstractions/Interfaces │
└──────────▲──────────────────┘
│ implements
┌──────────┴──────────────────┐
│ Database/HTTP/File │
│ (Low-level details) │
└─────────────────────────────┘
Infrastructure changes don't affect business logic
Practical DIP Example
❌ Violation (Direct Dependencies):
public class OrderService
{
private readonly SqlServerDatabase _database;
private readonly SmtpEmailSender _emailSender;
private readonly StripePaymentGateway _paymentGateway;
public OrderService()
{
// Tightly coupled to concrete implementations
_database = new SqlServerDatabase("connection-string");
_emailSender = new SmtpEmailSender("smtp.server.com");
_paymentGateway = new StripePaymentGateway("api-key");
}
public async Task<Order> CreateOrder(OrderRequest request)
{
// Business logic mixed with infrastructure concerns
var connection = _database.GetConnection();
var command = connection.CreateCommand();
command.CommandText = "INSERT INTO Orders..."; // SQL in business logic
await command.ExecuteNonQueryAsync();
_emailSender.Send("smtp.server.com", 587, "[email protected]",
request.CustomerEmail, "Order Confirmation", "Your order...");
_paymentGateway.Charge(request.PaymentToken, request.Amount);
return new Order();
}
}
// Problems:
// - Cannot test without real database, SMTP server, Stripe account
// - Cannot swap implementations (e.g., PostgreSQL, SendGrid, PayPal)
// - Business logic coupled to infrastructure details
✅ With DIP (Abstraction Layer):
// Business logic depends on abstractions
public class OrderService
{
private readonly IOrderRepository _orderRepository;
private readonly IEmailService _emailService;
private readonly IPaymentGateway _paymentGateway;
public OrderService(
IOrderRepository orderRepository,
IEmailService emailService,
IPaymentGateway paymentGateway)
{
_orderRepository = orderRepository;
_emailService = emailService;
_paymentGateway = paymentGateway;
}
public async Task<Order> CreateOrder(OrderRequest request)
{
// Pure business logic - no infrastructure concerns
var order = new Order
{
CustomerId = request.CustomerId,
Items = request.Items,
Total = CalculateTotal(request.Items)
};
await _orderRepository.Save(order);
await _emailService.SendOrderConfirmation(order);
await _paymentGateway.ProcessPayment(order.Total, request.PaymentToken);
return order;
}
private decimal CalculateTotal(List<OrderItem> items)
{
return items.Sum(i => i.Price * i.Quantity);
}
}
// Infrastructure implements abstractions
public class SqlServerOrderRepository : IOrderRepository
{
public async Task Save(Order order) { /* SQL implementation */ }
}
public class SendGridEmailService : IEmailService
{
public async Task SendOrderConfirmation(Order order) { /* SendGrid API */ }
}
public class StripePaymentGateway : IPaymentGateway
{
public async Task ProcessPayment(decimal amount, string token) { /* Stripe API */ }
}
// Easy to test
public class OrderServiceTests
{
[Fact]
public async Task CreateOrder_SavesOrderAndSendsEmail()
{
// Arrange
var mockRepo = new Mock<IOrderRepository>();
var mockEmail = new Mock<IEmailService>();
var mockPayment = new Mock<IPaymentGateway>();
var service = new OrderService(
mockRepo.Object,
mockEmail.Object,
mockPayment.Object);
// Act
var order = await service.CreateOrder(new OrderRequest());
// Assert
mockRepo.Verify(r => r.Save(It.IsAny<Order>()), Times.Once);
mockEmail.Verify(e => e.SendOrderConfirmation(It.IsAny<Order>()), Times.Once);
}
}
DIP in Hexagonal Architecture
Core Domain (Business Logic)
│
│ Depends on abstractions (ports)
│
▼
┌─────────────────────────────────┐
│ Abstractions/Ports │
│ - IOrderRepository │
│ - IPaymentGateway │
│ - INotificationService │
└─────────────────────────────────┘
▲ ▲
│ │
│ Implements │ Implements
│ │
┌───┴────────┐ ┌───┴────────┐
│ Database │ │ External │
│ Adapter │ │ API │
│ │ │ Adapter │
└────────────┘ └────────────┘
When DIP Adds Overhead
⚠️ Over-Abstraction:
// Abstracting stable, unlikely-to-change dependencies
public interface IDateTime
{
DateTime Now { get; }
}
public class SystemDateTime : IDateTime
{
public DateTime Now => DateTime.Now;
}
// Abstracting framework features
public interface IJsonSerializer
{
string Serialize(object obj);
}
public class NewtonsoftJsonSerializer : IJsonSerializer
{
public string Serialize(object obj)
=> JsonConvert.SerializeObject(obj);
}
When to Skip DIP:
- Framework types (DateTime, HttpContext) - stable, well-tested
- Utility libraries (JSON, logging) - rarely change
- Simple data structures (List, Dictionary)
- Language features (LINQ, async/await)
SOLID Trade-offs: The Decision Matrix
| Principle | Adds Value When | Creates Overhead When |
|---|---|---|
| SRP | Multiple teams, different change cadences | Small codebase, single team |
| OCP | High variability, frequent extensions | Stable domain, rare changes |
| LSP | Polymorphic behavior, substitution needed | Simple inheritance, no substitution |
| ISP | Large interfaces, varied clients | Small interfaces, single client |
| DIP | External dependencies, need testability | Stable internal utilities |
Conclusion: Pragmatic SOLID
Key Principles :
-
SOLID is about trade-offs, not absolutes
- Apply principles when they add value
- Skip them when overhead exceeds benefit
-
Context drives decisions
- System maturity and stability
- Performance requirements
- Time and budget constraints
-
Evolve architecture over time
- Start simple, refactor to patterns
- Don’t predict future needs—respond to actual requirements
- Measure complexity: if it’s growing, apply SOLID
-
Focus on business value
- SOLID principles serve the business, not vice versa
- Clean code that delivers late is still late
- Technical excellence enables business agility
The Mindset:
- Understand why each principle exists
- Recognize when to apply each principle
- Accept trade-offs with open eyes
- Optimize for change, not perfection