Bad code is not always code that does not work. Very often, bad code works perfectly today, passes basic tests, and satisfies the first business requirement. The problem appears later, when the same code must be changed, extended, tested, reused, or maintained by another developer.
In professional C# projects, especially enterprise applications, backend systems, APIs, industrial software, and long-running business systems, code quality is not about beauty. It is about reducing risk. A clean design makes future changes safer and cheaper. A poor design makes every new requirement dangerous.
This article shows how to transform bad C# code into better code using the SOLID principles. Instead of discussing theory only, we will use one practical example: an order processing service that calculates the total price, saves an order, sends an email, and writes logs.
At first glance, this kind of code may look normal. Many applications start like this. However, as requirements grow, the design becomes harder and harder to maintain.
The Problem: A Bad Order Service
Imagine that we are building a simple e-commerce backend. We have an order service responsible for processing customer orders.
Here is the first version:
using System;
using System.Collections.Generic;
using System.Net.Mail;
public class OrderItem
{
public string ProductName { get; set; }
public decimal UnitPrice { get; set; }
public int Quantity { get; set; }
}
public class Order
{
public int Id { get; set; }
public string CustomerEmail { get; set; }
public List<OrderItem> Items { get; set; } = new();
}
public class OrderService
{
public void ProcessOrder(Order order)
{
decimal total = 0;
foreach (var item in order.Items)
{
total += item.UnitPrice * item.Quantity;
}
if (total > 1000)
{
total = total * 0.9m;
}
Console.WriteLine("Saving order to database...");
Console.WriteLine($"Order {order.Id} saved with total {total}");
var smtpClient = new SmtpClient("smtp.company.com");
var mailMessage = new MailMessage(
"sales@company.com",
order.CustomerEmail,
"Order confirmation",
$"Your order has been processed. Total amount: {total}"
);
smtpClient.Send(mailMessage);
Console.WriteLine("Confirmation email sent.");
}
}This code works, but it has many design problems.
The OrderService calculates prices, applies discounts, saves data, sends emails, and logs information. It has too many responsibilities. It is tightly coupled to SmtpClient. It is hard to test because it directly sends real emails. If we want to change the discount rules, we must modify the service. If we want to replace email with SMS, we must modify the same class again.
This is exactly where SOLID helps.
What SOLID Means
SOLID is a set of five object-oriented design principles:
Single Responsibility Principle
Open/Closed Principle
Liskov Substitution Principle
Interface Segregation Principle
Dependency Inversion Principle
These principles are not rules that must be followed blindly. They are guidelines that help us create code that is easier to change, test, and extend.
Let us refactor the bad code step by step.
Step 1: Apply the Single Responsibility Principle
The Single Responsibility Principle says that a class should have one reason to change.
Our OrderService currently has many reasons to change:
Pricing logic changes
Discount rules change
Database logic changes
Email provider changes
Logging logic changes
Order workflow changes
That means the class does too much.
We can start by extracting price calculation into a separate class.
public interface IOrderTotalCalculator
{
decimal CalculateTotal(Order order);
}
public class OrderTotalCalculator : IOrderTotalCalculator
{
public decimal CalculateTotal(Order order)
{
decimal total = 0;
foreach (var item in order.Items)
{
total += item.UnitPrice * item.Quantity;
}
if (total > 1000)
{
total = total * 0.9m;
}
return total;
}
}Now the calculation logic has its own place. If pricing changes, we modify OrderTotalCalculator, not the entire order workflow.
Next, we extract order persistence.
public interface IOrderRepository
{
void Save(Order order, decimal total);
}
public class OrderRepository : IOrderRepository
{
public void Save(Order order, decimal total)
{
Console.WriteLine("Saving order to database...");
Console.WriteLine($"Order {order.Id} saved with total {total}");
}
}In a real application, this class would use Entity Framework Core, Dapper, or another persistence mechanism. For now, console output is enough to demonstrate the design.
Now we extract email sending.
public interface IEmailSender
{
void SendOrderConfirmation(string customerEmail, decimal total);
}
public class SmtpEmailSender : IEmailSender
{
public void SendOrderConfirmation(string customerEmail, decimal total)
{
var smtpClient = new System.Net.Mail.SmtpClient("smtp.company.com");
var mailMessage = new System.Net.Mail.MailMessage(
"sales@company.com",
customerEmail,
"Order confirmation",
$"Your order has been processed. Total amount: {total}"
);
smtpClient.Send(mailMessage);
}
}Finally, we can simplify the order service.
public class OrderService
{
private readonly IOrderTotalCalculator _calculator;
private readonly IOrderRepository _repository;
private readonly IEmailSender _emailSender;
public OrderService(
IOrderTotalCalculator calculator,
IOrderRepository repository,
IEmailSender emailSender)
{
_calculator = calculator;
_repository = repository;
_emailSender = emailSender;
}
public void ProcessOrder(Order order)
{
var total = _calculator.CalculateTotal(order);
_repository.Save(order, total);
_emailSender.SendOrderConfirmation(order.CustomerEmail, total);
}
}This version is already much better. OrderService coordinates the process, but it does not know the details of calculation, persistence, or email delivery.
Step 2: Apply the Open/Closed Principle
The Open/Closed Principle says that code should be open for extension but closed for modification.
Look again at the calculator:
public class OrderTotalCalculator : IOrderTotalCalculator
{
public decimal CalculateTotal(Order order)
{
decimal total = 0;
foreach (var item in order.Items)
{
total += item.UnitPrice * item.Quantity;
}
if (total > 1000)
{
total = total * 0.9m;
}
return total;
}
}The discount rule is still hardcoded. What happens if the business adds more rules?
For example:
10% discount for orders above 1000
5% discount for VIP customers
Free shipping above 500
Seasonal discount in December
Coupon code discount
If we keep adding if statements, the calculator will become difficult to maintain.
Instead, we can create discount rules.
First, let us extend the order model slightly:
public class Order
{
public int Id { get; set; }
public string CustomerEmail { get; set; }
public bool IsVipCustomer { get; set; }
public string? CouponCode { get; set; }
public List<OrderItem> Items { get; set; } = new();
}Now we define a discount rule interface.
public interface IDiscountRule
{
decimal ApplyDiscount(Order order, decimal currentTotal);
}Then we create specific rules.
public class LargeOrderDiscountRule : IDiscountRule
{
public decimal ApplyDiscount(Order order, decimal currentTotal)
{
if (currentTotal > 1000)
{
return currentTotal * 0.9m;
}
return currentTotal;
}
}
public class VipCustomerDiscountRule : IDiscountRule
{
public decimal ApplyDiscount(Order order, decimal currentTotal)
{
if (order.IsVipCustomer)
{
return currentTotal * 0.95m;
}
return currentTotal;
}
}
public class CouponDiscountRule : IDiscountRule
{
public decimal ApplyDiscount(Order order, decimal currentTotal)
{
if (order.CouponCode == "SAVE20")
{
return currentTotal * 0.8m;
}
return currentTotal;
}
}Now the calculator can use a collection of discount rules.
public class OrderTotalCalculator : IOrderTotalCalculator
{
private readonly IEnumerable<IDiscountRule> _discountRules;
public OrderTotalCalculator(IEnumerable<IDiscountRule> discountRules)
{
_discountRules = discountRules;
}
public decimal CalculateTotal(Order order)
{
decimal total = 0;
foreach (var item in order.Items)
{
total += item.UnitPrice * item.Quantity;
}
foreach (var rule in _discountRules)
{
total = rule.ApplyDiscount(order, total);
}
return total;
}
}This design is open for extension. If we need a new discount rule, we add a new class. We do not modify the calculator.
For example:
public class ChristmasDiscountRule : IDiscountRule
{
public decimal ApplyDiscount(Order order, decimal currentTotal)
{
if (DateTime.UtcNow.Month == 12)
{
return currentTotal * 0.97m;
}
return currentTotal;
}
}No existing code needs to be changed, except dependency injection registration.
Step 3: Apply the Liskov Substitution Principle
The Liskov Substitution Principle says that derived types should be replaceable for their base types without breaking the application.
A common violation happens when subclasses override behavior in unexpected ways.
Bad example:
public abstract class NotificationSender
{
public abstract void Send(string recipient, string message);
}
public class EmailNotificationSender : NotificationSender
{
public override void Send(string recipient, string message)
{
Console.WriteLine($"Sending email to {recipient}: {message}");
}
}
public class SmsNotificationSender : NotificationSender
{
public override void Send(string recipient, string message)
{
if (!recipient.StartsWith("+"))
{
throw new InvalidOperationException("SMS recipient must be a phone number.");
}
Console.WriteLine($"Sending SMS to {recipient}: {message}");
}
}At first glance, this looks fine. But the problem is that both classes use the same method signature, while they expect different recipient formats. An email sender expects an email address. An SMS sender expects a phone number.
If some code accepts NotificationSender, it may pass an email address. That works for email but fails for SMS.
Better design:
public interface IEmailSender
{
void SendOrderConfirmation(string customerEmail, decimal total);
}
public interface ISmsSender
{
void SendSms(string phoneNumber, string message);
}Now each interface expresses the real contract. We do not pretend that every notification channel behaves exactly the same.
The Liskov principle is not only about inheritance. It is about keeping contracts honest. If an implementation cannot safely behave like the abstraction, the abstraction is probably wrong.
Step 4: Apply the Interface Segregation Principle
The Interface Segregation Principle says that clients should not be forced to depend on methods they do not use.
Bad example:
public interface IOrderProcessor
{
void Validate(Order order);
decimal CalculateTotal(Order order);
void Save(Order order);
void SendConfirmation(Order order);
void Cancel(Order order);
void Refund(Order order);
}This interface is too large. A class that only calculates totals must still know about saving, cancelling, refunding, and sending confirmations.
Better design:
public interface IOrderValidator
{
void Validate(Order order);
}
public interface IOrderTotalCalculator
{
decimal CalculateTotal(Order order);
}
public interface IOrderRepository
{
void Save(Order order, decimal total);
}
public interface IOrderConfirmationSender
{
void SendConfirmation(Order order, decimal total);
}
public interface IOrderCancellationService
{
void Cancel(Order order);
}
public interface IRefundService
{
void Refund(Order order);
}Smaller interfaces are easier to understand, easier to test, and easier to implement.
A class should not implement methods that are meaningless for it. Empty methods, methods throwing NotSupportedException, and large “god interfaces” are often signs of Interface Segregation Principle violations.
Step 5: Apply the Dependency Inversion Principle
The Dependency Inversion Principle says that high-level modules should not depend on low-level modules. Both should depend on abstractions.
The original bad service directly created SmtpClient:
var smtpClient = new SmtpClient("smtp.company.com");This creates tight coupling. The service depends on a concrete email technology. That makes testing difficult and replacement expensive.
The better version receives dependencies through the constructor:
public class OrderService
{
private readonly IOrderTotalCalculator _calculator;
private readonly IOrderRepository _repository;
private readonly IEmailSender _emailSender;
public OrderService(
IOrderTotalCalculator calculator,
IOrderRepository repository,
IEmailSender emailSender)
{
_calculator = calculator;
_repository = repository;
_emailSender = emailSender;
}
public void ProcessOrder(Order order)
{
var total = _calculator.CalculateTotal(order);
_repository.Save(order, total);
_emailSender.SendOrderConfirmation(order.CustomerEmail, total);
}
}Now the service depends on abstractions, not concrete classes.
In ASP.NET Core, we can register dependencies like this:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IOrderTotalCalculator, OrderTotalCalculator>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<IDiscountRule, LargeOrderDiscountRule>();
builder.Services.AddScoped<IDiscountRule, VipCustomerDiscountRule>();
builder.Services.AddScoped<IDiscountRule, CouponDiscountRule>();
builder.Services.AddScoped<OrderService>();
var app = builder.Build();This gives us flexibility. We can replace SmtpEmailSender with another implementation without changing OrderService.
For example:
public class FakeEmailSender : IEmailSender
{
public void SendOrderConfirmation(string customerEmail, decimal total)
{
Console.WriteLine($"Fake email sent to {customerEmail}. Total: {total}");
}
}This implementation can be used in tests or local development.
Adding Validation
A real order process should validate input before calculating totals.
Bad validation would be placed directly inside OrderService. Better validation has its own responsibility.
public interface IOrderValidator
{
void Validate(Order order);
}
public class OrderValidator : IOrderValidator
{
public void Validate(Order order)
{
if (order == null)
{
throw new ArgumentNullException(nameof(order));
}
if (string.IsNullOrWhiteSpace(order.CustomerEmail))
{
throw new InvalidOperationException("Customer email is required.");
}
if (order.Items == null || order.Items.Count == 0)
{
throw new InvalidOperationException("Order must contain at least one item.");
}
foreach (var item in order.Items)
{
if (item.Quantity <= 0)
{
throw new InvalidOperationException("Item quantity must be greater than zero.");
}
if (item.UnitPrice < 0)
{
throw new InvalidOperationException("Item price cannot be negative.");
}
}
}
}Now we update the order service:
public class OrderService
{
private readonly IOrderValidator _validator;
private readonly IOrderTotalCalculator _calculator;
private readonly IOrderRepository _repository;
private readonly IEmailSender _emailSender;
public OrderService(
IOrderValidator validator,
IOrderTotalCalculator calculator,
IOrderRepository repository,
IEmailSender emailSender)
{
_validator = validator;
_calculator = calculator;
_repository = repository;
_emailSender = emailSender;
}
public void ProcessOrder(Order order)
{
_validator.Validate(order);
var total = _calculator.CalculateTotal(order);
_repository.Save(order, total);
_emailSender.SendOrderConfirmation(order.CustomerEmail, total);
}
}Now validation can be changed independently.
Making the Code Testable
One of the biggest advantages of SOLID code is testability.
The original code was hard to test because it calculated totals, saved data, and sent emails in one method. The improved version can be tested using fake implementations.
Example unit test:
using Xunit;
using System.Collections.Generic;
public class OrderTotalCalculatorTests
{
[Fact]
public void CalculateTotal_ShouldApplyLargeOrderDiscount()
{
var discountRules = new List<IDiscountRule>
{
new LargeOrderDiscountRule()
};
var calculator = new OrderTotalCalculator(discountRules);
var order = new Order
{
Items = new List<OrderItem>
{
new OrderItem
{
ProductName = "Laptop",
UnitPrice = 1200,
Quantity = 1
}
}
};
var total = calculator.CalculateTotal(order);
Assert.Equal(1080, total);
}
}This test does not need a database. It does not send emails. It tests only calculation logic.
We can also test OrderService by using fake dependencies.
public class FakeOrderRepository : IOrderRepository
{
public bool WasSaved { get; private set; }
public void Save(Order order, decimal total)
{
WasSaved = true;
}
}
public class FakeOrderEmailSender : IEmailSender
{
public bool WasEmailSent { get; private set; }
public void SendOrderConfirmation(string customerEmail, decimal total)
{
WasEmailSent = true;
}
}
public class FakeOrderValidator : IOrderValidator
{
public void Validate(Order order)
{
}
}Test:
public class OrderServiceTests
{
[Fact]
public void ProcessOrder_ShouldSaveOrderAndSendEmail()
{
var validator = new FakeOrderValidator();
var calculator = new OrderTotalCalculator(new List<IDiscountRule>());
var repository = new FakeOrderRepository();
var emailSender = new FakeOrderEmailSender();
var service = new OrderService(
validator,
calculator,
repository,
emailSender
);
var order = new Order
{
CustomerEmail = "customer@example.com",
Items = new List<OrderItem>
{
new OrderItem
{
ProductName = "Keyboard",
UnitPrice = 100,
Quantity = 2
}
}
};
service.ProcessOrder(order);
Assert.True(repository.WasSaved);
Assert.True(emailSender.WasEmailSent);
}
}The test is simple because the production code is decoupled.
Final Improved Version
After refactoring, the system contains small, focused classes:
OrderService coordinates the processOrderValidator validates the orderOrderTotalCalculator calculates totalsIDiscountRule allows flexible discount logicOrderRepository saves the orderSmtpEmailSender sends confirmations
Final version:
public class OrderService
{
private readonly IOrderValidator _validator;
private readonly IOrderTotalCalculator _calculator;
private readonly IOrderRepository _repository;
private readonly IEmailSender _emailSender;
public OrderService(
IOrderValidator validator,
IOrderTotalCalculator calculator,
IOrderRepository repository,
IEmailSender emailSender)
{
_validator = validator;
_calculator = calculator;
_repository = repository;
_emailSender = emailSender;
}
public void ProcessOrder(Order order)
{
_validator.Validate(order);
var total = _calculator.CalculateTotal(order);
_repository.Save(order, total);
_emailSender.SendOrderConfirmation(order.CustomerEmail, total);
}
}This class is now short, readable, and easy to understand. It does not contain technical details. It describes the business process at a high level.
That is a good sign.
Why This Design Is Better
The improved code is better because each part has a clear responsibility.
If discount logic changes, we change discount rules.
If email delivery changes, we replace IEmailSender.
If persistence changes, we replace IOrderRepository.
If validation changes, we update OrderValidator.
If we need more tests, we can test each part separately.
This reduces the risk of accidental bugs. In bad code, one small change can break unrelated behavior. In clean code, changes are isolated.
Common Mistakes When Applying SOLID
SOLID does not mean creating interfaces for everything blindly. Too many abstractions can also make code difficult to understand.
A common mistake is overengineering. For example, if an application has only one simple calculation and it will never change, adding many interfaces may not help. But in business software, requirements usually change. In that context, a clean design pays off.
Another mistake is using SOLID mechanically without thinking about business boundaries. The goal is not to have more classes. The goal is to make the code easier to change.
Good SOLID design should make the system simpler, not more complicated.
Practical Refactoring Strategy
When you see a large class, do not rewrite everything at once. Refactor gradually.
First, identify responsibilities. Ask what the class is doing. If it validates data, calculates values, sends emails, writes files, calls APIs, and saves to the database, it probably has too many responsibilities.
Second, extract one responsibility at a time. Start with the most obvious one, such as email sending or calculation logic.
Third, introduce interfaces only where they give real value. Interfaces are useful when you need multiple implementations, testing, dependency inversion, or separation between layers.
Fourth, write tests around extracted logic. Calculation rules and validation rules are usually good candidates for unit tests.
Fifth, use dependency injection to connect the parts.
This approach is safe and practical. It allows you to improve existing code without stopping feature development.
Conclusion
Bad code often starts as simple code. The problem appears when the application grows. A class that was once convenient becomes a maintenance problem because it knows too much and does too much.
SOLID principles help us transform such code into a cleaner design. In this article, we refactored a bad C# order service into a set of smaller, focused components. We applied Single Responsibility Principle, Open/Closed Principle, Liskov Substitution Principle, Interface Segregation Principle, and Dependency Inversion Principle.
The final result is easier to read, easier to test, and easier to extend.
Clean code is not about adding unnecessary architecture. It is about making future changes safer. In real C# systems, especially enterprise applications, this is one of the most important skills a developer can have.
Sources
https://github.com/rafalkukuczka/SolidRefactoringDemo
References
https://en.wikipedia.org/wiki/SOLID
More Info
We implement this in real production systems.
If you need help → contact us