Pure Functions and Side Effects: Referential Transparency, Hidden Inputs and Testability
A pure function always gives the same result for the same input and leaves no trace outside; such a call can be replaced by its value. Starting from decimal.Parse reading 189.50 as 18950 under the Turkish culture, we separate Bookstore's discount calculation from the clock, the console and static state, test it with xUnit theories and no fakes, and show memoization and the purity assumption of Angular computed() and pure pipes with real output.
Version note: The code in this post was built and run with C# 14 and .NET 10 (SDK 10.0.402), the tests with xUnit.net v3 4.0.3; the TypeScript examples with TypeScript 7.0.2, Node.js 26 and Angular 22.2 (@angular/core 22.2.6).
Guess First: How Many Lira Is "189.50"?
Bookstore, the online bookstore used as the shared example across this site's series, imports price lists from publishers as CSV. The price column is written with a dot: 189.50. The code that reads the row is a one-line, innocent-looking method:
public static class PriceText
{
public static decimal ParseLegacy(string text) => decimal.Parse(text);
public static decimal Parse(string text) => decimal.Parse(text, NumberStyles.Number, CultureInfo.InvariantCulture);
}
ParseLegacy seems to touch nothing outside itself: it takes a string and returns a decimal. The same method runs with the same "189.50" input, once on a CI server in the United States and once on a developer's machine in Istanbul. Does it give the same result on both machines?
foreach (string name in new[] { "en-US", "tr-TR" })
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(name);
Console.WriteLine($"{name}: ParseLegacy(\"189.50\") = {PriceText.ParseLegacy("189.50").ToString(CultureInfo.InvariantCulture)}, Parse(\"189.50\") = {PriceText.Parse("189.50").ToString(CultureInfo.InvariantCulture)}");
}
Answer: A Parameter You Can't See
$ dotnet run --project src/Bookstore.Fp.Demo -- 03-culture
en-US: ParseLegacy("189.50") = 189.50, Parse("189.50") = 189.50
tr-TR: ParseLegacy("189.50") = 18950, Parse("189.50") = 189.50
Under the Turkish culture, the price of Dune went up a hundredfold. decimal.Parse(string) reads the number according to the thread's CurrentCulture. In tr-TR the decimal separator is a comma and the dot is the thousands separator; because the default NumberStyles.Number allows thousands separators, 189.50 is read as 18950 without any error. No exception, no warning; the price list is imported with wrong prices.
ParseLegacy has a single parameter in its signature, but in reality it has two inputs: text and CultureInfo.CurrentCulture. The second one is invisible; you don't notice it while reading the method or while calling it. The fixed Parse does the same job but passes the culture explicitly. Now only its parameter decides the result. This post gives that difference a name: pure function. .NET's own code analysis has a rule for this trap too: CA1305 flags calls that don't use an overload taking an IFormatProvider when one exists.
Pure Functions and Referential Transparency
A function is pure if it meets two conditions: it always gives the same output for the same input, and apart from returning its output it does nothing observable from outside. The first condition says there are no hidden inputs; the second says there are no side effects, the traces a function leaves in the world beyond its return value.
These two conditions have a practical consequence: referential transparency. You can replace a call to a pure function with the value it returns without changing the program's behaviour. Wherever PriceText.Parse("189.50") is written, you can write 189.50m; the program runs the same. You can't do that for ParseLegacy("189.50"), because the value depends on the culture. You can't do it for LegacyCheckout.CalculateTotal(cart, "AUTUMN10", true) from the first post either: replace the call with 259.36 and nothing is written to the console, the static field isn't updated, no line is deleted from the cart, and after 1 December the result is different anyway.
Referential transparency matters for the person reading the code too. To understand a pure function, looking at its body and parameters is enough; you don't need to know where it's called, in what order or on which thread. You can reorder two pure functions, cache their results, run them in parallel, or not call them at all and compute the result in advance. Compilers and frameworks rely on this assumption too; Angular's computed() is one example.
Kinds of Side Effects and Hidden Inputs
Let's read LegacyCheckout again with this in mind:

Side effects fall under a few headings:
- I/O: files, the database, the network, the console, logs. I/O that reads something is a hidden input; I/O that writes something is a hidden output.
- Mutation: changing an object passed as a parameter, a static field or a shared collection (
lines.RemoveAt,LastTotal = total). - Exceptions: stopping the flow for some inputs instead of returning a value. The caller can't know this from the signature; we'll come back to it in the post on the Result type.
- Time and randomness:
DateTime.Now,DateTime.UtcNow,Stopwatch,Random.Shared,Guid.NewGuid(). They give a different result on each call, and so does a function that depends on them. - Environment:
CultureInfo.CurrentCulture, the server's time zone (DateTime.Nowreads not just the clock but the machine's time zone setting), environment variables, static configuration.
None of these are bad as such; an application exists to produce side effects: saving the order, sending the email, drawing the page. The goal isn't to eliminate them but to push them to the edge. The calculation that makes decisions stays pure, and the side effects are gathered in a thin layer that calls it. That's the functional core, imperative shell arrangement we named in the first post.
Refactoring Bookstore: Making the Discount Pure
LegacyCheckout's campaign check was DateTime.Now < new DateTime(2026, 12, 1): both the clock and the machine's time zone were hidden inputs. First we turn the campaign into data. Start and end are held as DateTimeOffset; because the comparison is made on the UTC instant, the server's time zone no longer matters:
public sealed record Campaign(string Code, decimal Rate, DateTimeOffset StartsAt, DateTimeOffset EndsAt)
{
public bool IsActiveAt(DateTimeOffset now) => now >= StartsAt && now < EndsAt;
}
Then the calculation itself. It doesn't read the clock, doesn't write to the console, keeps no static state; it takes the current time as a parameter too:
public sealed record CheckoutTotals(decimal Subtotal, decimal CouponDiscount, decimal MemberDiscount, decimal Shipping)
{
public decimal Total => Subtotal - CouponDiscount - MemberDiscount + Shipping;
}
// Functional core: no clock, no console, no static state
public static class CheckoutCalculator
{
public const decimal MemberDiscountAmount = 15m;
public const decimal FreeShippingThreshold = 300m;
public const decimal StandardShippingFee = 49.90m;
public static CheckoutTotals Calculate(Cart cart, bool isMember, Campaign campaign, DateTimeOffset now)
{
IEnumerable<CartLine> lines = cart.Lines.Where(line => line.Quantity > 0);
decimal subtotal = lines.Sum(line => line.LineTotal);
decimal coupon = CouponDiscount(subtotal, cart.CouponCode, campaign, now);
decimal member = isMember ? MemberDiscountAmount : 0m;
bool hasPrintedBooks = lines.Any(line => !line.IsEbook);
decimal shipping = hasPrintedBooks && subtotal - coupon - member < FreeShippingThreshold ? StandardShippingFee : 0m;
return new CheckoutTotals(subtotal, coupon, member, shipping);
}
public static decimal CouponDiscount(decimal subtotal, string? couponCode, Campaign campaign, DateTimeOffset now) =>
couponCode == campaign.Code && campaign.IsActiveAt(now)
? Math.Round(subtotal * campaign.Rate, 2, MidpointRounding.AwayFromZero)
: 0m;
}
Calculate has local variables inside, and none of them changes afterwards; from outside the method there are only inputs and a CheckoutTotals. The result is a record instead of a single number: having the discounts and shipping visible separately is more useful both for tests and for the invoice. The clock, the log and the console are in the shell:
// Imperative shell: reads the clock, calls the core, writes the log
public sealed class CheckoutService(TimeProvider clock, Campaign campaign, TextWriter log)
{
public CheckoutTotals Checkout(Cart cart, bool isMember)
{
DateTimeOffset now = clock.GetUtcNow();
CheckoutTotals totals = CheckoutCalculator.Calculate(cart, isMember, campaign, now);
log.WriteLine($"[checkout] {now:yyyy-MM-dd HH:mm}Z total={totals.Total}");
return totals;
}
}
TimeProvider is the clock abstraction that came with .NET 8; in production you pass TimeProvider.System, in tests a fake. Let's call the core at the last minute of the campaign and on the next day, and the shell with the real clock:
DateTimeOffset lastMinute = new(2026, 11, 30, 23, 59, 0, TimeSpan.FromHours(3));
DateTimeOffset nextDay = new(2026, 12, 1, 0, 1, 0, TimeSpan.FromHours(3));
Console.WriteLine($"core, last minute: {CheckoutCalculator.Calculate(cart, isMember: true, Autumn, lastMinute)}");
Console.WriteLine($"core, next day: {CheckoutCalculator.Calculate(cart, isMember: true, Autumn, nextDay).Total}");
CheckoutService service = new(TimeProvider.System, Autumn, Console.Out);
CheckoutTotals totals = service.Checkout(cart, isMember: true);
Console.WriteLine($"shell total: {totals.Total}, cart lines: {cart.Lines.Count}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 03-core
core, last minute: CheckoutTotals { Subtotal = 249.40, CouponDiscount = 24.94, MemberDiscount = 15, Shipping = 49.90, Total = 259.36 }
core, next day: 284.30
[checkout] 2026-11-15 01:23Z total=259.36
shell total: 259.36, cart lines: 3
The core shows its 1 December behaviour without waiting for 1 December: the coupon drops off and the total becomes 284.30. With the legacy code you'd have had to move the machine's clock forward to see this. The time in the shell's log line is the moment this post's code was run; it will be different when you run it, and that's exactly how it should be. The cart still has three lines: Cart is immutable, and the calculation filters out empty lines instead of deleting them.
Testing with xUnit: No Clock, No Fakes
The test of a pure function is a table of inputs and expected outputs. xUnit's [Theory] is made for exactly that. The four critical moments of the campaign window fit in a single test method:
public static TheoryData<string, DateTimeOffset, decimal> CouponCases => new()
{
{ "day before start", new DateTimeOffset(2026, 10, 31, 23, 59, 0, Istanbul), 0m },
{ "first minute", new DateTimeOffset(2026, 11, 1, 0, 0, 0, Istanbul), 24.94m },
{ "last minute", new DateTimeOffset(2026, 11, 30, 23, 59, 0, Istanbul), 24.94m },
{ "midnight Istanbul as UTC", new DateTimeOffset(2026, 11, 30, 21, 0, 0, TimeSpan.Zero), 0m }
};
[Theory]
[MemberData(nameof(CouponCases))]
public void Coupon_applies_only_inside_the_campaign_window(string moment, DateTimeOffset now, decimal expected)
{
decimal discount = CheckoutCalculator.CouponDiscount(249.40m, "AUTUMN10", Autumn, now);
Assert.True(expected == discount, $"{moment}: expected {expected}, got {discount}");
}
[Theory]
[InlineData(true, 259.36)]
[InlineData(false, 274.36)]
public void Total_matches_the_legacy_checkout(bool isMember, decimal expected)
{
CheckoutTotals totals = CheckoutCalculator.Calculate(DuneAndEmma, isMember, Autumn, new DateTimeOffset(2026, 11, 15, 12, 0, 0, Istanbul));
Assert.Equal(expected, totals.Total);
}
$ dotnet test
Test run summary: Passed!
total: 7
failed: 0
succeeded: 7
skipped: 0
duration: 410ms
The last row is especially valuable: 21:00 UTC is midnight in Istanbul. The campaign ends and the coupon doesn't apply. With the legacy code, testing this edge case would have meant changing the server's time zone. The tests have no mocks, no fakes, no setup, because the core has no connection to the world. xUnit v3 runs on Microsoft Testing Platform with the .NET 10 SDK; that's what the "test": { "runner": "Microsoft.Testing.Platform" } setting in global.json is for.
If you want to test the shell, you pass FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package. But because the shell has no decisions in it, there's very little left to test; that's the real gain of pulling decisions into the core. Constructor injection, seams and strategies for testing the shell are covered in detail in the Unit Test series post "Testable Design: Dependency Injection, Seams, Functional Core and Humble Object".
Memoization: Caching a Pure Function Safely
One consequence of referential transparency: you can compute a pure function's result once, keep it and return the kept value when the same input comes again. This is called memoization. A general helper takes a few lines:
public static class Memo
{
public static Func<TIn, TOut> Memoize<TIn, TOut>(this Func<TIn, TOut> function) where TIn : notnull
{
ConcurrentDictionary<TIn, TOut> cache = new();
return input => cache.GetOrAdd(input, function);
}
}
The ISBN-13 checksum check is pure; memoizing it is safe. Whether the coupon is active depends on the clock; what happens if we memoize it with the same helper?
int calls = 0;
Func<string, bool> isValidIsbn = isbn =>
{
calls++;
int sum = isbn.Select((c, i) => (c - '0') * (i % 2 == 0 ? 1 : 3)).Sum();
return isbn.Length == 13 && sum % 10 == 0;
};
Func<string, bool> cachedIsbn = isValidIsbn.Memoize();
bool[] results = [cachedIsbn("9780441013593"), cachedIsbn("9780441013593"), cachedIsbn("9780441013594")];
Console.WriteLine($"pure: {string.Join(", ", results)} with {calls} calls");
DateTimeOffset clock = new(2026, 11, 30, 23, 59, 0, TimeSpan.FromHours(3));
Func<string, bool> isCouponActive = code => code == Autumn.Code && Autumn.IsActiveAt(clock);
Func<string, bool> cachedCoupon = isCouponActive.Memoize();
Console.WriteLine($"23:59 -> cached: {cachedCoupon("AUTUMN10")}, direct: {isCouponActive("AUTUMN10")}");
clock = clock.AddMinutes(2);
Console.WriteLine($"00:01 -> cached: {cachedCoupon("AUTUMN10")}, direct: {isCouponActive("AUTUMN10")}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 03-memo
pure: True, True, False with 2 calls
23:59 -> cached: True, direct: True
00:01 -> cached: True, direct: False
The pure function ran twice for three calls, and the results are correct. isCouponActive, however, reads the clock variable that the lambda captured through a closure; that's a hidden input. Because the cache uses only code as the key, it keeps returning True after midnight: the discount keeps being applied although the campaign is over. For a cache to work correctly, the key must cover all of the function's inputs; with a hidden input, that's impossible. In a real system the cache's lifetime and size must be limited too (this helper grows forever); on the ASP.NET Core side, HybridCache and IMemoryCache exist for that.
The Purity Assumption in Angular: computed() and Pure Pipes
Angular's computed() is memoization built into the framework: it does the calculation once and returns the kept value until one of the signals it read changes. Angular can track which signals were read; it can't track an ordinary variable. Let's write the campaign check two ways:
import { computed, signal } from "@angular/core";
const campaignEnd = new Date("2026-12-01T00:00:00+03:00");
// Pure: everything it needs comes in as a parameter
function isCouponActive(code: string, now: Date): boolean {
return code === "AUTUMN10" && now < campaignEnd;
}
// Impure computed: reads a plain variable that Angular can't track
let now = new Date("2026-11-30T23:59:00+03:00");
const coupon = signal("AUTUMN10");
const activeImpure = computed(() => isCouponActive(coupon(), now));
// Pure computed: the clock is a signal too
const clock = signal(new Date("2026-11-30T23:59:00+03:00"));
const activePure = computed(() => isCouponActive(coupon(), clock()));
console.log("23:59 ->", "impure:", activeImpure(), "pure:", activePure());
now = new Date("2026-12-01T00:01:00+03:00");
clock.set(new Date("2026-12-01T00:01:00+03:00"));
console.log("00:01 ->", "impure:", activeImpure(), "pure:", activePure());
$ node src/03-purity.ts
23:59 -> impure: true pure: true
00:01 -> impure: true pure: false
It's the same as the C# memoize example: activeImpure isn't told when its hidden input changes and returns a stale value. Reading Date.now(), Math.random(), localStorage or a non-signal field of a service inside computed() always produces this bug. The fix is to make every input that can change a signal, or to pass it to the function as a parameter.
Pipes rely on the same assumption. According to the Angular documentation, pipes are pure by default: Angular reruns a pure pipe only when its input changes; for a primitive the value must change, for an object or array the reference must. If the pipe looks at the clock, the culture or a service's changing state, or if you change an array in place as in the second post, the old result stays on screen. pure: false looks like a fix, but then the pipe runs on every change detection cycle; this time the cost shows up in performance.
Common Pitfalls
Counting logging as "not a side effect". A log is I/O: it writes to a file, the console or a service over the network, and can throw an exception when it can't. Putting an ILogger inside the core means the function needs a fake for tests, and when it's memoized the logs disappear unexpectedly. Write the log in the shell, with the result the core returns; if you want to see intermediate steps of the calculation, have the core return them in the result (as CheckoutTotals carries the discounts separately).
A method that looks pure but changes a mutable argument. A "find the most expensive book" method that takes a List<CartLine> and calls lines.Sort(...) changes the order of the caller's list. Its signature looks pure, its return value is correct, and the bug shows up on another screen. If you need sorting, use OrderBy or a method that works on a copy; taking the parameter as IReadOnlyList<T> or an immutable collection also documents the intent.
Bloating the parameter list by trying to make everything pure. Clock, culture, campaign, exchange rate, tax rates, membership, country... when all of them are parameters, the method ends up with ten parameters. That's a sign the inputs to the core form a concept: gather them in a record (such as PricingContext) and let the shell build that record once. Also, not everything needs to be pure; what should stay pure is the calculation that makes decisions and is worth testing.
Reading "now" deep inside the core. Writing DateTime.UtcNow in a small helper at the bottom layer of the core makes the whole chain impure. Even if TimeProvider gets into the core, you only gain testability, not purity. Read the time once in the shell and pass it down as a value.
A Small Experiment
★☆☆ Run PriceText.ParseLegacy with the de-DE and fr-FR cultures too. Under which culture does "189.50" throw an exception, and under which does it give a wrong value?
★☆☆ Add a "one second before the campaign starts" row to the CouponCases table. Then write a separate test for the coupon written in lower case, "autumn10": is the discount applied? Is that a bug or a business rule?
★★☆ Test CheckoutService with FakeTimeProvider: set the clock to the last minute of the campaign, call Checkout, move the clock forward two minutes and call it again. What does this test verify that the core's [Theory] doesn't?
★★★ Add a lifetime parameter of type TimeSpan to Memo.Memoize and recompute entries that have expired. Which clock will you use to measure the lifetime? Does that decision stop the helper from being pure?
In the next post we connect these pure functions: higher-order functions and composition. We'll combine the coupon, member discount and VAT rules as a list of Func<decimal, decimal> and test how the order of the rules changes the result.