Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20266 min read
Testable CodePure FunctionsFunctional Programming.NETTesting
Share:𝕏

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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.