Some methods do almost nothing and are still hard to test.
Not because the business logic is complicated. Not because the algorithm is complex. But because the method fetches data, makes decisions, reads the current time, saves something, publishes an event, and writes a log line — all at once.
Here’s an example:
public async Task CompleteTrip(Guid tripId)
{
var trip = await dbContext.Trips.FindAsync(tripId);
if (trip == null)
throw new InvalidOperationException("Trip not found.");
if (trip.Status != TripStatus.Active)
throw new InvalidOperationException("Trip is not active.");
trip.EndedAt = DateTime.UtcNow;
trip.Status = TripStatus.Completed;
if (trip.Distance > 100)
trip.RequiresReview = true;
await dbContext.SaveChangesAsync();
await eventBus.Publish(
new TripCompleted(trip.Id, trip.EndedAt, trip.Distance));
logger.LogInformation("Trip {TripId} completed", trip.Id);
}
At first glance there’s little wrong here. The code is readable, the method isn’t especially long, and the business rule — trips over a hundred kilometers need review — is simple.
And yet this code is harder to understand and test than it needs to be.
There’s a second problem hiding in the model, by the way: Trip carries a Status alongside an EndedAt and a RequiresReview that only apply once the trip is done — the shape I took apart in make illegal states unrepresentable. This post leaves the model alone and looks only at the method.
I recently handed exactly this refactor to Claude Code. The principle below is an old one — the surprise was in how the work split between us.
Spotting the problem
The method does two fundamentally different kinds of work.
On one hand, it makes decisions:
if (trip.Status != TripStatus.Active)
throw new InvalidOperationException();
if (trip.Distance > 100)
trip.RequiresReview = true;
This is domain logic. Given the same input, we always expect the same outcome.
On the other hand, the method talks to the outside world:
await dbContext.Trips.FindAsync(tripId);
DateTime.UtcNow;
await dbContext.SaveChangesAsync();
await eventBus.Publish(...);
logger.LogInformation(...);
Database, time, messaging, logging — all effects.
And the moment decisions and effects run through each other, a problem appears. To test one simple business rule, we suddenly need a database, or mocks of one. We have to control the clock. We have to verify that an event was published. We might even have to check that SaveChangesAsync() was called exactly once.
The test is no longer about the business rule. The test is about the entire world surrounding that rule.
Functional Core, Imperative Shell
The principle Functional Core, Imperative Shell proposes separating these two responsibilities explicitly.
The functional core holds the decisions. The imperative shell handles interaction with the outside world.
The core tries to consist, as much as possible, of pure functions:
Input → Decision → Output
The shell does the rest:
Load → Call Core → Persist → Publish
So the shell hasn’t disappeared. Database calls don’t vanish. Messaging doesn’t vanish. Logging doesn’t vanish. The outside world is still necessary.
But the outside world no longer dictates what our business logic looks like.
What is a pure function?
A pure function has two key properties. First, the same input always produces the same output. Second, the function has no observable side effects.
For example:
public static bool RequiresReview(decimal distance)
{
return distance > 100;
}
This function uses no database, reads no configuration, never asks for the current time, writes nothing, and publishes no events. That makes it trivial to test:
RequiresReview(50).Should().BeFalse();
RequiresReview(150).Should().BeTrue();
But in a real domain model we usually want to do more than compute a single boolean. We want to make a decision and model its result.
The functional core
Let’s lift the business logic out of our original method. The Result<T> type here is a plain success-or-failure wrapper — the one from CSharpFunctionalExtensions, or your own; the pattern doesn’t depend on which.
public static Result<TripCompletion> Complete(Trip trip, DateTime completedAt)
{
if (trip.Status != TripStatus.Active)
return Result.Failure<TripCompletion>("Trip is not active.");
var completedTrip = trip.Complete(
completedAt,
requiresReview: trip.Distance > 100);
var domainEvent = new TripCompleted(
completedTrip.Id,
completedAt,
completedTrip.Distance);
return Result.Success(new TripCompletion(completedTrip, domainEvent));
}
This function knows nothing about Entity Framework, nothing about Azure Service Bus, nothing about logging. Even the current time is passed in as an argument.
Given the same Trip and the same completedAt, we always get the same result. That’s our functional core.
Notice what it hands back. Not a boolean saying the trip may be completed, but the completed trip and the event that goes with it — so the shell can’t do much except carry out the decision. That’s the same move as returning the proof instead of the answer, one layer up.
The imperative shell
The application layer now becomes responsible for the orchestration.
public async Task<Result> CompleteTrip(Guid tripId)
{
var trip = await repository.Get(tripId);
if (trip == null)
return Result.Failure("Trip not found.");
var completedAt = clock.UtcNow;
var result = TripCompletionService.Complete(trip, completedAt);
if (result.IsFailure)
return Result.Failure(result.Error);
await repository.Save(result.Value.Trip);
await eventBus.Publish(result.Value.Event);
logger.LogInformation("Trip {TripId} completed", trip.Id);
return Result.Success();
}
The shell still does plenty, and that isn’t a problem in itself. It orchestrates: it loads data, gathers input from the outside world, calls the core, interprets the result, and then performs the necessary effects.
The important difference is that it barely makes any decisions of its own.
Letting Claude Code do the split
I didn’t rewrite CompleteTrip by hand. The interesting part of this refactor is how little of it needs a human — once you’ve named the two responsibilities, the shape is mechanical.
I pointed Claude Code at the original handler and gave it the rule, not the solution:
This handler mixes decisions with effects. Extract the decisions into a pure static
Completemethod that takes the trip and the current time as parameters and returns aResult<TripCompletion>— no EF, no clock, no logging, no message bus. Leave the loading, saving, publishing and logging in the handler. Then write a unit test for the core method with no mocks.
What came back was almost exactly the core/shell split above. That makes sense: separating decisions from effects is pattern-matching, and pattern-matching is what these tools are good at. Moving DateTime.UtcNow out to a parameter, turning thrown exceptions into Result.Failure, threading the return value back through the handler — all mechanical, all the kind of edit that’s tedious by hand and error-prone the moment you do it across a dozen handlers.
What Claude Code didn’t decide — and shouldn’t — is which logic counts as a decision. That Distance > 100 is a business rule and SaveChangesAsync is an effect is obvious here; in a real handler the line is blurrier, and that judgment stays with you. I told it where the boundary was. It did the moving.
That’s the division of labour I keep coming back to on this blog: you hold the design, the AI does the refactor.
Push decisions inward, push effects outward
A handy way to recognize Functional Core, Imperative Shell is this rule:
Push decisions inward. Push effects outward.
Business decisions move toward the core. Side effects move toward the edge of the system.
If you see code like this in an application handler:
if (customer.IsPremium && order.Total > 1000)
{
discount = 0.10m;
}
that’s probably a decision that can move inward.
If you see code in your domain model that directly runs:
await database.SaveChangesAsync();
or:
await messageBus.Publish(...);
then an effect has probably drifted too far inward.
Why this makes testing simpler
The biggest benefit shows up in tests. The original method requires infrastructure or mocks. The functional core doesn’t.
[Fact]
public void Long_trip_requires_review()
{
var trip = Trip.Active(distance: 150);
var result = TripCompletionService.Complete(
trip,
new DateTime(2026, 7, 1));
result.Value.Trip.RequiresReview.Should().BeTrue();
}
No database. No dependency injection container. No mocks. No test server. No message bus. Just input and output.
Integration tests are still needed. You still want to know whether your repository works and whether events actually get published. But the number of combinations you have to cover through integration tests gets much smaller. The complex combinatorics live in the functional core — and that you can test quickly and deterministically.
This is not an argument for functional programming
The name can be misleading. You don’t need Haskell. You don’t need to replace all your classes with functions. And you don’t need to make your domain model immutable.
The principle is about architecture — about recognizing two fundamentally different responsibilities: deciding and executing.
Object-oriented domain models can be part of a functional core just fine, as long as the domain logic stays independent of infrastructure and external effects. An aggregate that handles a command, changes its state, and produces domain events fits perfectly inside this model.
The interesting consequence
When you apply this principle consistently, the shape of your application changes. The outer layer gets thinner in intelligence. The inner layer gets richer in decisions.
Your handlers become orchestration code. Your domain model and domain services become the place where decisions are made. And infrastructure goes back to being what infrastructure is supposed to be: a mechanism to read and change the outside world.
Wrapping up
A lot of code isn’t hard because the business logic is complicated. Code gets hard when decisions and effects become entangled.
Functional Core, Imperative Shell gives you a simple design rule to pull the two apart. Let the shell bring the world in. Let the core decide what that world means. Then let the shell execute what was decided.
Or, shorter:
Effects at the edges. Decisions at the core.
Try it on your own code. Open the messiest handler in your current project — the one you dread testing. Highlight every line that touches a database, the clock, a message bus, or a logger, and every line that makes a decision. If the two are interleaved, that’s your candidate. Pull the decisions into one static method that takes plain inputs and returns a result, leave the effects in the handler, and write a test for that method with no mocks. If the test is suddenly boring to write, you’ve felt the point of this post — and boring tests are exactly the ones you can hand to Claude Code once the method is pure.
Comments