Three change requests reach a pricing routine in the same week: the Gold discount rises from 7% to 8%, shipping becomes free for Gold customers, and finance wants midpoints rounded to even. How many places in the code each one touches was settled earlier, when somebody chose where to cut the program.

Structured control flow, procedures, modules, information hiding, coupling and cohesion, and separation of concerns are different ideas, published by different people between 1968 and 1979, that answer different questions. The one that says where a module boundary goes is Parnas's, and it is a bet on which decisions will change.

This part takes from Part 1 the structured procedure and its named functions, Stages 01 and 02 of the series' example, and does not repeat how they came about.

Ideas that are usually told as one

A common summary runs: structured programming gave us modules, and modules gave us encapsulation. The record does not support it.

YearWhoWhat, in the original termsSource
March 1968E. W. Dijkstra; title by the editor, Niklaus WirthLetter printed as "go to statement considered harmful", submitted as "A case against the goto statement"Communications of the ACM 11(3); EWD1308
April 1970E. W. Dijkstra"Notes on Structured Programming", EWD249, T.H.-Report 70-WSK-03, second editionScan of EWD249
April 1971Niklaus Wirth"Program development by stepwise refinement"Communications of the ACM 14(4)
August 1971D. L. ParnasTechnical report, "On the criteria to be used in decomposing systems into modules"Carnegie-Mellon University report
December 1972D. L. ParnasThe same paper in print; the criterion is named "information hiding"Communications of the ACM 15(12)
1974W. P. Stevens, G. J. Myers, L. L. Constantine"Structured design": "coupling" and "cohesion"IBM Systems Journal; IEEE Computer Society
30 August 1974E. W. Dijkstra"On the role of scientific thought", EWD447: "the separation of concerns"EWD447
1975, January 1977Niklaus WirthModula designed and implemented; "Modula: A language for modular multiprogramming" publishedWirth, "Modula-2 and Oberon"
1979Niklaus WirthThe defining report of Modula-2The same paper by Wirth

Parnas's report was circulating before the book "Structured Programming" was printed in 1972, so information hiding is not a later refinement of it. Coupling and cohesion come from a third source and, by the account cited below, are older than every row above. These ideas grew side by side, and each is still in use for the question it answers.

Control flow and procedures: structure inside one routine

Structure is designed, not translated

Part 1 covers the 1968 letter, whose title, as the table records, was the editor's and not Dijkstra's. In the manuscript, having noted that the go to statement appeared to have been proved logically superfluous, Dijkstra adds: "The exercise to translate an arbitrary flow diagram more or less mechanically into a jumpless one, however, is not to be recommended."

That is a statement about design. The value comes from choosing a shape in which the order of the text is the order of execution, as in Stage 01 of the example: one long method using only sequence, selection and iteration.

The earliest printed use of the term "structured programming" that this article can cite is the title of Dijkstra's Notes, issued as a report of the Technological University Eindhoven. The scanned title page reads "T.H.-Report 70-WSK-03 / Second edition April 1970". The first edition is usually dated 1969; that page does not carry the date, so treat 1969 as unconfirmed. The Notes were then published as pages 1 to 82 of "Structured Programming" by Dahl, Dijkstra and Hoare (Academic Press, London, 1972).

Refinement produces procedures

Niklaus Wirth put the phrase "stepwise refinement" into the title of a paper in Communications of the ACM in April 1971. As the technique is usually taught, you state the task in one sentence, restate it as a few smaller tasks, and repeat until each can be written down directly. Every level becomes a named procedure. Stage 02 is what that produces for the pricing rules:

C#
public static PriceBreakdown Price(OrderData order, decimal taxRate = 0.23m)
{
    Validate(order, taxRate);

    decimal subtotal = Subtotal(order);
    decimal volume = VolumeDiscount(subtotal);
    decimal loyalty = LoyaltyDiscount(order.Tier, subtotal);
    decimal coupon = CouponDiscount(order.CouponCode, order.IsFirstOrder, subtotal);
    decimal discount = CapDiscount(volume + loyalty + coupon, subtotal);
    decimal shipping = Shipping(subtotal - discount);
    decimal tax = Tax(subtotal - discount + shipping, taxRate);
    decimal total = RoundMoney(subtotal - discount + shipping + tax);

    return new PriceBreakdown(
        subtotal,
        [new("volume", volume), new("loyalty", loyalty), new("coupon", coupon)],
        discount, shipping, tax, total);
}

Each line is one of the pricing rules, in the order the rules apply. Below it, each name is refined once more:

C#
private static decimal Subtotal(OrderData order) =>
    order.Lines.Sum(line => line.UnitPrice * line.Quantity);

private static decimal VolumeDiscount(decimal subtotal)
{
    if (subtotal >= 5000m) return RoundMoney(subtotal * 0.10m);
    if (subtotal >= 1000m) return RoundMoney(subtotal * 0.05m);
    return 0m;
}

private static decimal LoyaltyDiscount(CustomerTier tier, decimal subtotal) => tier switch
{
    CustomerTier.Standard => 0m,
    CustomerTier.Silver => RoundMoney(subtotal * 0.03m),
    CustomerTier.Gold => RoundMoney(subtotal * 0.07m),
    _ => throw new ArgumentOutOfRangeException(nameof(tier), "Unknown customer tier."),
};

private static decimal CouponDiscount(string? couponCode, bool isFirstOrder, decimal subtotal)
{
    if (string.IsNullOrEmpty(couponCode)) return 0m;
    if (couponCode != "WELCOME10") throw new ArgumentException("Unknown coupon.", nameof(couponCode));
    return isFirstOrder ? RoundMoney(subtotal * 0.10m) : 0m;
}

private static decimal CapDiscount(decimal discount, decimal subtotal) =>
    Math.Min(discount, RoundMoney(subtotal * 0.20m));

This is good code. It is also the limit of what a procedure can do: it hides its local variables and nothing else. The literal 0.10m appears twice with two meanings, the large-volume rate and the coupon rate, and the top routine names all three discounts, so it knows how many there are.

What a module adds to a procedure

A module is a named group of procedures and data with a line drawn through it: some names are visible outside, the rest are not. The visible part is its interface. What that adds to a procedure is a place to keep something that several procedures share and nobody else may see, such as a table of rates, behind a boundary the compiler enforces.

Wirth's languages show the two ideas arriving separately. In his own account of 2007: "Pascal (1970) reflected the ideas of Structured Programming, Modula-2 (1979) added those of modular system design". Between the two he "designed and implemented the predecessor language Modula" in 1975; its paper, "Modula: A language for modular multiprogramming", appeared in Software: Practice and Experience in January 1977. Modula-2 is often dated 1978; Wirth's paper gives 1979 for the defining report.

C# has no module keyword. The nearest construct for stateless code is a static class with private members, and that is what the example uses. By that reading, Stage 02 is already a module: the class OrderPricing shows one function, Price, and hides everything else. A language can give you modules; it cannot tell you what to put in each. That question had an answer before Modula was designed.

Parnas's criterion: hide the decision that is likely to change

David Parnas, then at Carnegie-Mellon University, circulated "On the criteria to be used in decomposing systems into modules" as a technical report in August 1971. It was printed in Communications of the ACM in December 1972.

The paper takes one small system, a program that produces a KWIC (Key Word In Context) index, and decomposes it twice. In the first modularization each major step of the processing is a module. Parnas's description of how one arrives there is short: "One might say that to get the first decomposition one makes a flowchart." In our reading, it is the decomposition that stepwise refinement of the processing produces if nothing else guides the cut.

The second modularization is made on another basis: "Every module in the second decomposition is characterized by its knowledge of a design decision which it hides from all others." How lines are stored, for example, becomes a module of its own, used by almost every action in the system, instead of a fact that every step knows.

The criterion: a module boundary goes around a design decision, especially one that is likely to change, and the interface is whatever lets the rest of the system work without knowing how the decision was taken. Parnas names the criterion "information hiding", in quotation marks, and cites a report of his own from 1971 for it. Whether those two words already appear in the 1971 text is not established here; the 1972 use is.

Two corrections follow from the paper itself. The word "encapsulation" does not occur in it, and who first applied that word to objects is not established here. And the criterion is not to split the program into smaller pieces: both of Parnas's decompositions have pieces. They differ in what each piece knows.

The same rules as modules

Stage 03 applies the criterion to the pricing rules. The orchestrator is the counterpart of Stage 02's Price:

C#
// The only code that knows the order of the steps. It knows none of the numbers.
public static class OrderPricing
{
    public static PriceBreakdown Price(OrderData order, decimal taxRate = 0.23m)
    {
        decimal subtotal = Subtotals.Of(order);
        IReadOnlyList<DiscountLine> discounts = Discounts.For(order, subtotal);
        decimal discount = Discounts.Capped(discounts, subtotal);
        decimal shipping = Shipping.For(subtotal - discount);
        decimal tax = Tax.On(subtotal - discount + shipping, taxRate);
        decimal total = Rounding.Money(subtotal - discount + shipping + tax);

        return new PriceBreakdown(subtotal, discounts, discount, shipping, tax, total);
    }
}

The discount rules are now behind two functions:

C#
// Hides: which discounts exist, their rates and thresholds, and the cap.
public static class Discounts
{
    private const decimal SmallVolumeFrom = 1000m, SmallVolumeRate = 0.05m;
    private const decimal LargeVolumeFrom = 5000m, LargeVolumeRate = 0.10m;
    private const decimal SilverRate = 0.03m, GoldRate = 0.07m;
    private const string WelcomeCoupon = "WELCOME10";
    private const decimal WelcomeRate = 0.10m;
    private const decimal CapRate = 0.20m;

    public static IReadOnlyList<DiscountLine> For(OrderData order, decimal subtotal) =>
    [
        new("volume", Rounding.Money(subtotal * VolumeRate(subtotal))),
        new("loyalty", Rounding.Money(subtotal * LoyaltyRate(order.Tier))),
        new("coupon", Rounding.Money(subtotal * CouponRate(order.CouponCode, order.IsFirstOrder))),
    ];

    public static decimal Capped(IReadOnlyList<DiscountLine> discounts, decimal subtotal) =>
        Math.Min(discounts.Sum(discount => discount.Amount), Rounding.Money(subtotal * CapRate));

    private static decimal VolumeRate(decimal subtotal) =>
        subtotal >= LargeVolumeFrom ? LargeVolumeRate
        : subtotal >= SmallVolumeFrom ? SmallVolumeRate
        : 0m;

    private static decimal LoyaltyRate(CustomerTier tier) => tier switch
    {
        CustomerTier.Standard => 0m,
        CustomerTier.Silver => SilverRate,
        CustomerTier.Gold => GoldRate,
        _ => throw new ArgumentOutOfRangeException(nameof(tier), "Unknown customer tier."),
    };

    private static decimal CouponRate(string? couponCode, bool isFirstOrder)
    {
        if (string.IsNullOrEmpty(couponCode)) return 0m;
        if (couponCode != WelcomeCoupon) throw new ArgumentException("Unknown coupon.", nameof(couponCode));
        return isFirstOrder ? WelcomeRate : 0m;
    }
}

Three things changed between the two stages.

  1. Every literal has a name and lives in the module that owns it. 0.10m is now LargeVolumeRate in one place and WelcomeRate in another.
  2. The list of discounts moved. Stage 02's Price computes volume, loyalty and coupon by name and builds the result from them. Stage 03's Price receives a list from Discounts.For and passes it on, so it does not know how many discounts exist.
  3. Validation moved to the module that owns the knowledge. Stage 02 has a Validate function for the lines and the tax rate, called first. In Stage 03 Subtotals.Of rejects an invalid line and Tax.On rejects a rate outside 0 to 1; both are in src/Pricing/Stage03Modules/Modules.cs.

The arithmetic, the order of the steps, the places where amounts are rounded, the input and output records and the default tax rate did not change.

Shipping and rounding show the same move at its smallest. In Stage 02:

C#
private static decimal Shipping(decimal subtotalAfterDiscount) =>
    subtotalAfterDiscount >= 200m ? 0m : 7.50m;

private static decimal Tax(decimal taxable, decimal taxRate) =>
    RoundMoney(taxable * taxRate);

private static decimal RoundMoney(decimal amount) =>
    Math.Round(amount, 2, MidpointRounding.AwayFromZero);

In Stage 03:

C#
// Hides: what shipping costs and when it is free.
public static class Shipping
{
    private const decimal Fee = 7.50m;
    private const decimal FreeFrom = 200m;

    public static decimal For(decimal subtotalAfterDiscount) =>
        subtotalAfterDiscount >= FreeFrom ? 0m : Fee;
}
C#
// Hides: how a money amount is rounded.
public static class Rounding
{
    public static decimal Money(decimal amount) =>
        Math.Round(amount, 2, MidpointRounding.AwayFromZero);
}

The dependencies between the six types are few, and all of them point one way:

Text
OrderPricing.Price
  |-- Subtotals.Of
  |-- Discounts.For, Discounts.Capped ----> Rounding.Money
  |-- Shipping.For
  |-- Tax.On -----------------------------> Rounding.Money
  `-- Rounding.Money

Subtotals.Of, Discounts.For:   take OrderData (Contracts.cs)
Shipping, Tax, Rounding:       take decimal amounts only

Rounding is the module that is not a step of the calculation. Three others use it, which makes it this example's counterpart of the line storage in Parnas's paper. The other modules are close to the steps of a flowchart: when each step of a calculation owns its own numbers, the two decompositions nearly coincide.

The proof that behaviour is the same

tests/Pricing.Tests/StageEquivalenceTests.cs holds the theory Every_stage_prices_every_order_in_the_table_identically. It runs one table of 22 orders, each with a result computed by hand, against every stage, and compares every field of the result. tests/Pricing.Tests/InvalidInputTests.cs holds Every_stage_rejects_invalid_input, which gives each stage nine invalid inputs and requires an ArgumentException or a class derived from it.

The host prints the same three sample orders through both stages:

Text
Stage 02 Named functions       | small  | subtotal    99.90 | discount     0.00 | shipping 7.50 | tax    24.70 | total   132.10
Stage 02 Named functions       | mixed  | subtotal  2000.00 | discount   360.00 | shipping 0.00 | tax   377.20 | total  2017.20
Stage 02 Named functions       | capped | subtotal  6000.00 | discount  1200.00 | shipping 0.00 | tax  1104.00 | total  5904.00
Stage 03 Modules               | small  | subtotal    99.90 | discount     0.00 | shipping 7.50 | tax    24.70 | total   132.10
Stage 03 Modules               | mixed  | subtotal  2000.00 | discount   360.00 | shipping 0.00 | tax   377.20 | total  2017.20
Stage 03 Modules               | capped | subtotal  6000.00 | discount  1200.00 | shipping 0.00 | tax  1104.00 | total  5904.00

The tests do not prove that the two stages reject in the same way. Each invalid input has exactly one fault, and the assertion is on the exception's type. Stage 02 checks the tax rate before it computes anything; Stage 03 checks it last, inside Tax.On. An order with an unknown tier and a bad rate is rejected for the rate by Stage 02 and for the tier by Stage 03. Even for a bad rate alone, the parameter named in the exception is taxRate in one and rate in the other. No test here would notice.

The criterion applied to each module

The criterion can be run on paper. For each module, name the decision it hides, then name a change and ask who else has to be edited.

ModuleThe secret it keepsA change that stays insideA change that escapes, and who else changes
RoundingTwo decimals, midpoints away from zeroRound midpoints to evenRound only the final total: Discounts, Tax and OrderPricing each decide when to call it
SubtotalsWhat a valid line is, how lines add upReject a quantity above a limitHold prices as whole cents: OrderLineData and every function that takes a decimal
DiscountsWhich discounts exist, their rates and thresholds, the coupon code, the capGold from 7% to 8%; a fourth discount; a cap of 25%A discount applied after tax: OrderPricing owns the order of the steps
ShippingThe fee and the amount from which it is waivedA fee of 9.00; free from 250.00Free shipping for Gold customers: the signature of Shipping.For, and OrderPricing
TaxWhich rates are valid, how tax follows from the taxable amountAccept a narrower range of ratesTax on goods only, not on shipping: OrderPricing assembles the taxable amount
OrderPricingThe order of the steps and what each step is givenTax on goods onlyAny new fact a step needs passes through it

Take the three requests from the opening. The Gold rate is one constant, GoldRate, in Discounts. Rounding to even is one argument in Rounding.Money. The four midpoint rows of the test table then fail, which is correct: the request changes behaviour, and those rows were written so that the two rounding modes give different answers.

Free shipping for Gold customers is the change Stage 03 does not localise. Shipping.For receives one number, the subtotal after discount. That signature is itself information: it tells every caller that shipping depends on one amount and nothing else. The module hid the fee and the threshold, the decisions its author expected to change, and published the shape of the rule, which is what changed. Shipping.For now needs the tier, its signature changes, and OrderPricing changes with it.

Two more cases are in the table. When amounts are rounded has no owner: Rounding hides how, and three other types decide when. And every module knows the shape of OrderData and that money is a decimal. The data is public and passive, checked by whichever function happens to read it, and nothing stops a caller from passing Discounts.For a subtotal that never went through Subtotals.Of.

Coupling, cohesion and separation of concerns measure different things

Parnas's paper says where to cut. Two other vocabularies of the same years describe the result.

Coupling and cohesion. "Structured design", by W. P. Stevens, G. J. Myers and L. L. Constantine, appeared in the IBM Systems Journal in 1974 and gave the two terms their first widely accessible explanation. The ideas are older, and they are Constantine's. The IEEE Computer Society's biography puts it this way: "The concept of coupling and cohesion developed by Constantine in the mid-1960s was first published in 1968 and repeated in the 1974 publication." The 1968 publication is not identified there.

In the usual reading, coupling is how much one module must know about another, and cohesion is how closely the contents of one module belong together. In Stage 03, Shipping is cohesive: a fee, a threshold, one function. Discounts holds three discount rules and a cap. In our judgement that is one subject, what an order earns; a team in which marketing owns the coupon and finance the cap would see two. The coupling is low and visible in the diagram: every dependency is a function call that passes values.

Separation of concerns. The phrase is recorded in Dijkstra's essay "On the role of scientific thought", EWD447, dated 30 August 1974. He does not announce it as new. In a passage quoted from a letter of his own he says it is what he has sometimes called "the separation of concerns", and that it, "even if not perfectly possible, is yet the only available technique for effective ordering of one's thoughts". It is advice about thinking before it is a property of code.

These three do not always agree, and Stage 03 shows it. Treat validation as a concern and Stage 02 separates it better: there is a function called Validate, and a reader who wants to know what an order must satisfy starts there. Stage 03 spreads it over three modules, because information hiding puts the knowledge of what a valid tax rate is beside the tax calculation. Neither is a mistake. They answer different questions, and a review that uses the words as synonyms cannot discuss the difference.

What the modules cost, and when named functions are enough

Stage 02 is one class in a file of 92 lines with one public function. Stage 03 is six classes in a file of 126 lines with seven public functions. The example keeps the six in one file; in a working codebase each would normally be a file of its own.

Indirection and a wider surface. To follow one order through Stage 03 you visit five modules. And where Stage 02 promises its callers one thing, Price, every public function of Stage 03 is a promise somebody may come to depend on. The Gold request shows the cost: a signature that was cheap to publish is expensive to change.

The wrong secret. Information hiding is a forecast. Shipping forecast that the numbers would move and the inputs would not, and a wrong forecast leaves a boundary that the change has to cross.

Now count the three opening requests against Stage 02. The Gold rate is one literal in LoyaltyDiscount. Rounding to even is one argument in RoundMoney. Free shipping for Gold touches Shipping and Price, two functions in one class. Stage 02 handles all three in the same number of edits as Stage 03, in a file about a quarter shorter.

So for these rules, at this size, with one team, we would stay with Stage 02, and give the literals names. Not every literal is a design failure and not every extra boundary is an improvement. The modules start to pay under conditions this example does not have: when the discount table and the shipping rule belong to different people and change on different schedules, or when another part of the system needs the shipping rule alone. What Stage 03 gains here is narrow: named rates gathered in one place, and a pricing routine that no longer counts the discounts.

The change Stage 03 could not contain, the data that every module reads and none protects, is where Part 3 starts: it puts data and the behaviour that keeps it valid in one type, and looks at what objects promised to add to that.

What to take from this

  • Before splitting a class into modules, write the table: module, the decision it hides, a change that would stay inside, a change that would not. If the first column is easy and the second is empty, you have a flowchart, not a design.
  • Read a function's parameter list as published information. A narrow signature hides the numbers and reveals the shape of the rule; decide which of the two is more likely to change.
  • When a check moves into the module that owns the knowledge, test inputs with two faults: results can stay identical while rejections change.
  • Keep the single module with named functions until a second owner, a second caller or a second schedule of change appears.

Run the code

The example is one download, software-foundations-example.zip: unpack it and run the commands in its foundations folder. It was built and its tests were run with the .NET 10 SDK on 13 November 2026, and it needs that SDK. .NET 10 is a long-term support release; .NET 11 was released on 10 November 2026 and the example does not need it.

  • dotnet test runs the tests.
  • dotnet run --project src/Pricing.Host prints the output.

The tests that cover what this article showed:

  • StageEquivalenceTests.Every_stage_prices_every_order_in_the_table_identically: both stages against the 22 orders of OrderCases.cs.
  • InvalidInputTests.Every_stage_rejects_invalid_input and InvalidInputTests.Every_stage_accepts_the_boundary_values_that_are_valid: rule 10 and its valid edges, by exception type only.
  • HostOutputTests.The_host_prints_exactly_the_committed_expected_output: the output quoted above.

./counterproof.sh changes one rule at a time in a copy of the solution and requires the tests to fail. Two of its changes fall in the code shown here: the discount cap raised from 20% to 25% in Stage 03's Discounts, and the coupon no longer requiring a first order in Stage 02's CouponDiscount.

References

Tags#software-foundations#software-design#engineering#dotnet#development#havecode

The Foundations of Software Engineering

From the origins of programming to modern architecture. 8 parts, read in order.

  1. 01
    Before Design Patterns: How Software Engineering Learned to Manage Complexity

    From Lovelace's Notes of 1843 to Brooks's essay of 1986, each advance in programming removed a difficulty of the tools and left the difficulty of the problem. One pricing routine, written three ways with identical output, shows what structure and names buy and what they do not.

  2. 02
    Structured Programming, Modularity and Information Hiding

    You are here.

  3. 03
    Object-Oriented Programming: Origins, Promises and Trade-offs

    Object orientation is several mechanisms with separate origins: Simula's subclasses, abstract data types, Smalltalk's messaging. One pricing example shows what encapsulation and an interface buy, what they cost, and when functions over plain data stay the better design.

  4. 04
    The Gang of Four: 23 Design Patterns and the Problems They Solve

    The 23 patterns of the 1994 book, each as a problem, a shape, a cost and its modern C# form, in one pricing example. Most are a single move, an interface placed where something varies, and the example needed only one of them.

  5. 05
    SOLID, Coupling, Cohesion and Dependency Inversion

    Coupling and cohesion are the criteria; the five SOLID principles are later heuristics with separate origins in Meyer, Liskov and Martin. One pricing calculator shows what each buys and costs, and why dependency inversion, dependency injection and a container are three different things.

  6. 06
    Refactoring: Improving Design Without Changing Behaviour

    Refactoring changes structure against a recorded definition of behaviour, in steps small enough to check. One legacy pricing method is pinned by characterisation tests, reshaped in seven steps, and the tests themselves are proved able to fail.

  7. 07
    From Design Patterns to Domain-Driven Design and Modern Architecture

    Domain-driven design, ports and adapters, the onion and the clean architecture share one rule: the business rules refer to nothing else. The pricing example gains a domain model and a tested boundary, and needs 22 types where one class did.

  8. 08
    Do We Still Need Gang of Four Patterns in Modern C# and .NET?

    All 23 patterns set against C# 14 and .NET 10: eleven have a shorter form in the example, built from a language or platform feature, and twelve do not. The problems remain in every case, and each modern form has a failure of its own.

Support independent engineering knowledge

Knowledge, Analysis, the Field Guide and the tools are free to read and use, and they stay that way. If this article saved you time, you can support the research and writing behind it. Support is voluntary and unlocks nothing.

Buy me a coffee (opens in a new tab)João Lívio's page on buymeacoffee.com

Comments