c# / architecture
C# without the class hierarchy

A small rule can become surprisingly difficult to find in a C# application. The controller calls a service, the service delegates to a strategy, and the strategy resolves a calculator through a factory. Eventually there is a multiplication. Changing its rounding rule takes longer than understanding the arithmetic should require.
I do not think the cure is a ban on objects. I want the number of abstractions to follow the problem. Records, functions, and .NET 6 minimal APIs make that a comfortable way to work in C# rather than a workaround against the language.
By “NoOOP” I mean less unnecessary object-oriented ceremony. There will still be types and objects below. The useful distinction is whether an abstraction protects a rule or resource, or merely gives one line of code another address.
A rule that fits in a function
Suppose a demonstration quote calculator applies a ten percent discount at ten units. Prices are in one currency, EUR; tax and delivery are outside this example. It accepts a quantity and a hypothetical unit price, validates them, and returns a rounded total.
This is a calculator, not a checkout API. In a real checkout, the server would load the authoritative price instead of trusting the client's price. Keeping that boundary explicit lets us focus on the rule without pretending that multiplying user-supplied numbers is a complete sales system.
Use SDK 6.0.101 and .NET 6.0.1, from the
December 2021 servicing release.
Create global.json in an empty Quotes directory:
{
"sdk": {
"version": "6.0.101",
"rollForward": "disable"
}
}
The complete Quotes.csproj is:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>10.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Put the data and calculation in Pricing.cs:
namespace Quotes;
public record QuoteRequest(int Quantity, decimal UnitPrice);
public record PricingPolicy(int DiscountFrom, decimal DiscountRate);
public record Quote(decimal Subtotal, decimal Discount, decimal Total, string Currency);
public record QuoteResult(Quote? Value, string? Error);
public static class Pricing
{
public static QuoteResult Calculate(QuoteRequest request, PricingPolicy policy)
{
if (request.Quantity is < 1 or > 1000)
return new(null, "Quantity must be between 1 and 1000.");
if (request.UnitPrice is < 0.01m or > 1000000m)
return new(null, "Unit price must be between 0.01 and 1000000.");
if (decimal.Round(request.UnitPrice, 2) != request.UnitPrice)
return new(null, "Unit price must have at most two decimal places.");
if (policy.DiscountFrom < 1 || policy.DiscountRate is < 0m or > 1m)
throw new ArgumentException("Invalid pricing policy.", nameof(policy));
var subtotal = request.Quantity * request.UnitPrice;
var rate = request.Quantity >= policy.DiscountFrom ? policy.DiscountRate : 0m;
var discount = decimal.Round(subtotal * rate, 2, MidpointRounding.AwayFromZero);
return new(new Quote(subtotal, discount, subtotal - discount, "EUR"), null);
}
}
The static class is simply a place to name the function. It holds no global state. Every input that affects the result is a parameter, and the same inputs produce the same result. There is no clock, configuration lookup, database call, or random number hiding inside it.
The quantity and price bounds also make overflow irrelevant within this example's accepted range. Rounding is an explicit business choice: round the discount to cents, away from zero at a midpoint, then subtract it from the exact subtotal. Different businesses can need different rules; the important part is that ours can be read and tested.
Let HTTP translate, rather than calculate
Create Program.cs:
using Quotes;
if (args.Contains("--self-test"))
{
PricingChecks.Run();
return;
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(new PricingPolicy(10, 0.10m));
var app = builder.Build();
app.MapPost("/quotes", (QuoteRequest request, PricingPolicy policy) =>
ToResponse(Pricing.Calculate(request, policy)));
app.Run();
static IResult ToResponse(QuoteResult result)
{
if (result.Value is null)
return Results.BadRequest(new { error = result.Error });
return Results.Ok(result.Value);
}
ASP.NET Core binds the JSON body to QuoteRequest and resolves the registered
policy from dependency injection. The handler calculates and translates the
result into HTTP. The pricing module does not know what a status code is.
This uses the handler support introduced in the
ASP.NET Core 6 release.
The policy is an immutable record registered as a singleton. There is no mutable cart shared between requests. A database connection with per-request ownership would be a different dependency with a different lifetime; “small application” is not a reason to register everything as a singleton.
Expected invalid input becomes a result. Invalid internal policy throws because that indicates a programming or configuration error. We should not turn every internal failure into a cheerful HTTP 400 and blame the caller.
QuoteResult is intentionally simple, although its type permits combinations
we do not produce, such as both a value and an error. If this result becomes
a public library contract, stronger construction rules may be worth adding.
That is a concrete reason for another type design, unlike adding a generic
result framework before the second use exists.
Test the rule without starting a server
Add PricingChecks.cs so the self-test branch has this implementation:
namespace Quotes;
public static class PricingChecks
{
public static void Run()
{
var policy = new PricingPolicy(10, 0.10m);
Check(Pricing.Calculate(new(2, 12.50m), policy).Value?.Total == 25m,
"No discount below threshold");
Check(Pricing.Calculate(new(10, 12.50m), policy).Value?.Total == 112.50m,
"Discount at threshold");
Check(Pricing.Calculate(new(10, 0.05m), policy).Value?.Discount == 0.05m,
"Discount in cents");
Check(Pricing.Calculate(new(1, 0.05m), new(1, 0.10m)).Value?.Total == 0.04m,
"Midpoint rounding");
Check(Pricing.Calculate(new(0, 1m), policy).Error is not null,
"Reject zero quantity");
Check(Pricing.Calculate(new(1001, 1m), policy).Error is not null,
"Reject oversized quantity");
Check(Pricing.Calculate(new(1, 1.001m), policy).Error is not null,
"Reject fractional cents");
Console.WriteLine("Pricing checks passed.");
}
private static void Check(bool condition, string message)
{
if (!condition) throw new Exception(message);
}
}
Run dotnet run -- --self-test. A failed check terminates with an exception;
success prints the message. This tiny harness keeps the article free of
test-package prerequisites. In a repository with an established test runner,
I would express these as normal tests there.
The useful property is that we can exercise the policy without mocking an
HTTP context or constructing a service container. For the transport boundary,
start the server with
dotnet run --no-launch-profile --urls http://127.0.0.1:5081 and send:
curl -i http://127.0.0.1:5081/quotes \
-H 'Content-Type: application/json' \
-d '{"quantity":10,"unitPrice":12.50}'
curl -i http://127.0.0.1:5081/quotes \
-H 'Content-Type: application/json' \
-d '{"quantity":0,"unitPrice":12.50}'
The first returns 200 with subtotal 125, discount 12.5, and total 112.5 EUR. The second returns 400. Malformed JSON is a separate binding failure, handled before our calculation runs. Testing both layers keeps the distinction visible.
Compare that with the layers we did not need
A controller, an IQuoteService, a QuoteService, an IPricingStrategy, and
a factory could all wrap this function. Their presence would not make the
calculation more replaceable than passing another policy already does.
They would give the reader more code to inspect before finding the rule.
I would introduce a boundary when a real dependency appears. A price catalog that performs I/O could expose a focused interface, or a delegate if the contract is one operation. The caller would fetch the authoritative data and pass the resulting values into the calculation. The calculation remains deterministic while the I/O can fail, retry, or be cancelled explicitly.
That separation is especially useful when a quote becomes an order. An order needs persistence, concurrency handling, and transaction decisions. Hiding all of that behind a vaguely named service would not settle those decisions. I want the operation that commits an order to make its consistency boundary obvious, even if that means more code than the quote calculator needs.
Nor would I create dozens of one-line static classes to simulate a different kind of framework. A module can contain related functions. Group them around the rules they share, and keep the dependency direction understandable.
Objects are useful when they protect something
A connection pool manages lifetime and concurrency. A domain object can prevent an invalid state transition. An interface can isolate a remote provider with a stable contract. Those are substantial jobs.
Records are a good fit for the immutable scalar data here, but they do not make an application functional by themselves. As discussed in the C# 9 article, a record can still contain mutable references. A static method can still reach into global state. The design comes from controlling effects and making inputs explicit.
I would also resist claims that removing these layers automatically improves memory usage. Some abstractions allocate, some disappear into ordinary calls, and the database payload may dwarf both. Measure if performance is the reason for changing them. Readability and testability are sufficient reasons when those are the actual problems.
My starting point for a small C# service is data, functions, and a narrow transport boundary. When an object has a real invariant or resource to own, give it that responsibility. Until then, let the calculation stay easy to find.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


