Lesser-Known Principles: Separation of Concerns, Least Astonishment, Fail Fast, CQS and GRASP
The principles outside SOLID drive most everyday decisions: Fail Fast catches errors at startup, CQS keeps a question from changing the answer, and GRASP asks which class a responsibility belongs to. Catching a configuration typo with options validation, the Math.Round surprise, side effects in getters, Explicit Dependencies, Persistence Ignorance and deciding when principles collide.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
In the Bookstore, the shipping fee comes from configuration: shipping is free on carts of 50 EUR and above, 4.90 EUR below that. During a deploy someone edits the key in appsettings.json by hand and drops a letter. The application starts without a problem:
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
builder.Logging.ClearProviders();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
// The typo in appsettings.json: "Treshold"
["Shipping:FreeShippingTreshold"] = "50",
["Shipping:FlatRate"] = "4.90"
});
builder.Services.Configure<ShippingOptions>(builder.Configuration.GetSection("Shipping"));
using IHost host = builder.Build();
await host.StartAsync();
ShippingOptions options = host.Services.GetRequiredService<IOptions<ShippingOptions>>().Value;
Console.WriteLine($"Shipping for a 35.00 cart: {options.CostFor(35m):0.00}");
public sealed class ShippingOptions
{
[Range(1, 10_000)]
public decimal FreeShippingThreshold { get; set; }
[Range(0.01, 100)]
public decimal FlatRate { get; set; }
public decimal CostFor(decimal subtotal) => subtotal >= FreeShippingThreshold ? 0m : FlatRate;
}
What is the shipping fee for a 35 EUR cart? When, and how, does the application tell you?
Fail Fast: Catching the Error at Its Source
The output:
Shipping for a 35.00 cart: 0.00
The application never tells you. The FreeShippingTreshold key was silently ignored because the class has no matching property, FreeShippingThreshold stayed at 0, the default for decimal, and every cart counted as "above 50". No exception, no log. The problem will surface in accounting when the carrier's invoice arrives at the end of the month; until then nobody knows how many orders shipped for free.
In his 2004 IEEE (Institute of Electrical and Electronics Engineers) Software column "Fail Fast", Jim Shore (author of The Art of Agile Development) describes this as failing slowly: the software works around a problem and keeps going, and the failure shows up much later, far from its source, in a strange form. His advice is the opposite: if something is wrong, stop immediately, loudly, and at the place where the problem is. Options validation exists for exactly this:
builder.Services.AddOptions<ShippingOptions>()
.Bind(builder.Configuration.GetSection("Shipping"))
.ValidateDataAnnotations()
.ValidateOnStart(); // fail at startup, not at the first order
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'ShippingOptions' members: 'FreeShippingThreshold' with the error: 'The field FreeShippingThreshold must be between 1 and 10000.'.
The [Range] attribute was already on the class, but nothing was running it. ValidateDataAnnotations wires up the check and ValidateOnStart runs it while the host starts; the application never begins taking traffic with a bad configuration. The message shows the symptom but not the root cause: the value is 0 because the key is misspelled. Tell the binder to reject unknown keys and the error points straight at the source:
builder.Services.AddOptions<ShippingOptions>()
.Bind(builder.Configuration.GetSection("Shipping"), binder => binder.ErrorOnUnknownConfiguration = true)
.ValidateDataAnnotations()
.ValidateOnStart();
Unhandled exception. System.InvalidOperationException: 'ErrorOnUnknownConfiguration' was set on the provided BinderOptions, but the following properties were not found on the instance of ShippingOptions: 'FreeShippingTreshold'
Fail fast is not only about configuration. Guard clauses in constructors (ArgumentException.ThrowIfNullOrWhiteSpace(isbn), ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity)), ValidateOnBuild in the DI (dependency injection) container (it caught a missing registration at startup in the previous DIP (Dependency Inversion Principle) post), the compile-time warnings of nullable reference types: all of them try to catch an error as close as possible to where and when it happens. The "Configuration and the Options Pattern" post in the ASP.NET Core Web API (Application Programming Interface) series goes into the details of options validation.
Separation of Concerns
The term comes from Edsger Dijkstra's (Dutch computer scientist and a pioneer of structured programming) 1974 note "On the role of scientific thought". What Dijkstra meant was a discipline of thinking: while dealing with one aspect of a problem, deliberately set the others aside. Postpone efficiency while thinking about correctness, and usability while thinking about efficiency. In software design it came to mean putting different kinds of work in different places. The example on the "Architectural principles" page on Microsoft Learn is just that: code that decides which products to highlight and code that decides how to format them should stay apart, because the two are only coincidentally related.
SoC (Separation of Concerns) and SRP (Single Responsibility Principle) are often confused. SoC looks at the kind of work: presentation, business rules, data access, security, logging. SRP looks at who changes the code and why. ASP.NET Core's middleware pipeline is a good example of SoC: exception handling, authentication, routing and rate limiting are each a separate middleware, and you see none of them inside a controller. The pipeline itself is covered in the middleware post of the ASP.NET Core Web API series.
The Principle of Least Astonishment
An API should behave the way the person using it expects; a surprise is a cost. The principle has roots in user interface and programming language design, but it pays off most in API design. A well-known surprise from .NET itself:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine(Math.Round(2.5m));
Console.WriteLine(Math.Round(3.5m));
Console.WriteLine(Math.Round(0.125m, 2));
Console.WriteLine(Math.Round(2.5m, MidpointRounding.AwayFromZero));
2
4
0.12
3
By the rule you learned at school, 2.5 rounds up to 3. Math.Round, however, uses MidpointRounding.ToEven by default, rounding a value exactly in the middle to the nearest even number: 2.5 → 2, 3.5 → 4. It is a deliberate choice that keeps rounding errors from accumulating statistically, but seeing 0.125 EUR as 0.12 on an invoice surprises an accountant. That is why in the Bookstore we always state MidpointRounding explicitly when rounding money.
In your own APIs the most common sources of surprise are: methods named like queries that have side effects (a GetCustomer that creates the customer if missing), different names for the same concept (Remove in one place, Delete in another), behavior that goes against platform conventions (throwing from something named TryParse, running synchronously with an Async suffix), and silently swallowed errors. The last one is the exact opposite of fail fast.
Command-Query Separation
The rule comes from Bertrand Meyer's (designer of the Eiffel language) 1988 book Object-Oriented Software Construction: a method either returns something (a query) or changes state (a command), not both. Asking a question should not change the answer. A violation looks like this:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Cart cart = new();
cart.Add(12.99m);
cart.Add(22.99m);
cart.ApplyCoupon("WELCOME10");
Console.WriteLine($"Header total: {cart.Total}");
Console.WriteLine($"Checkout total: {cart.Total}");
public sealed class Cart
{
private readonly List<decimal> _prices = [];
private string? _coupon;
public void Add(decimal price) => _prices.Add(price);
public void ApplyCoupon(string code) => _coupon = code;
// Looks like a query, but marks the coupon as "used"
public decimal Total
{
get
{
decimal total = _prices.Sum();
if (_coupon == "WELCOME10")
{
total -= 10m;
_coupon = null;
}
return total;
}
}
}
Header total: 25.98
Checkout total: 35.98
The page header read the total once and consumed the coupon; the payment page read the same property and saw the undiscounted amount. More insidiously, Visual Studio's debugger runs the getter when it evaluates properties in the watch window; debugging changes the bug. The version that follows CQS (Command-Query Separation):
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Cart cart = new();
cart.Add(12.99m);
cart.Add(22.99m);
cart.ApplyCoupon("WELCOME10");
Console.WriteLine($"Header total: {cart.Total}");
Console.WriteLine($"Checkout total: {cart.Total}");
decimal charged = cart.Checkout();
Console.WriteLine($"Charged: {charged}, coupon left: {cart.Coupon ?? "none"}");
public sealed class Cart
{
private readonly List<decimal> _prices = [];
public string? Coupon { get; private set; }
// Query: same answer however often it is called, never touches state
public decimal Total => _prices.Sum() - (Coupon == "WELCOME10" ? 10m : 0m);
// Commands: change state
public void Add(decimal price) => _prices.Add(price);
public void ApplyCoupon(string code) => Coupon = code;
public decimal Checkout()
{
decimal total = Total;
Coupon = null;
return total;
}
}
Header total: 25.98
Checkout total: 25.98
Charged: 25.98, coupon left: none
Total gives the same answer no matter how many times it is read. Checkout, though, both changes state and returns the amount; under Meyer's strict reading that is a violation too. In his 2005 bliki (the blog-and-wiki mix of short articles on his site) entry Martin Fowler describes the same tension with a stack's pop: he follows the principle wherever he can, but he will not give up a useful pop. In .NET, Stack<T>.Pop, Queue<T>.Dequeue, HashSet<T>.Add returning a bool, and ConcurrentDictionary.GetOrAdd are deliberate exceptions. The last one shows an important reason: in concurrent code, if "ask first, then act" are two separate calls, another thread can slip in between; the query and the change need to be one atomic operation.
Don't confuse CQS with CQRS (Command Query Responsibility Segregation). CQS is a rule at the level of a single method; CQRS is an architectural approach that uses separate models, even separate data stores, for reading and writing. CQRS is the subject of the upcoming "CQRS and Event Sourcing" series.
Explicit Dependencies and Persistence Ignorance
The "Architectural principles" page on Microsoft Learn lists two more principles. The Explicit Dependencies Principle says that methods and classes should explicitly require every collaborator they need to work correctly. A constructor is a contract; if it says "just give me these" but reaches for a static, a global configuration or a service locator at run time, it is not honest with its clients. In the previous post we saw with real output how a service locator breaks that honesty and pushes the error into production.
Persistence Ignorance asks that the code of types to be persisted be unaffected by the persistence technology. The same page lists violations: a required base class or interface, classes that save themselves (Active Record), a required parameterless constructor, properties that must be virtual, persistence-specific attributes. Entity Framework Core (EF Core) does quite well here: entities can be POCOs (Plain Old CLR (Common Language Runtime) Objects: plain classes tied to no framework type) and can be bound to constructors with parameters; virtual navigation properties are needed only if you use lazy-loading proxies. Still, putting attributes like [Column] and [MaxLength] on an entity is a small leak; IEntityTypeConfiguration, which we discussed in the EF Core series, is the way to keep that information outside the entity.
GRASP: Who Should Get the Responsibility?
In his book on UML (Unified Modeling Language), Applying UML and Patterns, first published in 1997, Craig Larman frames the really hard question of object-oriented design as "which class does this responsibility belong to?" and offers nine patterns to answer it: GRASP (General Responsibility Assignment Software Patterns). They are not patterns in the GoF (Gang of Four: Gamma, Helm, Johnson and Vlissides's 1994 book Design Patterns) sense but questions to ask while deciding:
| GRASP | Question it asks | In the Bookstore |
|---|---|---|
| Information Expert | Who has the information this job needs? | Order.Total, the lines are in Order |
| Creator | Who should create B? A, if A contains B or uses it closely. | Order.AddItem creates the OrderItem |
| Controller | Who first receives a system event from the user interface (UI)? | PlaceOrderHandler |
| Low Coupling | Which option reduces dependencies? | The handler does not know the concrete gateway |
| High Cohesion | Which option keeps the class focused? | Order does no payment or persistence work |
| Polymorphism | Where does behavior that varies by type go? | Each payment provider has its own Charge |
| Pure Fabrication | Do we need a class with no domain counterpart that cleans up the design? | IOrderRepository |
| Indirection | What goes in between so two things are not wired directly? | IPaymentGateway |
| Protected Variations | What is a likely point of change hidden behind? | The payment provider sits behind an interface |
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
InMemoryOrderRepository repository = new();
PlaceOrderHandler handler = new(repository, new FakePaymentGateway());
Order order = handler.Handle(customerId: 7, [("978-0441013593", 2, 12.99m), ("978-0141439518", 1, 7.50m)]);
Console.WriteLine($"Order {order.Id}: {order.Items.Count} lines, total {order.Total}, saved orders: {repository.Count}");
// Controller: the first object to receive the "place order" system event and hand out the work
public sealed class PlaceOrderHandler(IOrderRepository orders, IPaymentGateway payments)
{
public Order Handle(int customerId, IEnumerable<(string Isbn, int Quantity, decimal UnitPrice)> lines)
{
Order order = new(orders.NextId(), customerId);
foreach ((string isbn, int quantity, decimal unitPrice) in lines)
order.AddItem(isbn, quantity, unitPrice);
payments.Charge(customerId, order.Total);
orders.Add(order);
return order;
}
}
public sealed class Order(int id, int customerId)
{
private readonly List<OrderItem> _items = [];
public int Id { get; } = id;
public int CustomerId { get; } = customerId;
public IReadOnlyList<OrderItem> Items => _items;
// Information Expert: Order has the information needed for the total
public decimal Total => _items.Sum(i => i.LineTotal);
// Creator: Order contains the OrderItems, so it creates them
public void AddItem(string isbn, int quantity, decimal unitPrice) =>
_items.Add(new OrderItem(isbn, quantity, unitPrice));
}
public sealed record OrderItem(string Isbn, int Quantity, decimal UnitPrice)
{
public decimal LineTotal => Quantity * UnitPrice;
}
// Protected Variations + Polymorphism: the payment provider may change
public interface IPaymentGateway
{
void Charge(int customerId, decimal amount);
}
public sealed class FakePaymentGateway : IPaymentGateway
{
public void Charge(int customerId, decimal amount) =>
Console.WriteLine($"[payment] customer {customerId}: {amount}");
}
// Pure Fabrication: no counterpart in the domain, invented for cohesion
public interface IOrderRepository
{
int NextId();
void Add(Order order);
}
public sealed class InMemoryOrderRepository : IOrderRepository
{
private readonly List<Order> _orders = [];
public int Count => _orders.Count;
public int NextId() => 1001 + _orders.Count;
public void Add(Order order) => _orders.Add(order);
}
[payment] customer 7: 33.48
Order 1001: 2 lines, total 33.48, saved orders: 1
The last item on the list should look familiar. In his 2001 IEEE Software article "Protected Variation: The Importance of Being Closed", Larman presents Protected Variations as a more general statement of OCP (the Open/Closed Principle) and of the information hiding David Parnas introduced in 1972: identify the predicted points of variation and put a stable interface around them. Low Coupling and High Cohesion were the subject of this series' second post. GRASP's value lies less in saying something new than in bringing familiar principles down to the moment of "where do I put this method?".
When Principles Collide
Memorizing principles is easy; the hard part is deciding when two of them pull in opposite directions. Some typical collisions:
- Information Expert says "the information needed to save the order is in Order, so let Order save it"; High Cohesion and Persistence Ignorance say no. Larman's own answer is Pure Fabrication: invent a repository.
- Fail Fast says "stop on unexpected data"; the robustness principle of Jon Postel, who wrote the early standards documents of the internet protocols, says "be liberal in what you accept". Tolerance at the boundary (unknown fields in JSON (JavaScript Object Notation) from an external API) and fail fast inside (your own invariants) is usually the right balance.
- CQS says "separate the query from the change"; concurrency demands atomicity.
GetOrAddandTryDequeuestay combined on purpose. - DRY (Don't Repeat Yourself) says "write the validation rule in one place"; Separation of Concerns says "keep presentation, API and domain apart". If the rule lives in the domain as the single source and the other layers can use it, both are satisfied.
There is no fixed ranking that decides which one wins, but there is a single criterion: which one makes the next likely change cheaper? Each principle is that criterion seen from a different angle.
Common Pitfalls
Thinking fail fast means crashing in production. The goal is to pull errors to the earliest possible moment: compile time, startup, tests. When an unexpected situation arises while handling a request, the right move is not to bring the process down but to reject that request with a clear error and make it visible in the logs. Global exception handling strikes that balance.
Doing work in a getter. Properties are expected to be cheap and side-effect free to read; the Framework Design Guidelines (Microsoft's .NET library design rules) also recommend a method instead of a property for slow or state-changing operations. Caching that is invisible from outside, such as lazy initialization, is the exception.
Reading GRASP as "everything goes into the domain object". Applied on its own, Information Expert turns entities into god classes that send email, save themselves and generate PDFs. The nine items need to be read together.
Arguing with principle names. Instead of "this is a CQS violation", saying "calling this getter twice changes the total" is both clearer and verifiable.
A Small Experiment
★☆☆ Add a [Required] Carrier string property to ShippingOptions and start the application without putting it in configuration. At which stage does the error arrive? What changes when you remove ValidateOnStart?
★★☆ Look in your own code base for a property whose getter changes state or does I/O. When you find one, turn it into a method and review its callers: how many of them were relying on that side effect without knowing it?
★★★ Add stock reduction to PlaceOrderHandler. According to GRASP, who would you give this responsibility to: Order, a new class called Inventory, or the handler itself? Write down what Information Expert and High Cohesion say here and justify your decision.
So far we have discussed principles at the class and method level. In the next post we move up a level: when splitting a .NET solution into projects, which classes should live together, which way dependencies between projects should flow, and how that is measured with Robert C. Martin's instability and abstractness metrics.