Module 4 · 4. Object-Oriented Design, Records, Interfaces, and SOLID · Lesson 11 of 24
Records, Interfaces, and Substitutability in Domain Models
Learning outcomes
Build a value-oriented model with explicit boundaries, write a behavioral contract before choosing an implementation, and split a reporting service according to what its callers need. You will run three independent console examples and then use the solved shared-test plan to write checks for their contracts.
1. Repair the model before depending on it
The starting Money example compared currency strings inside Add but allowed arbitrary constructor input. Two matching invalid strings could pass that comparison. It also left null operands, amount overflow, and the relationship between currency casing and equality unexplained. Matching strings alone is not currency validation.
For this lesson we choose a new, deliberately small policy: AAA, BBB and CCC are invented labels. They are not a real currency catalogue, and the rates below are invented constants. This is an in-memory programming exercise, not a financial model or a rounding policy.
- Construction accepts only those three labels, ignoring letter case. It stores uppercase text. It does not trim spaces; null throws ArgumentNullException, and blank, padded or unsupported labels throw ArgumentException. The parameter name is currency.
- Amount may be negative, zero or positive. The example does not invent an overdraft rule. Add rejects a null other argument first, then rejects a different stored label with InvalidOperationException. A decimal result outside the representable range throws OverflowException.
- Add returns a new Money. The original amount stays unchanged. Both properties are get-only, and Add routes its result through the constructor rather than offering a public property initializer that could bypass the currency check.
In the first program, new Money(12m, "aaa") is compared with the calculated sum. Both have the stored values 12 and AAA. The separate TagBatch experiment deliberately stores a mutable list: after editing copy.Tags, both printed counts are 2. The second with expression explicitly supplies a new list; adding to that list prints separate counts 2 and 3. Do not treat a record keyword or a with expression as a promise that every reachable object is protected from edits.
Run each complete listing in its own .NET 10 console project, replacing Program.cs. Do not paste all three into the same project: their helper type names overlap. These examples use only the standard library and do not access files, networks or accounts.
using System;
using System.Collections.Generic;
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var original = new Money(10m, "aaa");
var sum = original.Add(new Money(2m, "AAA"));
Console.WriteLine($"sum: {sum.Amount} {sum.Currency}; original: {original.Amount}");
Console.WriteLine($"equal: {sum == new Money(12m, "aaa")}");
try { original.Add(new Money(2m, "BBB")); }
catch (InvalidOperationException) { Console.WriteLine("mismatch rejected"); }
try { new Money(decimal.MaxValue, "AAA").Add(new Money(1m, "AAA")); }
catch (OverflowException) { Console.WriteLine("overflow rejected"); }
var batch = new TagBatch(new List<string> { "first" });
var copy = batch with { };
copy.Tags.Add("second");
Console.WriteLine($"list counts: {batch.Tags.Count}/{copy.Tags.Count}");
var detached = batch with { Tags = new List<string>(batch.Tags) };
detached.Tags.Add("third");
Console.WriteLine($"separate list counts: {batch.Tags.Count}/{detached.Tags.Count}");
public static class Codes
{
public static string Normalize(string value, string parameter)
{
if (value is null) throw new ArgumentNullException(parameter);
string normalized = value.ToUpperInvariant();
if (normalized is not ("AAA" or "BBB" or "CCC"))
throw new ArgumentException("Use AAA, BBB or CCC, without surrounding spaces.", parameter);
return normalized;
}
}
public sealed record Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Currency = Codes.Normalize(currency, nameof(currency));
Amount = amount;
}
public Money Add(Money other)
{
ArgumentNullException.ThrowIfNull(other);
if (!StringComparer.OrdinalIgnoreCase.Equals(Currency, other.Currency))
throw new InvalidOperationException("Currencies must match.");
return new Money(checked(Amount + other.Amount), Currency);
}
}
public sealed record TagBatch(List<string> Tags);Verified output:
sum: 12 AAA; original: 10 equal: True mismatch rejected overflow rejected list counts: 2/2 separate list counts: 2/3
2. Write the rate-provider contract before its classes
An interface names callable members; a class implementing these abstract members must supply them. The interface signature alone cannot express this exercise’s whitelist, rate values, error precedence or absence of side effects. Those are part of our documented contract and tests. C# interface reference.
- Input: from and to use the same invented-label policy. Validate from before to. Report their own parameter names, from or to. A token already canceled on entry wins over invalid arguments, and the OperationCanceledException carries that token.
- Results: a supported label converted to itself returns 1. AAA → BBB returns 2; BBB → AAA returns 0.5. The other four directed pairs throw KeyNotFoundException. There is no live lookup, fallback rate, inferred reverse rate or silently fabricated success.
- Repeated calls return the same fixture result. These two implementations complete the calculation immediately and do not change caller-owned state. This exercise checks cancellation on entry; it does not promise mid-calculation interruption.
- Callers observe failure around both the method call and its await. This contract does not require failure to be delayed until awaiting. The caller below consumes each returned operation once and uses only IExchangeRateProvider.
Now implement the contract twice: a dictionary lookup and explicit branches. ConvertTenAsync is the real consumer. Nothing in its body selects a concrete provider type. The two successful 20 results, the two missing-pair failures, and the cancellation example make the observable promise concrete.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
foreach (IExchangeRateProvider provider in new IExchangeRateProvider[]
{ new TableRates(), new BranchRates() })
{
// The same caller uses only the interface, regardless of implementation.
decimal converted = await ConvertTenAsync(provider);
Console.WriteLine($"{provider.GetType().Name}: {converted}");
try { await provider.GetRateAsync("AAA", "CCC", default); }
catch (KeyNotFoundException) { Console.WriteLine("missing pair rejected"); }
}
using var source = new CancellationTokenSource();
source.Cancel();
try { await new TableRates().GetRateAsync(null!, null!, source.Token); }
catch (OperationCanceledException) { Console.WriteLine("cancellation before validation"); }
static async ValueTask<decimal> ConvertTenAsync(IExchangeRateProvider provider)
=> checked(10m * await provider.GetRateAsync("aaa", "BBB", default));
public static class Codes
{
public static string Normalize(string value, string parameter)
{
if (value is null) throw new ArgumentNullException(parameter);
string normalized = value.ToUpperInvariant();
if (normalized is not ("AAA" or "BBB" or "CCC"))
throw new ArgumentException("Use AAA, BBB or CCC, without surrounding spaces.", parameter);
return normalized;
}
}
public interface IExchangeRateProvider
{
ValueTask<decimal> GetRateAsync(string from, string to, CancellationToken cancellationToken);
}
public sealed class TableRates : IExchangeRateProvider
{
private readonly Dictionary<(string From, string To), decimal> rates = new()
{
[("AAA", "BBB")] = 2m,
[("BBB", "AAA")] = 0.5m
};
public ValueTask<decimal> GetRateAsync(string from, string to, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
from = Codes.Normalize(from, nameof(from));
to = Codes.Normalize(to, nameof(to));
if (from == to) return new ValueTask<decimal>(1m);
if (rates.TryGetValue((from, to), out decimal rate)) return new ValueTask<decimal>(rate);
throw new KeyNotFoundException("No synthetic rate for this pair.");
}
}
public sealed class BranchRates : IExchangeRateProvider
{
public ValueTask<decimal> GetRateAsync(string from, string to, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
from = Codes.Normalize(from, nameof(from));
to = Codes.Normalize(to, nameof(to));
if (from == to) return new ValueTask<decimal>(1m);
decimal value = (from, to) switch
{
("AAA", "BBB") => 2m,
("BBB", "AAA") => 0.5m,
_ => throw new KeyNotFoundException("No synthetic rate for this pair.")
};
return new ValueTask<decimal>(value);
}
}Verified output:
TableRates: 20 missing pair rejected BranchRates: 20 missing pair rejected cancellation before validation
The deliberately broken verification implementation returns 0 for every request. It still satisfies the method signature. Our first shared assertion asks for AAA → BBB and expects 2, so it rejects that implementation before a caller can treat zero as a valid conversion. A substitute that accepts fewer allowed inputs or changes a promised result breaks this specific contract. These checks are stronger evidence than merely compiling a class with the interface name.
The exact exception types in this lesson are chosen API promises, not a universal rule that every replacement anywhere must throw identical exceptions. Decide what callers may rely on, including cancellation and recoverable failures, then test those promises. Additional production concerns such as remote failures or time-varying rates would require a different contract; they are intentionally absent here.
3. Practice: split reporting by capability
Original task: split a broad reporting service into query and export capabilities, and define tests every implementation must pass. Start with the caller that only wants an open-item count: why should it receive an Export member?
// Before: both clients receive both capabilities.
public interface IReportingService
{
ReportRow[] Query(string status);
string Export(ReportRow[] rows);
}Before reading the solution, write two narrow interfaces, a count-only caller, a download caller and one failing test that would expose a bad replacement. Use the following authored contract so the implementations have a common target.
- ReportRow: a positive Id, a non-null/nonblank Status, and a non-null Title. Empty titles are allowed. Constructor validation runs in that order. Invalid Id throws ArgumentOutOfRangeException; null text throws ArgumentNullException; blank Status throws ArgumentException. Properties have no setters.
- Query: null status throws ArgumentNullException; blank status throws ArgumentException. Other text is matched exactly and case-sensitively, without trimming. Results are sorted by Id ascending. Unknown text returns an empty array, not null. A nonempty result is a detached array: replacing an element must not change later queries. Empty arrays may be shared.
- Export: produce the fixed header id,status,title and a newline, then one row per input item in input order. Preserve duplicates. Format Id without culture-dependent separators. Quote both text cells, double embedded quotes, and keep commas or newlines inside those quotes. End every row with a newline character (\n), regardless of operating system.
- Export rejects a null array with ArgumentNullException and any null element with ArgumentException, both naming rows. It does not reorder or replace elements, and repeated calls return the same string. An empty array returns just the header. This exporter returns text; it never writes a file. This is our chosen output format for synthetic strings, not a claim of spreadsheet-formula protection or a pipeline for untrusted imports.
Solved refactor
IReportQuery serves CountOpen. DownloadOpen explicitly requests query and export capabilities because it uses both. A storage class does not have to implement Export and pretend it can export. The array-backed query and loop-based CSV exporter below are a complete solution; the shared verification suite also exercises an indexed query and a join-based exporter against the same promises.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
IReportQuery query = new ArrayQuery();
IReportExporter exporter = new CsvLoopExporter();
VerifyQuery(query);
Console.WriteLine($"open count: {Clients.CountOpen(query)}");
Console.Write(Clients.DownloadOpen(query, exporter));
var snapshot = query.Query("open");
snapshot[0] = new ReportRow(99, "open", "replacement");
Console.WriteLine($"fresh first id: {query.Query("open")[0].Id}");
Console.WriteLine($"unknown count: {query.Query("archived").Length}");
static void VerifyQuery(IReportQuery query)
{
var rows = query.Query("open");
if (rows.Length != 2 || rows[0].Id != 1 || rows[1].Id != 3)
throw new Exception("Ordered open rows do not match the contract.");
rows[0] = new ReportRow(99, "open", "changed");
if (query.Query("open")[0].Id != 1)
throw new Exception("A result array exposed query storage.");
}
public interface IReportQuery { ReportRow[] Query(string status); }
public interface IReportExporter { string Export(ReportRow[] rows); }
public sealed record ReportRow
{
public int Id { get; }
public string Status { get; }
public string Title { get; }
public ReportRow(int id, string status, string title)
{
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
Rules.Status(status);
ArgumentNullException.ThrowIfNull(title);
Id = id; Status = status; Title = title;
}
}
public static class Rules
{
public static void Status(string status)
{
ArgumentNullException.ThrowIfNull(status);
if (string.IsNullOrWhiteSpace(status)) throw new ArgumentException("Status must contain text.", nameof(status));
}
public static void Rows(ReportRow[] rows)
{
ArgumentNullException.ThrowIfNull(rows);
if (rows.Any(row => row is null)) throw new ArgumentException("Rows must not contain null.", nameof(rows));
}
}
public static class Fixture
{
// Deliberately stored out of order, so the query must honor its ordering promise.
public static ReportRow[] Create() => new[]
{
new ReportRow(3, "open", "gamma,delta"),
new ReportRow(2, "closed", "beta"),
new ReportRow(1, "open", "alpha")
};
}
public sealed class ArrayQuery : IReportQuery
{
private readonly ReportRow[] rows = Fixture.Create();
public ReportRow[] Query(string status)
{
Rules.Status(status);
return rows.Where(row => StringComparer.Ordinal.Equals(row.Status, status))
.OrderBy(row => row.Id).ToArray();
}
}
public static class CsvCell
{
public static string Encode(string value) => "\"" + value.Replace("\"", "\"\"") + "\"";
}
public sealed class CsvLoopExporter : IReportExporter
{
public string Export(ReportRow[] rows)
{
Rules.Rows(rows);
var result = new StringBuilder("id,status,title\n");
foreach (var row in rows)
{
result.Append(row.Id.ToString(CultureInfo.InvariantCulture)).Append(',')
.Append(CsvCell.Encode(row.Status)).Append(',')
.Append(CsvCell.Encode(row.Title)).Append('\n');
}
return result.ToString();
}
}
public static class Clients
{
public static int CountOpen(IReportQuery query) => query.Query("open").Length;
public static string DownloadOpen(IReportQuery query, IReportExporter exporter)
=> exporter.Export(query.Query("open"));
}Verified output:
open count: 2 id,status,title 1,"open","alpha" 3,"open","gamma,delta" fresh first id: 1 unknown count: 0
Trace the solution: the fixture is stored in order 3, 2, 1, yet Query("open") returns 1 then 3. The exporter keeps that order and quotes gamma,delta as one cell. Replacing snapshot[0] with Id 99 changes only the returned array; the next query still starts with Id 1. Query("archived") has no fixture matches and returns zero rows.
4. Solved shared-test plan
The plan below gives expected behaviors for tests you write; it is not another complete runnable program. Start with the supplied TableRates, BranchRates, ArrayQuery and CsvLoopExporter. BrokenZeroRates, IndexedQuery and CsvJoinExporter are supplementary verification fixtures mentioned as evidence; their definitions are not printed in this lesson or its PDF, and none is needed to run the three listings above.
Run each row of this plan against every implementation of the named capability. Expected values come from the contract and fixture, not from comparing two implementations with each other: two implementations can share the same bug.
- Money: 10 AAA + 2 aaa yields 12 AAA while the original remains 10; zero and negative amounts remain allowed. Reject null, blank, padded and unknown labels, a null operand and a different label. Test overflow at both decimal extremes.
- Rate provider: test both directed rates, all three identity pairs, all four missing pairs, mixed-case labels, invalid from and to inputs, repeated calls, null-from precedence, and an already-canceled token with invalid arguments. Verify the cancellation token too. Run the identical suite for
TableRatesandBranchRates. For a negative-control exercise, write a provider that returns0for every request; the first rate assertion must reject it.
- Query: assert full ordered rows for open and closed, empty results for archived, OPEN and padded open, detached nonempty arrays, unchanged later queries after replacing an element, and null/blank failures. Start with
ArrayQuery; apply the same checks to any query replacement you write.
- Exporter: assert the exact string for empty input, a title containing a quote/comma/newline, an empty title, and repeated duplicate rows in deliberately unsorted order. Assert repeatability and unchanged input element references, then null-array and null-element failures. Start with
CsvLoopExporter; apply the same checks to any exporter replacement you write.
- Composition: run CountOpen and DownloadOpen for every query/exporter pairing. The expected open count is 2 and the CSV must match the worked example exactly. This checks the real callers as well as the isolated capabilities.
A minimal assertion is to throw if expected and actual differ. The supplied VerifyQuery receives IReportQuery. Write an export test that receives IReportExporter in the same way; neither test needs a storage-specific downcast. The reporting program already calls VerifyQuery. Here it is again as an excerpt to show the shared-test shape:
static void VerifyQuery(IReportQuery query)
{
var rows = query.Query("open");
if (rows.Length != 2 || rows[0].Id != 1 || rows[1].Id != 3)
throw new Exception("Ordered open rows do not match the contract.");
rows[0] = new ReportRow(99, "open", "changed");
if (query.Query("open")[0].Id != 1)
throw new Exception("A result array exposed query storage.");
}Interview check: worked answers
- When is this Money record useful? The exercise wants two independently constructed amounts with the same normalized stored values to compare equal. The constructor and Add own the rules; the declaration alone would not invent them. A model whose identity is independent of its current field values needs a different equality decision.
- Why split the interfaces? CountOpen needs only Query. Its dependency now says exactly that. Splitting files without changing this dependency would not solve the problem.
- What proves substitutability here? Both provider implementations pass the same positive, boundary and failure checks, and the same conversion caller works with each. This is bounded evidence for the written contract, not a proof about every possible future behavior.
- Which mistake is most tempting? Returning an empty result for a missing rate because it seems convenient. Our query contract allows an empty result for no matches; our rate contract requires a missing-pair exception. Similar-looking methods do not automatically share the same failure policy.
Reference notes
Language/API checks: interface declarations; decimal arithmetic and overflow; cancellation-token check. The labels, rates, reporting fixture, caller policies and test cases are original examples. The checked decimal operations reject overflow; ThrowIfCancellationRequested reports cancellation with OperationCanceledException.
Analogy
Imagine two museum information desks offering the same advertised service. One clerk searches index cards; the other follows a short decision chart. Visitors can use either desk only if both honor the same rules for accepted requests, answers and refusals. A desk that prints zero for every request will fail a check that expects a particular answer, even if its sign and request form look correct.
Mapping. The two desks are TableRates and BranchRates; the advertised rules are the lesson's written behavioral contract. The shared tests challenge those promises, including failures and cancellation on entry. Giving a count-only caller just IReportQuery resembles directing a visitor to an information service without also requiring an export service.
Where it stops. A matching interface signature does not enforce every advertised rule, and a finite test suite gives bounded evidence rather than a proof of all future behavior. This picture focuses on substitutability; record equality, shared mutable members and the invented-label validation policy still need their own explicit rules and examples.