Testable Design: Dependency Injection, Seams, Functional Core and Humble Object
What makes code testable is moving decisions into a pure core and leaving time, I/O and framework code in a thin shell; the decisions can then be tested without mocks. A discount test using DateTime.Now depended on the calendar and hid the Istanbul vs UTC gap. Kinds of seams, TimeProvider, functional core / imperative shell, Humble Object with BackgroundService, and characterization tests for legacy code.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.2 and Microsoft.Extensions.TimeProvider.Testing 10.11.0.
Predict First
Bookstore runs a "Sunday reading" campaign: orders placed on Sundays get 5% off. The code and its tests:
// Before: time is read directly, no seam
public sealed class LegacySundayPricing
{
public decimal PriceFor(decimal subtotal) =>
DateTime.Now.DayOfWeek == DayOfWeek.Sunday
? Math.Round(subtotal * 0.95m, 2, MidpointRounding.AwayFromZero)
: subtotal;
}
public sealed class LegacySundayPricingTests
{
[Fact]
public void Sunday_orders_get_five_percent_off() =>
Assert.Equal(95.00m, new LegacySundayPricing().PriceFor(100m));
[Fact]
public void Weekday_orders_pay_full_price() =>
Assert.Equal(100m, new LegacySundayPricing().PriceFor(100m));
}
I ran the tests on the day I wrote this post, 8 November 2026. Which one passed? Which one will pass tomorrow? With the servers running on UTC (Coordinated Universal Time), does an order placed at 01:30 on Sunday morning in Istanbul get the discount?
The Answer: A Test Suite Tied to the Calendar
failed Bookstore.Pricing.Tests.LegacySundayPricingTests.Weekday_orders_pay_full_price (1ms)
Assert.Equal() Failure: Values differ
Expected: 100
Actual: 95.00
Test run summary: Failed!
total: 10
failed: 1
succeeded: 9
8 November 2026 is a Sunday; the Sunday test passed, the weekday test failed. Tomorrow it will be the other way round. One of the two tests is red every day, and which one depends on the calendar. The answer to the third question is bad as well: DateTime.Now reads the server's local time. If the server is on UTC, orders placed between 00:00 and 03:00 on Sunday in Istanbul are still Saturday for the server and get no discount. There is no way to test this code, because the input of the behavior under test (what day is it today?) is not given to the code from outside. In this post we apply, one by one, the design moves that make code testable: seams, functional core / imperative shell, Humble Object, and characterization tests for legacy code.
Constructor Injection and Seams
In Working Effectively with Legacy Code (2004), Michael Feathers defines a seam as a place where you can alter behavior in your program without editing in that place. Feathers lists three kinds: preprocessing seams (C/C++ macros), link seams (linking a different implementation at build or link time) and object seams (supplying a different object through an interface or a virtual method). In C# we use object seams in practice, and the cleanest way to open one is constructor injection: the dependency arrives as a constructor parameter, the class does not create it.
Typical dependencies that need a seam: TimeProvider instead of DateTime.Now/UtcNow; a generator function or interface instead of Guid.NewGuid() and Random; IOptions<T> and explicit parameters instead of static and singleton access (ConfigurationManager.AppSettings, HttpContext.Current); File, Environment, the network. What they share is that they read values from the environment they run in: if the test cannot control that value, the test result depends on the environment. The ASP.NET Core Web API (Application Programming Interface) series covers the mechanics of dependency injection, and post 14 of the C# İleri (Advanced C#) series covers taking time from outside; here we look at the question from the design side: where should the seam go?
Functional Core, Imperative Shell
The first reflex would be to give LegacySundayPricing a TimeProvider. A better answer comes from Gary Bernhardt's 2012 "Functional Core, Imperative Shell": move the code that makes decisions into a pure core, and leave everything that touches the world, such as I/O and time, in a thin shell.

public sealed record PriceBreakdown(decimal Subtotal, decimal SundayDiscount, decimal LoyaltyDiscount)
{
public decimal Total => Subtotal - SundayDiscount - LoyaltyDiscount;
}
// Functional core: no I/O, no time, only input and output
public static class PricingRules
{
public static PriceBreakdown Price(decimal subtotal, DayOfWeek localDay, int completedOrders)
{
decimal sunday = localDay == DayOfWeek.Sunday ? Round(subtotal * 0.05m) : 0m;
decimal loyalty = completedOrders >= 5 ? Round((subtotal - sunday) * 0.05m) : 0m;
return new PriceBreakdown(subtotal, sunday, loyalty);
}
private static decimal Round(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
}
public interface ICustomerHistory
{
Task<int> CompletedOrderCountAsync(string customerId, CancellationToken ct);
}
// Imperative shell: gathers data, calls the core, makes no decisions
public sealed class CheckoutPricing(ICustomerHistory history, TimeProvider time)
{
private static readonly TimeZoneInfo Istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
public async Task<PriceBreakdown> PriceAsync(string customerId, decimal subtotal, CancellationToken ct)
{
int completedOrders = await history.CompletedOrderCountAsync(customerId, ct);
DayOfWeek localDay = TimeZoneInfo.ConvertTime(time.GetUtcNow(), Istanbul).DayOfWeek;
return PricingRules.Price(subtotal, localDay, completedOrders);
}
}
The Sunday discount and the loyalty discount now live in PricingRules.Price, and all of this function's inputs are parameters. Its test, in the output-based style of post 08, without mocks:
[Theory]
[InlineData(DayOfWeek.Sunday, 0, 5.00, 0)]
[InlineData(DayOfWeek.Saturday, 0, 0, 0)]
[InlineData(DayOfWeek.Monday, 5, 0, 5.00)]
[InlineData(DayOfWeek.Sunday, 5, 5.00, 4.75)]
public void Discounts_for_a_100_euro_order(
DayOfWeek day, int completedOrders, decimal sunday, decimal loyalty)
{
PriceBreakdown price = PricingRules.Price(100m, day, completedOrders);
Assert.Equal(new PriceBreakdown(100m, sunday, loyalty), price);
}
The last row pins down how the two rules combine: the loyalty discount is calculated on the amount after the Sunday discount (95 × 5% = 4.75). Rules like this become clear while talking to the business, and they are documented with exactly this kind of test table. The shell only has to do two things right: gather the right data and compute the right day. A single test is enough for it, and that test looks at the time zone boundary:
[Fact]
public async Task Saturday_night_utc_is_already_sunday_in_istanbul()
{
// 7 November 2026 22:30 UTC = 8 November 01:30 Istanbul (Sunday)
FakeTimeProvider time = new(new DateTimeOffset(2026, 11, 7, 22, 30, 0, TimeSpan.Zero));
CheckoutPricing sut = new(new StubHistory(completed: 0), time);
PriceBreakdown price = await sut.PriceAsync("C-1", 100m, TestContext.Current.CancellationToken);
Assert.Equal(5.00m, price.SundayDiscount);
}
This test gives the same result today, tomorrow and on a CI (Continuous Integration) machine running in any time zone. The gain is not only testability: the time zone bug from Predict First became visible the moment the shell's single responsibility was named as "compute the local day".
Humble Object: Thinning What Is Hard to Test
Some code is hard to test by nature: BackgroundService loops, controllers, UI (User Interface) event handlers, callbacks called by the framework. The Humble Object pattern from Meszaros's xUnit Test Patterns says: make the hard-to-test part so thin that no logic worth testing is left in it; move the logic somewhere separate and testable.
// The decision under test: pure and deterministic
public static class LoyaltyReminders
{
public static IReadOnlyList<string> Select(IEnumerable<CustomerActivity> customers, DateOnly today) =>
customers
.Where(c => c.CompletedOrders >= 5 && today.DayNumber - c.LastOrder.DayNumber >= 60)
.Select(c => c.Email)
.ToList();
}
// Humble Object: only the loop and I/O, no logic
public sealed class LoyaltyReminderWorker(
ICustomerActivityStore store, IReminderSender sender, TimeProvider time) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using PeriodicTimer timer = new(TimeSpan.FromHours(24), time);
do
{
IReadOnlyList<CustomerActivity> customers = await store.LoadAsync(stoppingToken);
DateOnly today = DateOnly.FromDateTime(time.GetUtcNow().UtcDateTime);
foreach (string email in LoyaltyReminders.Select(customers, today))
{
await sender.SendAsync(email, stoppingToken);
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
}
[Fact]
public void Loyal_customers_silent_for_60_days_get_a_reminder()
{
CustomerActivity[] customers =
[
new("ada@example.com", Today.AddDays(-60), CompletedOrders: 7),
new("alan@example.com", Today.AddDays(-59), CompletedOrders: 7),
new("grace@example.com", Today.AddDays(-90), CompletedOrders: 4),
];
IReadOnlyList<string> selected = LoyaltyReminders.Select(customers, Today);
Assert.Equal(["ada@example.com"], selected);
}
The decision "who gets a reminder?" is tested with three rows of data and boundary values (day 60 included, day 59 excluded, 4 orders are not enough). Nothing worth testing is left in the worker itself: load, select, send, wait. Seeing it once inside a running host is the job of an integration test; testing the loop by advancing a FakeTimeProvider is the topic of post 11. The same idea applies to controllers: when a controller is a thin layer translating an HTTP (HyperText Transfer Protocol) request into the domain, the weight of the unit tests moves to the service behind it.
Legacy Code: Characterization Tests and Extract and Override
You do not always get to design code right from the start. When you face an old class without tests that needs to change, Feathers suggests two steps: first write tests that pin down the current behavior, then open seams in small, safe steps. A test that pins down current behavior is called a characterization test; it records not "what is right" but "what happens now".
// Legacy code: invoice number generator
public class LegacyInvoiceNumbers
{
public string Next(int sequence) => $"INV-{Now():yyyyMM}-{sequence:D5}";
// Extract and override: time moved into a virtual method
protected virtual DateTime Now() => DateTime.Now;
}
public sealed class LegacyInvoiceNumbersTests
{
private sealed class FixedClockInvoiceNumbers(DateTime now) : LegacyInvoiceNumbers
{
protected override DateTime Now() => now;
}
private readonly LegacyInvoiceNumbers sut = new FixedClockInvoiceNumbers(new DateTime(2026, 11, 8));
// Characterization test: pins today's behavior as it is
[Fact]
public void Formats_year_month_and_five_digit_sequence() =>
Assert.Equal("INV-202611-00042", sut.Next(42));
[Fact]
public void Sequence_above_99999_grows_to_six_digits() =>
Assert.Equal("INV-202611-100000", sut.Next(100_000));
}
"Extract and Override Call" is one of the smallest seam moves in Feathers's catalog: move the DateTime.Now call into a protected virtual method and override it with a subclass in the test. The public API did not change, the constructor did not change, the production behavior is the same. The second test is interesting: a sequence number above 99999 grows to six digits. Is that right or a bug? A characterization test does not say; it only says "this is how it is now". If accounting says it is a bug, you change the test and fix the code; but now you do it knowingly. Large-scale cleanup, that is, restructuring a legacy code base step by step, is the topic of the upcoming Refactoring series.
In Production
Register TimeProvider in the DI (dependency injection) container: services.AddSingleton(TimeProvider.System). Classes then take TimeProvider from the constructor, and tests pass a FakeTimeProvider. Accessing TimeProvider.System directly all over the application is using DateTime.Now under another name.
Make the time zone decision in one place. Bookstore's "Sunday" is Istanbul's Sunday; this knowledge lives once, in CheckoutPricing. If every class computes the time zone itself, orders around midnight fall on different days in different classes. Which time zone applies is a business decision and should be written down explicitly.
Try to grow the functional core. When a new business rule arrives, the first question should be "which data does this rule need, and can that data be passed as parameters?" If the answer is yes, the rule goes into the core and is tested without mocks; the shell only learns to gather the new data.
Common Pitfalls
Adding an interface to every class just for testing. IPricingRules, ILoyaltyReminders, IPriceBreakdownFactory... A pure function does not need an interface, because there is no reason to fake it: the real one is fast and deterministic. Interfaces are for dependencies at the system boundary (ICustomerHistory, IReminderSender); every other interface is a ceremony that spreads each change across two files. Most single-implementation interfaces are the infrastructure of the "one mock per collaborator" reflex from post 01.
Opening everything up to test a private method. Exposing internals to the test project with InternalsVisibleTo is sometimes reasonable (we used it for a setter in post 02 of this series), but making a private method internal just to test it ties tests to the implementation. Test private methods through public behavior. If a private method is complex enough to want its own tests, it probably wants to be a public method of a separate class or a pure function, like PricingRules.
Opening the seam at the wrong level. Wrapping DateTime.Now in an IClock interface and then setting up clock.Now.Returns(...) in every test works, but it leaves the decision where time is read. Read the clock in the shell, pass it to the core as a value.
Mistaking characterization tests for a specification. These tests are a record of "current behavior"; bugs are pinned down in them too. Say so in their names and comments so the next developer does not mistake them for business rules.
A Small Experiment
★☆☆ Without deleting LegacySundayPricing, change it to take a TimeProvider from the constructor and rewrite its two tests with FakeTimeProvider. Do the tests now give the same result whichever day they run?
★★☆ Add a new rule to PricingRules.Price: an extra 3% on orders of 200 EUR and above. Decide how the rule combines with the Sunday and loyalty discounts and document the decision with theory rows. Did anything change in the shell?
★★★ Find a class in your own project that calls DateTime.Now, DateTime.UtcNow or Guid.NewGuid(). First write its characterization tests with extract and override, then move the decision-making part into a pure function and move the tests to that function. How many tests became mock-free?
In the next post we turn to the test code itself: Test Data Builder, Object Mother, AutoFixture and Bogus for building test data, and the balance between DRY (Don't Repeat Yourself) and DAMP (Descriptive And Meaningful Phrases) in test code.