I'll Be Backend
Principles · C#

Separation of concerns: untangle the request handler

Keep the booking rule apart from HTTP and storage so every caller reuses it.

13 steps4 chapters~6 min at 1.25×#architecture#separation of concerns#request handler#dependency injection
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the C# video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–3

Refactor

Steps 4–9

Payoff

Steps 10–11

Recap

Steps 12–13

The code: before and after

Before

class BookingHandler
{
    private readonly Dictionary<string, int> rates =
        new() { ["Golf"] = 40, ["Polo"] = 35 };
    private readonly List<string> saved = new();
    public string Handle(Dictionary<string, string> request)
    {
        string car = request["car"];
        int days = int.Parse(request["days"]);
        if (!rates.ContainsKey(car) || days < 1) return "400 Bad Request";
        int total = rates[car] * days;
        if (days >= 7) total = total * 90 / 100;
        saved.Add($"{car},{days},{total}");
        return $"200 OK: {car} for {days} days, {total} USD";
    }
    public int SavedCount() => saved.Count;
}

class Desk
{
    public static void Main()
    {
        var web = new BookingHandler();
        Console.WriteLine(web.Handle(
            new() { ["car"] = "Golf", ["days"] = "7" }));
        Console.WriteLine($"{web.SavedCount()} saved");
    }
}

After

class BookingHandler(BookingService service)
{
    public string Handle(Dictionary<string, string> request)
    {
        string car = request["car"];
        int days = int.Parse(request["days"]);
        try
        {
            int total = service.Book(car, days);
            return $"200 OK: {car} for {days} days, {total} USD";
        }
        catch (ArgumentException)
        {
            return "400 Bad Request";
        }
    }
    public int SavedCount() => service.SavedCount();
}

class BookingImport(BookingService service)
{
    public void ImportLine(string line)
    {
        string[] parts = line.Split(',');
        service.Book(parts[0], int.Parse(parts[1]));
    }
}

class BookingService(BookingStore store)
{
    private readonly Dictionary<string, int> rates =
        new() { ["Golf"] = 40, ["Polo"] = 35 };
    public int Book(string car, int days)
    {
        if (!rates.ContainsKey(car) || days < 1)
            throw new ArgumentException("bad booking");
        int total = Price(car, days);
        store.Save(car, days, total);
        return total;
    }
    private int Price(string car, int days)
    {
        int total = rates[car] * days;
        if (days >= 7) total = total * 90 / 100;
        return total;
    }
    public int SavedCount() => store.Count();
}

class BookingStore
{
    private readonly List<string> saved = new();
    public void Save(string car, int days, int total)
    {
        saved.Add($"{car},{days},{total}");
    }
    public int Count() => saved.Count;
}

class Desk
{
    public static void Main()
    {
        var service = new BookingService(new BookingStore());
        var web = new BookingHandler(service);
        var partner = new BookingImport(service);
        Console.WriteLine(web.Handle(
            new() { ["car"] = "Golf", ["days"] = "7" }));
        partner.ImportLine("Polo,3");
        Console.WriteLine($"{web.SavedCount()} saved");
    }
}

Transcript, step by step

Problem

Step 1: One method, one request Before

This is the same car rental desk from last time, and now it takes bookings over the web. A request handler is the method that takes one web request and returns the response, and here that method is Handle. It reads the car and the days, checks them, prices the rental with ten percent off for a week or more, keeps the booking, and writes the response. Desk is the caller, so it sends one web request and prints how many bookings were kept. The run prints Golf for seven days at two hundred fifty two dollars, then one saved. With only one way in, one method that does the whole request is the shortest path, and that's why it was written this way.

Step 2: A nightly partner file Change request

A partner now sends a nightly file of bookings, one line each, like Polo for three days. The quick way adds Import Line to the handler, and it splits the line into a car and a number of days. Calling Handle would mean building a fake web request and reading two hundred OK or four hundred Bad Request back out of the text, so copying the rule looks shorter. So it repeats the check, then the price with its discount, and then the save. It compiles, and the run now ends with two saved.

Step 3: Tangled concerns Code smell

A concern is one job the code is responsible for, like reading the request, pricing the rental, or keeping the booking. Here the concerns are tangled, because the pricing rule sits inside the code that reads a web request, so a second way in can't reach the rule without copying it. You can see the check here, the discount here, and the save here, and each one is now written twice. The next time the discount starts at five days, someone edits one copy, and partner bookings get a different price than web bookings.

Refactor

Step 4: Refactor first Back it out

So let's take the import back out, refactor first, and then add it. The handler is back to one way in, and the run prints one saved again, so every step from here keeps that output.

Step 5: Name the price Extract Method

A business rule is what the rental desk decides, here which cars exist and the weekly discount. We extract the two pricing lines into a method called Price, which takes the car and the days and returns the total. Handle now calls Price, and the run prints the same two lines as before.

Step 6: One call books it Extract Method

Next we extract Book, which holds the check, the price and the save, and returns the total. An unknown car or fewer than one day now throws an argument exception instead of returning text. Handle catches it and returns four hundred Bad Request, so the web response stays the same. That leaves Handle with two jobs, reading the request and writing the response, around one call to Book.

Step 7: The rule gets a class Extract Class

Now we extract a class, Booking Service, and move the rates, Book and Price into it. The handler holds a service and calls its Book method. Saved Count follows the list, so the handler's version now asks the service. The handler's concern is now the web request, and the service's concern is the business rule.

Step 8: Storage moves out Extract Class

We extract one more class, Booking Store, and move the saved list into it. It has Save, which takes the car, the days and the total, and Count, which returns the size of the list. Book now hands the booking to the store, and the service's Saved Count asks the store too. Keeping the booking is now the store's concern and nobody else's.

Step 9: Pass the service in Constructor Injection

Each class built its own helper with new, so nothing outside could share the same service. With constructor injection, a form of dependency injection, the handler takes its service in a primary constructor, and the service takes its store the same way. Desk builds all three and passes each one in, and the run still prints the same two lines.

Payoff

Step 10: Add the import again Payoff

Now the partner file gets its own class, Booking Import. Import Line splits the line and calls Book on the service, and that's all it does. Desk builds one service and passes it to both the handler and the import. The run prints the Golf line and then two saved, because the partner booking lands in the same store. The service, the store and the handler didn't change at all to make room for it.

Step 11: One owner for the rule Payoff

The check is here, the discount here, and the save here, each written once, in Booking Service. That's separation of concerns, where each concern lives in its own place, so a change to one doesn't touch the others. The booking rule now has one owner, Booking Service, and every way in only turns its own input into a call to Book. So when the discount moves to five days, you edit this one line, and web and partner bookings get the same price. You may know the class level version as the Single Responsibility Principle, where each class has one reason to change.

Recap

Step 12: Where we started, where we ended Before / after

On the left, one Handle method read the request, held the business rule and kept the booking. A second way in had nothing it could call, so it had to copy those lines. On the right, the partner import turns a line into one call to Book. Book holds the check and the save, Price holds the discount, and the web handler, left out here, calls the same Book.

Step 13: When to reach for it Takeaways

Reach for it when the same rule is needed from more than one way in, like a web request, a file import or a scheduled job. Skip it for a small script with one way in that nobody else calls, because splitting it adds classes to read and nothing to reuse. You'll spot it by a request handler that parses input, holds a business rule and writes storage, all in one method. ASP.NET Core keeps these concerns apart for you, because it injects a registered service into each controller that asks for one.