Dataslope logoDataslope

Side-Effect Management

Functional core, imperative shell — pushing IO to the edges so the heart of your program stays pure, testable, and easy to reason about

LINQ pipelines work best when their lambdas are pure — they read inputs, return outputs, and do nothing else. Real programs, of course, have to do plenty of impure work: read files, hit the network, write to a database, print to a console, check the clock.

This page is about where the impurity lives. The pattern is older than C#, but it fits LINQ exceptionally well: functional core, imperative shell.

The two halves of a program

The shell talks to the outside world: it reads files, performs HTTP calls, accepts user input, prints results, writes to disk.

The core does the work: filtering, transforming, calculating, deciding. It takes pure data in and returns pure data out. The core never knows where its inputs came from or where its outputs will go.

Why this split is a superpower

PropertyShell (IO)Core (pure)
Hardest part to test
Hardest part to read
Worth most of your engineering attention
Where bugs hide
Where LINQ shines

The trick is to make the core as large as possible and the shell as small as possible. The shell becomes thin glue that's easy to audit. The core becomes a pile of pure functions that's a joy to test.

A bad example: tangled IO and logic

Code Block
C# 13

To test the parsing rule — "non-numeric and non-positive entries are dropped" — you'd have to mock a file reader, capture console output, and assert on log lines. The logic is trapped inside the IO.

A good example: pure core, thin shell

Code Block
C# 13

SalesParser.Parse is pure: same input, same output, no side effects. You can hand-write tests for it without ever touching a file, the console, or any infrastructure.

The shell — the four lines after the class — is so small there's almost nothing to test.

Threading the "world" through arguments

When you do need impure inputs (the clock, randomness, a database result), pass them in as parameters instead of calling them inline. This single change makes the function pure (or at least more pure).

Code Block
C# 13

In a test, you pass an arbitrary DateTime. In production, the shell calls Subs.ExpiringSoon(subs, DateTime.UtcNow). Same code, no mocking framework needed.

The same trick works for randomness (Random rng), configuration (Settings s), and external services (interfaces).

What about logging?

Logging is a side effect — but most teams accept it inside pure code because it's "harmless". A pragmatic compromise:

  • Don't log inside lambdas that LINQ may run many times or never.
  • Do log at boundaries (when receiving input, before returning to the shell).
  • If logging is essential to a transformation, return the log lines as data and let the shell print them.
// Return logs as data instead of writing them inline
public static (IEnumerable<decimal> values, IEnumerable<string> warnings) ParseWithWarnings(IEnumerable<string> lines)
{
    var warnings = new List<string>();
    var values = lines
        .Select(line => decimal.TryParse(line, out var n) ? (decimal?)n : (decimal?)null)
        .Select((n, i) =>
        {
            if (n is null) warnings.Add($"line {i}: not a number");
            return n;
        })
        .Where(n => n is > 0)
        .Select(n => n!.Value)
        .ToList();   // materialize so the warnings list is filled
    return (values, warnings);
}

The function still returns data. The shell decides whether "warnings" go to the console, a log file, or a UI.

Practice: extract the core

Challenge
C# 13

Report.Build reads no environment. It uses no Console. It takes "today" as an argument. Given the same three inputs, it always returns the same string. That is what makes it a pure core.

The shape of a clean program

The arrows on the boundary are IO. Everything inside the inner box is pure. The pure code grows, the boundary stays thin. As an engineering posture, this is one of the most impactful habits you can adopt.

QuestionSelect one

Why do experienced functional-style C# programmers pass values like DateTime now or Random rng as arguments instead of calling DateTime.UtcNow / new Random() inside the function?

Because DateTime.UtcNow is slow.

Because static calls are forbidden inside LINQ lambdas.

To make the function deterministic and testable — the same inputs always produce the same output, and tests can supply any time/seed they want.

To improve thread safety.

On this page