I'll Be Backend
Principles · C#

Single Responsibility: split calculation from formatting

Keep the money math in one class and the text in another.

13 steps4 chapters~5 min at 1.25×#solid#single responsibility#extract class#separation
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–4

Refactor

Steps 5–10

Payoff

Step 11

Recap

Steps 12–13

The code: before and after

Before

record Line(string Item, int Qty, int UnitPrice);

class InvoiceReport(int taxPercent)
{
    public string Render(string customer, List<Line> lines)
    {
        var text = new StringBuilder();
        text.AppendLine($"Invoice for {customer}");
        int subtotal = 0;
        foreach (var line in lines)
        {
            int amount = line.Qty * line.UnitPrice;
            subtotal += amount;
            text.AppendLine($"{line.Item} x{line.Qty}  {amount}");
        }
        int tax = subtotal * taxPercent / 100;
        text.AppendLine($"Subtotal {subtotal}");
        text.AppendLine($"Tax {tax}");
        text.AppendLine($"Total {subtotal + tax}");
        return text.ToString();
    }
}

static class Billing
{
    public static void Main()
    {
        List<Line> lines = [new("Paper", 3, 5), new("Ink", 1, 25)];
        var report = new InvoiceReport(20);
        Console.Write(report.Render("Acme", lines));
    }
}

After

record Line(string Item, int Qty, int UnitPrice)
{
    public int Amount() => Qty * UnitPrice;
}

record Totals(int Subtotal, int Tax, int Total);

class InvoiceReport
{
    public string Render(string customer, List<Line> lines, Totals totals)
    {
        var text = new StringBuilder();
        text.AppendLine($"Invoice for {customer}");
        foreach (var line in lines)
        {
            text.AppendLine($"{line.Item} x{line.Qty}  {line.Amount()}");
        }
        text.AppendLine($"Subtotal {totals.Subtotal}");
        text.AppendLine($"Tax {totals.Tax}");
        text.AppendLine($"Total {totals.Total}");
        return text.ToString();
    }

    public string RenderCsv(string customer, Totals totals) =>
        $"{customer},{totals.Subtotal},{totals.Tax},{totals.Total}";
}

class InvoiceCalculator(int taxPercent)
{
    public Totals Totals(List<Line> lines)
    {
        int subtotal = 0;
        foreach (var line in lines)
        {
            subtotal += line.Amount();
        }
        int tax = subtotal * taxPercent / 100;
        return new Totals(subtotal, tax, subtotal + tax);
    }
}

static class Billing
{
    public static void Main()
    {
        List<Line> lines = [new("Paper", 3, 5), new("Ink", 1, 25)];
        var totals = new InvoiceCalculator(20).Totals(lines);
        var report = new InvoiceReport();
        Console.Write(report.Render("Acme", lines, totals));
        Console.WriteLine(report.RenderCsv("Acme", totals));
    }
}

Transcript, step by step

Problem

Step 1: One invoice report Before

This code prints an invoice for a customer, in whole dollars. A Line is one row of the invoice, with an item, a quantity and a unit price. Invoice Report adds up the rows, applies the tax percent and writes the text, all in one method called Render. Billing is the caller, and it prints Acme's invoice with a total of forty eight dollars. One loop did both jobs and there was only one kind of invoice, so a single method was a fair first choice.

Step 2: The same invoice as CSV Change request

Now the sales team wants the same invoice as one CSV row for their spreadsheet. The quick edit adds Render CSV, and it copies the summing loop and the tax line from Render. Billing prints that row too, so the run adds Acme, forty, eight, forty eight. It compiles, and today both outputs show the same total.

Step 3: Two jobs in one method The problem

Render does two jobs, because it calculates the money and it formats the text. Calculation is the subtotal, tax and total math, and formatting is turning those numbers into text. You can see Render calculate here, in the loop, and here, at the tax, then format here, and Render CSV calculate again here and here. So the tax rule now lives twice, in two methods that each work it out on their own. When the finance team says round tax up, both methods change, and the copy you miss sends the sales team a total that disagrees with the printed invoice.

Step 4: Two reasons to change Principle

Robert Martin calls this the Single Responsibility Principle, and it says a class should have one reason to change. A reason to change is a rule, or a group of people, that can ask for an edit. The finance team owns the tax rule, so they can ask you to change these two lines. The customer reads the printed invoice and the sales team reads the CSV, so they own the layout. Invoice Report answers to both groups, so it has two reasons to change.

Refactor

Step 5: Refactor first Back it out

So the CSV comes out again, and we refactor first. We'll split the math from the text in small moves, and then the CSV comes back as one short method.

Step 6: One loop per job Split Loop

The first move is Split Loop, which gives each job its own loop. The first loop only sums the subtotal, and the second only appends a row for each line. The line amount is now worked out in both loops, so the next move fixes that.

Step 7: A line knows its amount Move Method

Move Method puts the line amount into Line, as a method called Amount. A row already holds its quantity and unit price, so it can multiply them itself. Both loops now ask the line for its amount instead of doing the math.

Step 8: The math gets a name Extract Method

Extract Method moves the summing loop and the tax line into a private method called Totals. It returns a small record, also called Totals, that holds the subtotal, the tax and the total. Render calls Totals once and reads the three numbers back. So Render has no math left except that one call.

Step 9: A class for the money Extract Class

Extract Class moves the tax percent and the Totals method into a new class, Invoice Calculator. Invoice Report keeps a calculator, built from the tax percent its constructor still takes. Render asks the calculator for the totals, and Billing doesn't change.

Step 10: Hand Render the totals Change Function Declaration

Change Function Declaration gives Render the totals as a parameter. So Invoice Report loses its constructor parameter and the calculator field. Billing now works out the totals with an Invoice Calculator, then hands them to the report. Invoice Report no longer knows a tax rule exists, because it only lays out the numbers it's given.

Payoff

Step 11: The CSV, again Payoff

Now the sales team's CSV goes back in. Render CSV is one line, and Billing prints its row. Invoice Calculator and Render did not change. Render CSV has no loop and no tax math, because it reads the subtotal here and the tax here. A new tax rule edits Invoice Calculator only, and a new layout edits Invoice Report only. Both layouts print the same total, because the math lives in one place.

Recap

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

On the left, Render summed the rows, worked out the tax and wrote the text, all in one method. Any second output had to copy that math. On the right, Invoice Calculator owns the totals and the tax rule. Invoice Report turns those totals into text, and the CSV is one line.

Step 13: When to reach for it Takeaways

Use it when one method computes values and turns them into text, and a second output needs the same values. Skip it when a one-off script prints one report once, because the extra class and record add reading with no second caller. You'll spot it by arithmetic between string appends, and by a second output method that copies the math. In C sharp, To String with the currency format does only the formatting half, so it turns forty eight into forty eight dollars and never works out the total.