Module 4 · 4. Object-Oriented Design, Records, Interfaces, and SOLID · Lesson 12 of 24
SOLID with Composition, Dependency Injection, and Honest Boundaries
Learning outcomes
Use SOLID to identify useful change boundaries, compose a service from collaborators, and test time-dependent decisions without waiting for the wall clock. Explain dependency inversion separately from dependency injection and its containers.
SOLID as design questions
Use these principles as review questions, not a scorecard requiring more classes.
- Single responsibility: group code that changes for the same reason. A class need not have just one method.
- Open/closed: allow a known variation through an extension point without rewriting stable consumers. Do not predict every future variation.
- Liskov substitution: a replacement must honor the caller's behavioral expectations, not merely implement matching signatures. Rejecting inputs allowed by the original contract strengthens preconditions and breaks substitution.
- Interface segregation: give consumers the capabilities they need instead of a broad dependency carrying unrelated operations.
- Dependency inversion: keep high-level decisions independent of infrastructure details through suitable abstractions.
For this exercise, expiry rules, time acquisition, notification orchestration, and email infrastructure are different change boundaries. Composition connects collaborators rather than inheriting a mail-sending base class. There is no need for an interface for every class: the service uses the concrete, small SubscriptionPolicy.
Define the contract before refactoring
This synthetic subscription is active only when expiresAt is strictly later than the sampled time. At exact equality it is expired. DateTimeOffset comparisons compare instants, so a different offset representing the same instant does not change the decision.
IClock supplies a sampled timestamp. Each policy call reads it exactly once; the decision applies to that sample, not to a guarantee that the subscription remains active after the method returns. FakeClock returns its configured value until the test changes it. Neither this interface nor this lesson promises monotonically increasing time.
Worked example 1: test the expiry boundary
Run this complete program in its own console project. It preserves the original IClock, SubscriptionPolicy, and SystemClock design while adding an explicit null guard and observable boundary cases. The fake moves time instantly; there are no sleeps.
using System;
public static class Program
{
public static void Main()
{
var expiry = new DateTimeOffset(2030, 1, 1, 12, 0, 0, TimeSpan.Zero);
var clock = new FakeClock(expiry.AddTicks(-1));
var policy = new SubscriptionPolicy(clock);
Console.WriteLine($"Before: {policy.IsActive(expiry)}");
clock.Now = expiry;
Console.WriteLine($"At: {policy.IsActive(expiry)}");
clock.Now = expiry.AddTicks(1);
Console.WriteLine($"After: {policy.IsActive(expiry)}");
clock.Now = expiry.ToOffset(TimeSpan.FromHours(2));
Console.WriteLine($"Same instant, different offset: {policy.IsActive(expiry)}");
Console.WriteLine($"Clock reads: {clock.Reads}");
}
}
public interface IClock
{
DateTimeOffset UtcNow { get; }
}
public sealed class SubscriptionPolicy
{
private readonly IClock clock;
public SubscriptionPolicy(IClock clock)
{
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
public bool IsActive(DateTimeOffset expiresAt)
{
DateTimeOffset now = clock.UtcNow; // Exactly one read per decision.
return expiresAt > now; // Equality means expired.
}
}
public sealed class SystemClock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
public sealed class FakeClock : IClock
{
public DateTimeOffset Now { get; set; }
public int Reads { get; private set; }
public FakeClock(DateTimeOffset now) { Now = now; }
public DateTimeOffset UtcNow
{
get { Reads++; return Now; }
}
}Verified output
Before: True At: False After: False Same instant, different offset: False Clock reads: 4
Trace the four calls: one tick before expiry is active; equality and one tick after are expired; 14:00 at offset +02:00 is the same instant as 12:00 at +00:00. Four decisions cause four reads. The one-tick inputs test comparison boundaries; they make no claim about real clock resolution.
Practice: separate direct clock and email dependencies
Suppose a service directly checks DateTimeOffset.UtcNow and constructs an email sender inside NotifyIfExpired. Changing the transport now touches the service, and testing exact expiry depends on real time. Refactor it under these requirements:
- Keep the strict expiry rule; reject a missing policy, clock, or sink at construction.
- Reject a blank recipient before reading time or recording anything. For this exercise a recipient is an opaque nonblank string, not a validated email address.
- Return false and record nothing for an active subscription. For an expired subscription, call the sink once and return true only if that call returns normally.
- Use a fake clock and a memory-only recording sink. Do not send email or access networks, credentials, or files.
Worked solution: compose policy and notification infrastructure
This second program is independent: replace Program.cs in a separate console project with the whole block. The repeated definitions deliberately make it runnable without code from example 1.
using System;
using System.Collections.Generic;
public static class Program
{
public static void Main()
{
var expiry = new DateTimeOffset(2030, 1, 1, 12, 0, 0, TimeSpan.Zero);
var clock = new FakeClock(expiry.AddTicks(-1));
var sink = new RecordingEmailSink();
var service = new SubscriptionService(new SubscriptionPolicy(clock), sink);
Console.WriteLine($"Active notified: {service.NotifyIfExpired("learner@example.invalid", expiry)}");
clock.Now = expiry;
Console.WriteLine($"Boundary notified: {service.NotifyIfExpired("learner@example.invalid", expiry)}");
Console.WriteLine($"Recorded: {sink.Count}");
Console.WriteLine($"Recipient: {sink.RecipientAt(0)}");
Console.WriteLine($"Repeat notified: {service.NotifyIfExpired("learner@example.invalid", expiry)}");
Console.WriteLine($"Recorded after repeat: {sink.Count}");
}
}
public interface IClock
{
DateTimeOffset UtcNow { get; }
}
public sealed class SubscriptionPolicy
{
private readonly IClock clock;
public SubscriptionPolicy(IClock clock)
{
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
public bool IsActive(DateTimeOffset expiresAt)
{
DateTimeOffset now = clock.UtcNow; // Exactly one read per decision.
return expiresAt > now; // Equality means expired.
}
}
public sealed class SystemClock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
public sealed class FakeClock : IClock
{
public DateTimeOffset Now { get; set; }
public int Reads { get; private set; }
public FakeClock(DateTimeOffset now) { Now = now; }
public DateTimeOffset UtcNow
{
get { Reads++; return Now; }
}
}
public interface IEmailSink
{
// One call requests one recording; no delivery or deduplication promise.
void SendExpiryNotice(string recipient);
}
public sealed class RecordingEmailSink : IEmailSink
{
private readonly List<string> recipients = new();
public int Count => recipients.Count;
public string RecipientAt(int index) => recipients[index];
public void SendExpiryNotice(string recipient)
{
if (string.IsNullOrWhiteSpace(recipient))
throw new ArgumentException("Recipient is required.", nameof(recipient));
recipients.Add(recipient); // Memory only. Never sends real email.
}
}
public sealed class SubscriptionService
{
private readonly SubscriptionPolicy policy;
private readonly IEmailSink email;
public SubscriptionService(SubscriptionPolicy policy, IEmailSink email)
{
this.policy = policy ?? throw new ArgumentNullException(nameof(policy));
this.email = email ?? throw new ArgumentNullException(nameof(email));
}
public bool NotifyIfExpired(string recipient, DateTimeOffset expiresAt)
{
if (string.IsNullOrWhiteSpace(recipient))
throw new ArgumentException("Recipient is required.", nameof(recipient));
if (policy.IsActive(expiresAt)) return false;
email.SendExpiryNotice(recipient);
return true; // The sink returned; this is not proof of delivery.
}
}Verified output
Active notified: False Boundary notified: True Recorded: 1 Recipient: learner@example.invalid Repeat notified: True Recorded after repeat: 2
Why this solves the refactor
SubscriptionPolicy owns the expiry decision. SystemClock adapts the real clock; FakeClock supplies controlled time. SubscriptionService coordinates the decision and one notification attempt. RecordingEmailSink owns a private in-memory record of calls. The entry point chooses and connects the collaborators; the service neither constructs infrastructure nor looks it up globally.
The constructor calls in Main are dependency injection by hand. Dependency inversion concerns which capabilities the consumer depends on. A DI container can automate object creation and lifetime management; using one does not by itself fix a poor boundary. No container is needed here.
The timestamp is sampled when policy.IsActive runs, not when the service or policy is constructed. All constructor dependencies are mandatory. The service does not read the clock again before sending: an active result can become stale immediately after the sample. A different business requirement would need an explicit new contract.
Honest guarantees and failure cases
- The sink is a simulation, not an email implementation. Its count measures recorded requests, not delivery.
- Repeated calls at expiry record repeated notices. There is no deduplication, persistence, retry, or exactly-once guarantee.
- A sink exception propagates. The service does not catch it, retry, or return true. A real transport might fail before or after accepting a request; this example does not resolve that ambiguity.
- Both clock implementations fit the consumer's narrow timestamp contract. A clock that throws instead of providing an expected sample cannot be substituted in a success-path test simply because its signature matches.
- The fake and recording sink are single-threaded teaching objects. The example makes no concurrency guarantee.
Try it before reading the answers
- Replace > with >=. Which outputs change?
- Move the clock read into SubscriptionPolicy's constructor. Why does advancing FakeClock stop working?
- Call NotifyIfExpired twice at expiry. Does composition prevent duplicate notices?
- Should a new transport require a new subscription-policy interface?
Answers
- Equality becomes active: example 1's At and Same instant lines become True. In example 2 both calls at equality return False; no entry exists, so RecipientAt(0) throws instead of producing the remaining output. This is a business-rule change, not an improvement.
- The policy would reuse the construction-time snapshot. The shown implementation samples once per decision, so changes to the fake affect subsequent calls.
- No. The recording count reaches two. Preventing duplicates needs a defined identity, storage, and failure policy beyond this exercise.
- No. A compatible IEmailSink adapter changes infrastructure wiring without changing the expiry policy. Introduce another abstraction only for a concrete consumer need.
Test checklist
The accompanying optional contract harness checks before/equal/after expiry, equal instants with different offsets, one clock read per decision, all three constructor null contracts, blank-recipient validation before side effects, active and expired paths, preserved recipient, repeated calls, and a synthetic failing sink called once. Verified harness output: PASS: 15 checks. These are deterministic unit-level checks of the authored model, not production delivery tests.
Interview check
Explain this design in one minute: the subscription decision is isolated from time acquisition and notification infrastructure. Narrow contracts allow controlled substitutes. Constructor injection supplies them explicitly; a container is optional. Composition lets us change a collaborator without deriving a new service. The benefits must justify the extra types, and this example does not claim delivery reliability.
Sources and scope
The programs, synthetic requirements, exercises, and output traces are original teaching examples. Principle terminology was checked against Robert C. Martin's SOLID discussion. Constructor injection and container concepts were checked against Microsoft's dependency injection overview. Instant comparison semantics were checked against DateTimeOffset greater-than documentation.
Analogy
Imagine a community-hall coordinator checking expired room bookings. A rule card explains the expiry decision, a timekeeper supplies the current reading, and a messenger accepts notice requests. For rehearsal, a colleague supplies a chosen time and a note-taker records each request. The organizer assigns these roles before the coordinator starts, so testing a boundary does not require waiting for the real clock.
Mapping. The rule card is SubscriptionPolicy, the timekeeper supplies IClock, and the notice role supplies IEmailSink. SubscriptionService coordinates them. Choosing the collaborators in Main is injection by hand; deciding which capabilities the coordinator depends on is the design boundary. The concrete policy remains small without needing an interface of its own.
Where it stops. A time reading describes one sampled instant; it does not promise that time advances monotonically or that the decision remains current. A recorded request does not prove delivery or prevent another request on the next call. Assigning roles does not automatically provide retries, thread safety or a good design; each boundary must serve an actual caller need.