Fixing a 'God Class': A Real-World SOLID Tutorial
Fixing a 'God Class': A Real-World SOLID Tutorial
Interviewers love to ask about SOLID, but the academic definitions are hard to remember. Let's use one real-world scenario to explain all five principles: fixing a 'God Class' that does way too much.
You join a new e-commerce project and find this class. It's a nightmare.
The 'Bad' Code (Our 'God Class'):
// This class does WAY too much.
public class OrderService
{
public void ProcessOrder(Order order, string paymentType)
{
// 1. Validation Logic
Console.WriteLine("Validating order data...");
if (order.TotalAmount <= 0) { / ... / }
// 2. Payment Logic
Console.WriteLine("Processing payment...");
if (paymentType == "CreditCard") {
Console.WriteLine("Charging credit card...");
} else if (paymentType == "PayPal") {
Console.WriteLine("Redirecting to PayPal...");
}
// 3. Database Logic
Console.WriteLine("Saving order to database...");
var db = new SqlConnection("...connection string...");
db.Open();
// ... complex SQL to save the order ...
db.Close();
// 4. Notification Logic
Console.WriteLine("Sending confirmation email...");
var smtp = new SmtpClient("...server...");
// ... complex logic to build and send an email ...
}
}Why is this bad?
- Fragile: Change the email logic, and you might break the payment logic.
- Untestable: You can't test just the validation. You have to run a real database and credit card charge.
- Inflexible: Adding a 'Crypto' payment type is a nightmare.
We will now fix this mess by applying each SOLID principle, one by one. This cluster will break down each step.
SOLID Pillar 1: The Single Responsibility Principle (S)
SOLID Pillar 1: The Single Responsibility Principle (S)
Super Easy Explanation: A class should have one job, and only one job.
Analogy: A Swiss Army knife is bad; it does 20 things poorly. A sharp chef's knife is good; it does one thing perfectly. Our 'God Class' is a Swiss Army knife.
Applying the 'S' Principle:
Our OrderService has four jobs: Validating, Paying, Saving, and Notifying. We'll break it into four smaller, focused classes. Each one has only one reason to change.
The 'Good' Code (Applying 'S'):
// 1. First Job: Validation
public class OrderValidator
{
public void Validate(Order order)
{
if (order.TotalAmount <= 0) { / ... / }
Console.WriteLine("Order validated.");
}
}
// 2. Second Job: Saving
public class OrderRepository
{
public void Save(Order order)
{
Console.WriteLine("Saving order to database...");
// ... db logic ...
}
}
// 3. Third Job: Notifying
public class NotificationService
{
public void SendConfirmationEmail(Order order)
{
Console.WriteLine("Sending confirmation email...");
// ... smtp logic ...
}
}
// 4. Fourth Job: Payment (We will fix this one next!)
public class PaymentProcessor
{
public void ProcessPayment(Order order, string paymentType)
{
if (paymentType == "CreditCard") { / ... / }
else if (paymentType == "PayPal") { / ... / }
}
}Payoff: Now, if we need to change how we save to the database, we only touch the OrderRepository. We can't accidentally break anything else. But our PaymentProcessor still has a problem...
SOLID Pillar 2: The Open/Closed Principle (O)
SOLID Pillar 2: The Open/Closed Principle (O)
Super Easy Explanation: Your class should be open to new features, but closed to being changed.
Analogy: A laptop is 'closed.' You can't just solder in a new component. But it's 'open' to extension via its USB ports. Interfaces are your USB ports.
Applying the 'O' Principle:
Our PaymentProcessor from the last step violates this. To add 'Crypto' payments, we have to modify it. Let's fix this using an interface (our 'USB port').
'Before' (Bad - Violates 'O'):
public class PaymentProcessor
{
public void ProcessPayment(Order order, string paymentType)
{
if (paymentType == "CreditCard") { / ... / }
else if (paymentType == "PayPal") { / ... / }
// To add Crypto, we must modify this class! BAD!
}
}'After' (Good - Follows 'O'):
// 1. Create our 'USB Port' (the interface)
public interface IPaymentProcessor
{
void ProcessPayment(Order order);
}
// 2. Create our 'plugins' (the concrete classes)
public class CreditCardProcessor : IPaymentProcessor
{
public void ProcessPayment(Order order)
{
Console.WriteLine("Charging credit card...");
}
}
public class PayPalProcessor : IPaymentProcessor
{
public void ProcessPayment(Order order)
{
Console.WriteLine("Redirecting to PayPal...");
}
}
// 3. NOW, to add a new payment type, we just add a new class!
// We are 'open' for extension.
public class CryptoProcessor : IPaymentProcessor
{
public void ProcessPayment(Order order)
{
Console.WriteLine("Processing crypto payment...");
}
}
// The original CreditCardProcessor and PayPalProcessor classes
// are 'closed' and never need to be touched again.Payoff: We can add new payment methods forever and never risk breaking the existing, tested ones.
SOLID Pillar 3: The Liskov Substitution Principle (L)
SOLID Pillar 3: The Liskov Substitution Principle (L)
Super Easy Explanation: Child classes must be a perfect, 1-for-1 substitute for their parent class, without causing a problem.
Analogy: The famous 'Rectangle vs. Square' problem. You have a
Rectangleclass withHeightandWidth. You think, 'ASquareis aRectangle,' so you makeSquareinherit. But aSquarehas a rule:Heightmust equalWidth. If a piece of code gets yourSquarebut thinks it's aRectangle, it might setHeight = 10andWidth = 20. YourSquaremust now break the rule (and not be a square) or throw an exception. It breaks the 'contract' of the parent. Therefore, aSquareis not a substitute for aRectangle.
Applying the 'L' Principle:
In our scenario, imagine we have a Product class. Then we create a DigitalProduct class that inherits from it. But our base Product class has a Weight property and a Ship() method. A DigitalProduct has no weight and can't be shipped. If we override Ship() to do nothing or throw an exception, we have violated Liskov.
Any code that tries to ship a List<Product> will crash when it hits our DigitalProduct.
The 'Good' Fix:
// 1. The base abstraction is minimal.
public abstract class Product
{
public string Name { get; set; }
public decimal Price { get; set; }
}
// 2. Create a separate interface for the 'shipping' capability.
public interface IShippable
{
decimal Weight { get; }
void Ship(string address);
}
// 3. Our physical product implements BOTH.
public class PhysicalProduct : Product, IShippable
{
public decimal Weight { get; set; }
public void Ship(string address) { / ... shipping logic ... / }
}
// 4. Our digital product implements ONLY the base class.
public class DigitalProduct : Product
{
public string DownloadUrl { get; set; }
}
Payoff: Our code is predictable. We can now trust that anything that implements IShippable can actually be shipped. We've fixed our bad abstraction.
SOLID Pillar 4: The Interface Segregation Principle (I)
SOLID Pillar 4: The Interface Segregation Principle (I)
Super Easy Explanation: Don't make 'fat' interfaces. Make small, specific ones. Clients shouldn't be forced to depend on methods they don't use.
Analogy: A person who just wants a drink shouldn't be forced to look at a 10-page dinner menu. It's better to have a separate, small 'Drinks Menu.' Fat interfaces are bad.
Applying the 'I' Principle:
Let's look at the OrderRepository we created in Step 1. What if we made a 'fat' interface for it?
'Before' (Bad - 'Fat' Interface):
// This interface is too big!
public interface IOrderRepository
{
void Save(Order order);
Order GetOrderById(int id);
List GetOrdersByCustomer(int customerId);
void DeleteOldOrders();
} Now, our main OrderService (which we will build in the next step) only needs to Save(Order order). But by depending on IOrderRepository, it's 'coupled' to all the other methods. If we change the `DeleteOldOrders()` method, our `OrderService` might need to be recompiled, even though it doesn't care about deleting!
'After' (Good - 'Segregated' Interfaces):
// 1. Create small, specific 'role' interfaces.
public interface IOrderSaver
{
void Save(Order order);
}
public interface IOrderReader
{
Order GetOrderById(int id);
List GetOrdersByCustomer(int customerId);
}
public interface IOrderDeleter
{
void DeleteOldOrders();
}
// 2. Our concrete class can implement as many as it needs.
public class OrderRepository : IOrderSaver, IOrderReader, IOrderDeleter
{
public void Save(Order order) { / ... / }
public Order GetOrderById(int id) { / ... / }
// ... etc ...
} Payoff: Our main `OrderService` can now ask for only the IOrderSaver. It doesn't know or care about reading or deleting, making it simpler and truly decoupled.
SOLID Pillar 5: The Dependency Inversion Principle (D)
SOLID Pillar 5: The Dependency Inversion Principle (D)
Super Easy Explanation: High-level classes shouldn't depend on low-level classes. They should both depend on interfaces (abstractions). This is achieved with 'Dependency Injection'.
Analogy: A boss shouldn't depend on a specific person (Bob the intern). What if Bob is sick? The boss should depend on a role (an 'Intern'). This 'inverts' the dependency from
Boss -> BobtoBoss -> Intern <- Bob.
Applying the 'D' Principle:
This is the final, most important step. We'll build our new, clean OrderService. It is a 'high-level' class. It shouldn't depend on 'low-level' classes like OrderRepository.
'Before' (Bad - Hard-coded dependencies):
public class OrderService
{
// Our high-level class depends DIRECTLY on low-level classes.
// This is 'new-ing up' dependencies. It's inflexible and untestable.
private OrderValidator _validator = new OrderValidator();
private OrderRepository _repo = new OrderRepository();
private NotificationService _notifier = new NotificationService();
public void ProcessOrder(Order order, IPaymentProcessor paymentProcessor)
{
_validator.Validate(order);
paymentProcessor.ProcessPayment(order);
_repo.Save(order);
_notifier.SendConfirmationEmail(order);
}
}'After' (Good - Using 'Dependency Injection'):
Our class now depends only on the interfaces (the 'roles'). We 'inject' the concrete classes in the constructor.
// This is our final, beautiful class!
public class OrderService
{
// Our class depends ONLY on abstractions (interfaces).
private readonly IOrderValidator _validator;
private readonly IOrderSaver _repo; // Uses our 'segregated' interface!
private readonly INotificationService _notifier;
// We'll also need a way to get the right payment processor
private readonly IPaymentProcessorFactory _paymentFactory;
// The dependencies are 'injected' in the constructor.
public OrderService(
IOrderValidator validator,
IOrderSaver repo,
INotificationService notifier,
IPaymentProcessorFactory paymentFactory)
{
_validator = validator;
_repo = repo;
_notifier = notifier;
_paymentFactory = paymentFactory;
}
public void ProcessOrder(Order order, string paymentType)
{
// 1. Get the correct processor from a factory
var paymentProcessor = _paymentFactory.GetProcessor(paymentType);
// 2. Coordinate the work
_validator.Validate(order);
paymentProcessor.ProcessPayment(order);
_repo.Save(order);
_notifier.SendConfirmationEmail(order);
}
}Payoff: This is our final, SOLID class. It has one job (coordinating). It's Testable (we can pass in 'fake' mocks of each interface) and Flexible (we can swap out `EmailService` for `SmsService` without ever changing `OrderService`).


