Learnixo

Writing Testable Code in C# · Lesson 4 of 5

Pure Functions and Side-Effect Isolation

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 safely

Extracting Pure Logic from an Impure Handler

C#
// 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

C#
// 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 total

Clinical Domain Pure Functions

C#
// 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.