Separation of Concerns
By the end of this lesson
Give each part one reason to change.
A concern is a reason the code might have to change. Separation of concerns means arranging things so that each part answers to one of those reasons.
That phrasing matters. "One thing" is too vague to act on — whether a method does one thing depends entirely on how you squint at it. "One reason to change" is a question you can answer by looking at your own change history. If the VAT rate changes, which files do you open? If you move email providers, which files do you open? If the answer is the same file both times, that file has two concerns in it.
public async Task<int> PlaceOrderAsync(OrderRequest request)
{
if (request.Lines.Count == 0)
throw new ArgumentException("An order needs at least one line.");
decimal net = request.Lines.Sum(line => line.UnitPrice * line.Quantity);
decimal vat = Math.Round(net * 0.20m, 2);
decimal total = net + vat;
var order = new Order
{
CustomerId = request.CustomerId,
Total = total,
PlacedUtc = DateTime.UtcNow,
};
_db.Orders.Add(order);
await _db.SaveChangesAsync();
await _http.PostAsJsonAsync(
"https://email.internal.example/send",
new { to = request.CustomerEmail, subject = "Order received", body = $"Total {total:C}." });
return order.Id;
}- Lines 3-4 are a validation rule. They change when the rules about what makes an acceptable order change.
- Lines 6-8 are a pricing calculation. They change when tax rates change, or when discounts arrive, or when rounding has to follow a different convention.
- Lines 10-19 are persistence. They change when the storage technology or the table shape changes.
- Lines 21-24 are notification. They change when the email provider changes, or when someone decides the customer should get a text message instead.
- Four independent reasons, one method. Any of them being touched puts the other three at risk, and none of them can be tested without the other three being present.
The cost is not aesthetic. Testing the VAT calculation requires a database and an HTTP endpoint, because the calculation is welded to both. Changing the email provider means editing a method that also decides whether an order is valid. And a reader who wants to know the pricing rule has to read twenty-four lines of unrelated machinery to find three lines of arithmetic.
Separating the concerns means each of those four reasons lives somewhere of its own, and the original method becomes a short description of the sequence.
public sealed class PlaceOrderHandler(
IOrderValidator validator,
IOrderPricing pricing,
IOrderRepository orders,
ICustomerNotifier notifier)
{
public async Task<int> HandleAsync(OrderRequest request, CancellationToken token)
{
validator.Validate(request);
OrderTotals totals = pricing.Price(request.Lines);
Order order = Order.Create(request.CustomerId, request.Lines, totals);
await orders.AddAsync(order, token);
await notifier.OrderReceivedAsync(order, token);
return order.Id;
}
}- The parameters on the class declaration are a primary constructor — a C# 12 shorthand that declares the constructor and makes its parameters usable throughout the class body, without writing matching fields.
- The handler now states the sequence and nothing else: validate, price, create, store, notify. Read it once and you know what placing an order involves.
- Each collaborator has one reason to change. A VAT change touches the pricing implementation. A provider change touches the notifier implementation. Neither touches this file.
- Pricing can now be tested by calling it with a list of lines. No database, no HTTP, no setup — a few milliseconds per test.
What the separation actually buys, task by task:
| Everything in one method | Concerns separated | |
|---|---|---|
| Change the VAT rate | Edit a method that also validates and saves | Edit the pricing class |
| Test the total | Needs a database and an email endpoint | Call a method with a list of lines |
| Swap the email provider | Edit order placement logic | Write a second notifier implementation |
| Find the pricing rule | Read the whole method | Open one small class |
| Number of files | One | Five |
Summary
- A concern is a reason to change; separate by those reasons rather than by technical type
- Entangled code makes cheap changes risky and makes fast tests impossible
- Check dependencies, not folders — a class that needs a database is not separated from it
- The cost is more files and more indirection, so separate what genuinely changes on its own schedule
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Count the reasons to change
A method called GeneratePayslip does the following: loads an employee from the database, calculates gross pay from hours and rate, applies tax bands, renders a PDF, uploads the PDF to blob storage, and writes a row to an audit table.
How many reasons to change are in that method? Which of them would you separate first, and why that one?
Show solution
There are at least five: the way employees are loaded, the pay calculation, the tax bands, the PDF layout, and the storage destination. Auditing is arguably a sixth, though it often travels with the operation rather than changing on its own.
Separate the tax band calculation first. It changes on a legal schedule that has nothing to do with your release plan, it is the part most likely to be wrong, and it is pure arithmetic — so once it is separate you can test dozens of cases in under a second. Highest risk, cheapest to isolate.
The PDF rendering is the second candidate, for a different reason: it is slow and awkward in tests, and it makes every other test in the method slow too by association.
Loading the employee is the weakest candidate. It will change when the storage does, which is rare, and separating it buys little on its own.
Try it yourself
Separate one concern
Take the tangled PlaceOrderAsync from this lesson and extract only the pricing. Do not touch validation, persistence or notification.
Then write one test that checks the VAT on an order of two lines. The test must not reference a database.
Show solution
Pricing becomes a class with no dependencies, which is why it can be tested by construction and a call. The test needs no fixture, no database and no configuration.
Extracting one concern rather than all four is deliberate. Each extraction is a separate, verifiable step, and you keep a working application between them. Rewriting the whole method at once means a large change with no test coverage to catch what you broke halfway through.
Note the calculation returns a value rather than mutating the order. That keeps it a function of its inputs, which is what makes it trivially testable.
public sealed record OrderTotals(decimal Net, decimal Vat, decimal Total);
public sealed class OrderPricing : IOrderPricing
{
private const decimal VatRate = 0.20m;
public OrderTotals Price(IReadOnlyList<OrderLine> lines)
{
decimal net = lines.Sum(line => line.UnitPrice * line.Quantity);
decimal vat = Math.Round(net * VatRate, 2, MidpointRounding.AwayFromZero);
return new OrderTotals(net, vat, net + vat);
}
}
[Fact]
public void Price_TwoLines_AddsTwentyPercentVat()
{
var lines = new[]
{
new OrderLine { UnitPrice = 30m, Quantity = 2 },
new OrderLine { UnitPrice = 40m, Quantity = 1 },
};
OrderTotals totals = new OrderPricing().Price(lines);
Assert.Equal(100m, totals.Net);
Assert.Equal(20m, totals.Vat);
}Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.