Title: Our Renewal Reminders Went to Expired Customers, and We Couldn’t Test It
The task sounded simple: email customers 3 days before their subscription expires.
A few weeks after launch, support forwarded us angry messages. Customers whose subscriptions had already expired were getting “renew soon” emails every single day.
The bug was one missing condition. What hurt more was the reason we missed it: the code was impossible to unit test.
What Dependency Inversion really means
The Dependency Inversion Principle says that high-level business logic should not depend on low-level details like databases, email servers, or the system clock. Both should depend on abstractions, which are usually interfaces.
Here is the code we shipped:
public class RenewalReminderService
{
public void SendReminders()
{
var repository = new SqlSubscriptionRepository();
var smtp = new SmtpClient("smtp.company.com");
var expiring = repository.GetAll()
.Where(s => (s.ExpiresOn - DateTime.Now).TotalDays <= 3);
foreach (var subscription in expiring)
{
smtp.Send("no-reply@company.com", subscription.Email,
"Renew soon", "Your plan expires soon.");
}
}
}
Look at the condition. If a subscription expired two weeks ago, the difference is negative, and a negative number is always less than or equal to 3. Expired customers were included.
Why nobody caught it
To test this method, you would need:
- A real SQL database with test data
- A real SMTP server, or you’d email real people
- A way to control what DateTime.Now returns, which is impossible
So nobody wrote a test. The high-level rule, “who gets a reminder,” was welded to three low-level details.
The fix: depend on abstractions
First we define what the business logic needs, next to the business logic:
public interface ISubscriptionRepository
{
Task<IReadOnlyList<Subscription>> GetActiveAsync(CancellationToken ct);
}
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body, CancellationToken ct);
}
For time, .NET 8 and later has TimeProvider built in, so we don’t need to invent our own clock interface. Now the service:
public class RenewalReminderService
{
private readonly ISubscriptionRepository _subscriptions;
private readonly IEmailSender _email;
private readonly TimeProvider _time;
public RenewalReminderService(
ISubscriptionRepository subscriptions,
IEmailSender email,
TimeProvider time)
{
_subscriptions = subscriptions;
_email = email;
_time = time;
}
public async Task SendRemindersAsync(CancellationToken ct)
{
var now = _time.GetUtcNow();
var subscriptions = await _subscriptions.GetActiveAsync(ct);
foreach (var s in subscriptions)
{
var daysLeft = (s.ExpiresOn - now).TotalDays;
if (daysLeft is >= 0 and <= 3)
await _email.SendAsync(s.Email, "Renew soon", "Your plan expires soon.", ct);
}
}
}
Now the rule is clear and readable, and the missing lower bound (>= 0) is obvious.
The test we couldn’t write before
[Fact]
public async Task Does_not_remind_expired_subscribers()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 10, 13, 0, 0, 0, TimeSpan.Zero));
var repository = new FakeSubscriptionRepository(
new Subscription { Email = "a@x.com", ExpiresOn = time.GetUtcNow().AddDays(-1) });
var email = new FakeEmailSender();
var service = new RenewalReminderService(repository, email, time);
await service.SendRemindersAsync(CancellationToken.None);
Assert.Empty(email.Sent);
}
No database, no mail server, and the clock is under our control. FakeTimeProvider comes from the Microsoft.Extensions.TimeProvider.Testing package.
Wiring it up
In production, the real implementations are plugged in at startup:
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<ISubscriptionRepository, SqlSubscriptionRepository>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<RenewalReminderService>();
Where the interfaces live matters
ISubscriptionRepository and IEmailSender belong with the business logic, not with the SQL or SMTP code. SqlSubscriptionRepository and SmtpEmailSender depend on those interfaces, not the other way around. That is the “inversion”: the details now point toward the business rules.
Three terms people mix up
- Dependency Inversion is the principle: depend on abstractions
- Dependency Injection is the technique: pass dependencies in through the constructor
- The IoC container is the tool that does the passing for you
When not to do this
You don’t need an interface for every class. Invert dependencies at the boundaries: databases, network calls, the file system, the clock, and randomness. A small helper that formats a string doesn’t need one.
The lesson
If you can’t write a fast unit test for your most important logic, it’s usually because it creates its own dependencies. Make the business rule depend on interfaces, inject the details from outside, and the code becomes testable and replaceable at the same time.
Leave a Reply