EN · · 4 min read
Composition Is About Change Boundaries
A concrete C# example of using invariants, policy interfaces, and tests to make change explicit without turning every class into an abstraction.
Technical GuideIntermediateEvergreen
Revised
Historical note: this article is anchored to the shipping-policy exercise added to
learn-dotneton August 15, 2026. The LoC editorial pass was made on August 23. It describes a learning implementation, not a production incident.
Composition is often taught as a diagram: prefer objects containing other objects over classes inheriting from one another. The advice is sound and still incomplete. The important question is not whether one object stores another. It is whether the design puts change behind a boundary that has a clear name, a narrow contract, and tests that protect the behavior around it.
The shipping example in learn-dotnet is deliberately small. That makes the
design pressure easier to see: a shipment owns an invariant, while a shipping
policy owns a calculation that may vary.
Begin with the invariant
The domain object rejects a weight that makes no sense:
public sealed class Shipment
{
public Shipment(decimal weightKg)
{
if (weightKg <= 0)
throw new ArgumentOutOfRangeException(nameof(weightKg));
WeightKg = weightKg;
}
public decimal WeightKg { get; }
}
This validation does not belong in a shipping strategy. Every strategy relies on the same fact: a shipment entering the calculation has a positive weight. Moving the check into each policy would duplicate a rule and allow policies to disagree about what a valid shipment is.
That distinction gives us two responsibilities:
Shipmentprotects what must always be true.- A shipping policy decides how a valid shipment is priced.
Name the axis of change
The implementation gives the variable behavior a small contract:
public interface IShippingPolicy
{
decimal CalculateCost(Shipment shipment);
}
The interface is not valuable merely because it is an interface. It is valuable because “shipping policy” is a coherent reason to change. Standard and express delivery can evolve independently while accepting the same domain input.
public sealed class StandardShippingPolicy : IShippingPolicy
{
public decimal CalculateCost(Shipment shipment) =>
5m + (shipment.WeightKg * 1.2m);
}
public sealed class ExpressShippingPolicy : IShippingPolicy
{
public decimal CalculateCost(Shipment shipment) =>
12m + (shipment.WeightKg * 2.5m);
}
The numbers are exercise data, not pricing guidance. What matters is that each class has one calculation and no knowledge of how the application selected it.
Keep orchestration boring
The service composes the invariant-bearing object with the selected behavior:
public sealed class ShippingService
{
private IShippingPolicy _policy;
public ShippingService(IShippingPolicy policy) =>
_policy = policy ?? throw new ArgumentNullException(nameof(policy));
public decimal Quote(Shipment shipment) =>
_policy.CalculateCost(shipment);
public void ChangePolicy(IShippingPolicy policy) =>
_policy = policy ?? throw new ArgumentNullException(nameof(policy));
}
There is no switch over policy names and no subclass hierarchy for the service. The orchestration remains stable as policies change. A caller can also provide its own implementation without modifying the example assembly, and the tests exercise that extension point explicitly.
This is the Open/Closed Principle in a useful, modest form: one known dimension can be extended without editing the coordinator. It does not mean every future change is magically supported. If shipping later needs currency, destination, or delivery promises, the contract itself may need to evolve.
Tests reveal whether the boundary is real
A composition boundary earns its place when tests can observe its promise. Useful tests cover more than the two arithmetic formulas:
- constructing a shipment with invalid weight fails;
- standard and express policies produce their expected quotes;
- changing the policy changes the result without replacing the service;
- a consumer-defined policy works without changing existing code;
- null policies are rejected at the composition boundary.
The fourth test is particularly informative. A fake “extensible” design often still requires a central enum or switch to recognize the new behavior. Passing a new implementation directly proves that selection and execution are separate.
When composition is too much
An interface for a formula that will never vary adds indirection without buying a change boundary. The strategy shape is justified when at least one of these is true:
- policies change for different customers or contexts;
- each policy needs focused tests or dependencies;
- selection happens at configuration or runtime;
- a consumer legitimately supplies new behavior;
- the alternative is a coordinator accumulating conditional branches.
For two stable branches, a well-named function or switch may be clearer. Design patterns are responses to pressure, not decorations applied in advance.
The broader OOP lesson
OOP is most useful here as a way to distribute responsibility:
- an object guards its own valid state;
- a policy exposes one behavioral capability;
- a service coordinates collaborators;
- tests describe the contract at each boundary.
The goal is not the largest number of classes. The goal is to make the next likely change local, visible, and difficult to misunderstand.