Pure Functions — The Most Testable Code You Can Write
Design .NET code as pure functions for maximum testability: referential transparency, side-effect-free computation, extracting pure logic from impure orchestration, and clinical domain examples.
What Makes a Function Pure
A pure function:
1. Always returns the same output for the same input
2. Has no side effects (no DB writes, no HTTP calls, no logging, no mutation)
Examples of pure functions:
decimal CalculateWarfarinDose(decimal weight, decimal inrValue)
bool IsInrInTherapeuticRange(decimal inrValue, MedicationName medication)
string FormatPatientName(string firstName, string lastName)
Result ValidatePrescriptionDose(DosageValue dose, MedicationName medication)
Examples of impure functions:
Task SavePrescriptionAsync(Prescription p) → writes to DB (side effect)
Task SendEmailAsync(string to, string body) → sends email (side effect)
DateTime GetCurrentTime() → reads system clock (non-deterministic)
string GetConfigValue(string key) → reads config (external state)
Pure functions are the most testable code you can write:
→ No mocks needed
→ No DI container needed
→ Run instantly
→ Completely deterministic
→ Can run in parallel safelyExtracting Pure Logic from an Impure Handler
// BEFORE: logic embedded in a handler — hard to test in isolation
public sealed class ApprovePrescriptionHandler
: IRequestHandler<ApprovePrescriptionCommand, Result>
{
public async Task<Result> Handle(ApprovePrescriptionCommand cmd, CancellationToken ct)
{
var prescription = await _repository.GetByIdAsync(PrescriptionId.Of(cmd.Id), ct);
if (prescription is null) return Result.Failure(Error.NotFound(...));
// Logic buried inside async handler — requires full stack to test
if (prescription.Status != PrescriptionStatus.Draft)
return Result.Failure(Error.Validation("Status", "Not in draft status."));
if (cmd.CheckedAt < DateTime.UtcNow.AddHours(-24))
return Result.Failure(Error.Validation("InrCheck", "INR check expired."));
if (prescription.MedicationName.Value == "Warfarin"
&& (cmd.InrValue < 2.0m || cmd.InrValue > 3.0m))
return Result.Failure(Error.Validation("InrValue", "Warfarin INR out of range."));
prescription.Status = PrescriptionStatus.Approved;
await _repository.SaveAsync(prescription, ct);
return Result.Success();
}
}
// AFTER: pure domain logic extracted, impure orchestration stays in the handler
public static class PrescriptionApprovalRules
{
// Pure: no I/O, no mutation, same input → same output
public static Result ValidateApproval(
PrescriptionStatus status,
string medicationName,
decimal inrValue,
DateTime checkedAt,
DateTime now)
{
if (status != PrescriptionStatus.Draft)
return Result.Failure(Error.Validation("Status", "Not in draft status."));
if (checkedAt < now.AddHours(-24))
return Result.Failure(Error.Validation("InrCheck", "INR check expired."));
if (medicationName == "Warfarin" && (inrValue < 2.0m || inrValue > 3.0m))
return Result.Failure(Error.Validation("InrValue", "Warfarin INR out of range."));
return Result.Success();
}
}
// Handler: thin impure orchestration layer
public async Task<Result> Handle(ApprovePrescriptionCommand cmd, CancellationToken ct)
{
var prescription = await _repository.GetByIdAsync(PrescriptionId.Of(cmd.Id), ct);
if (prescription is null) return Result.Failure(Error.NotFound(...));
// Pure validation — easy to test independently
var validation = PrescriptionApprovalRules.ValidateApproval(
prescription.Status,
prescription.MedicationName.Value,
cmd.InrValue,
cmd.CheckedAt,
_clock.UtcNow);
if (validation.IsFailure) return validation;
prescription.Approve();
await _repository.SaveAsync(prescription, ct);
return Result.Success();
}Testing Pure Functions
// Pure function tests: no mocks, no DI, no async, runs in microseconds
public sealed class PrescriptionApprovalRulesTests
{
private static readonly DateTime Now = new(2026, 3, 15, 10, 0, 0, DateTimeKind.Utc);
[Theory]
[InlineData(PrescriptionStatus.Draft, true)]
[InlineData(PrescriptionStatus.Approved, false)]
[InlineData(PrescriptionStatus.Suspended, false)]
public void ValidateApproval_StatusCheck(PrescriptionStatus status, bool shouldSucceed)
{
var result = PrescriptionApprovalRules.ValidateApproval(
status,
medicationName: "Heparin",
inrValue: 2.5m,
checkedAt: Now.AddHours(-1),
now: Now);
result.IsSuccess.Should().Be(shouldSucceed);
}
[Theory]
[InlineData(2.0, true)]
[InlineData(3.0, true)]
[InlineData(1.99, false)]
[InlineData(3.01, false)]
public void ValidateApproval_WarfarinInrRange(decimal inrValue, bool shouldSucceed)
{
var result = PrescriptionApprovalRules.ValidateApproval(
PrescriptionStatus.Draft,
medicationName: "Warfarin",
inrValue: inrValue,
checkedAt: Now.AddHours(-1),
now: Now);
result.IsSuccess.Should().Be(shouldSucceed);
}
[Fact]
public void ValidateApproval_InrCheckOlderThan24Hours_Fails()
{
var result = PrescriptionApprovalRules.ValidateApproval(
PrescriptionStatus.Draft,
medicationName: "Warfarin",
inrValue: 2.5m,
checkedAt: Now.AddHours(-25), // expired
now: Now);
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be("Validation.InrCheck");
}
}
// 80 test cases covering all rule combinations — runs in under 10ms totalClinical Domain Pure Functions
// Dosage calculation — pure: no I/O, deterministic
public static class WarfarinDosageCalculator
{
// Calculates next dose based on current INR and target range
public static DosageValue CalculateAdjustedDose(
DosageValue currentDose,
decimal currentInr,
decimal targetInrMin,
decimal targetInrMax)
{
if (currentInr < targetInrMin)
{
// INR below range: increase dose by 10%
return DosageValue.Of(
Math.Round(currentDose.Amount * 1.10m, 1),
currentDose.Unit);
}
if (currentInr > targetInrMax)
{
// INR above range: decrease dose by 10%
return DosageValue.Of(
Math.Round(currentDose.Amount * 0.90m, 1),
currentDose.Unit);
}
// INR in range: no change
return currentDose;
}
}
// Test: pure function, no setup needed
[Theory]
[InlineData(5.0, 1.5, 2.0, 3.0, 5.5)] // INR below range: 5.0 * 1.10 = 5.5
[InlineData(5.0, 3.5, 2.0, 3.0, 4.5)] // INR above range: 5.0 * 0.90 = 4.5
[InlineData(5.0, 2.5, 2.0, 3.0, 5.0)] // INR in range: unchanged
public void CalculateAdjustedDose_ReturnsCorrectDose(
decimal currentDose, decimal inr,
decimal targetMin, decimal targetMax,
decimal expectedDose)
{
var result = WarfarinDosageCalculator.CalculateAdjustedDose(
DosageValue.Of(currentDose, "mg"), inr, targetMin, targetMax);
result.Amount.Should().Be(expectedDose);
}The Impure Shell / Pure Core Pattern
Organise code into two layers:
→ Pure core: business rules, calculations, validations
All pure functions. No I/O. Easily testable.
→ Impure shell: orchestration, I/O, infrastructure
Thin. Calls the pure core. Delegates I/O to adapters.
Benefits:
→ 80% of your code is in the pure core — fully testable without mocks
→ 20% is in the impure shell — tested with integration tests
→ No need to mock business logic — it's pure and deterministic
Applied to the clinical platform:
Pure core: PrescriptionApprovalRules, WarfarinDosageCalculator,
PatientMrn.Create(), DosageValue.Of()
Impure shell: ApprovePrescriptionHandler (orchestrates pure core + I/O),
PrescriptionRepository (EF Core), EmailNotificationService (SMTP)Production issue I've seen: A clinical system had its INR range validation logic scattered across three places: the API controller (validation filter), the service layer (business logic), and the domain entity (domain rule). Each was an impure method — one read from a database, one from config, one inline. When a new medication required a different INR range, the developer updated the config-based rule but missed the domain entity rule. The domain entity still used the old range. For 2 weeks, prescriptions that passed API validation were rejected at the domain level — but the error message was generic. Extracting all INR validation into a single pure function (
PrescriptionApprovalRules.ValidateApproval) with 40 test cases would have made this impossible: one test would have caught the inconsistency immediately.
Key Takeaway
Pure functions — same input, same output, no side effects — are the most testable code you can write. Extract business rules, calculations, and validations into pure static methods. Test them exhaustively with
[Theory]data-driven tests — no mocks, no DI, microsecond execution. Keep I/O in thin impure orchestration layers (handlers, repositories). The "impure shell / pure core" pattern maximises the testable surface area of your business logic and minimises the code that requires infrastructure to test.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.