Foundations Studio
Chapters 1 and 2 gave you the vocabulary — pillars, relationships, UML, SOLID. This studio makes you use it: you'll design a fresh domain from scratch, defend every decision in writing before seeing a model answer, and drive three real C# projects from red tests to green. By the end you'll have practised exactly what the exam asks: propose a design, justify it, draw it, and spot what's wrong with the alternative.
How studios work
Every lab in a studio runs the same three-beat loop:
A design question is posed. You commit an answer in the box first — it saves in your browser — then climb the hint ladder if stuck, and only then open the model answer and its marking rubric. Committing before revealing is the whole point: it converts reading into retrieval.
You switch to the Labs-Code/labs/ folder and make failing xUnit tests pass. The tests
are the spec — red is your to-do list, green is proof the design works. Run everything with
dotnet test inside a lab's Starter/ folder.
An exam-style prompt: "justify X over Y for this scenario", with marks attached. This is the module's favourite question shape — being right isn't enough; you must argue the lifetime, the coupling, the axis of change.
You need the .NET SDK 10+ (dotnet --version to check). Each lab folder is independent —
no solution files, no IDE required. cd into a lab's Starter/, run
dotnet test, watch it fail, open the source, fill in the // TODO markers, re-run.
A full reference implementation lives in each lab's Solution/ — peek only after attempting.
The Labs-Code/README.md has the same instructions.
The FitCore scenario
FitCore operates fitness centres. Each centre has rooms (studios, weight floors) fitted with equipment (rowers, racks — bolted down, asset-tagged by the centre). The centre employs trainers, who freelance across several centres. Members hold a credit balance, join under a fee plan (Standard flat-rate, Premium unlimited, and marketing wants a pay-as-you-go Student plan next quarter), and book class sessions (Spin at 6pm with coach Ravi, capacity 20). Monthly, the system charges each member per their plan and emails a receipt. If a centre closes, its rooms and equipment are scrapped; its trainers and members carry on elsewhere.
Everything below works this one brief, the way a past-paper scenario would. Sketch on paper as you go — the exam is a paper exercise.
Step 1 — Identify the abstractions
Underline the candidate nouns in the brief (there are at least ten). Propose the class list for a first design. For every noun you reject as a class, say what it becomes instead (an attribute? a string? a value on another class?). One noun in the brief deserves to be an abstraction with multiple implementations rather than a plain class — name it. (6 marks)
Hint 1 — nudge
Noun/verb parsing from chapter 1: nouns are candidate classes, but only candidates. Test each: does it have both state and behaviour the system needs? "Credit balance" is a noun — is it a class?
Hint 2 — stronger
Look at the sentence about fee plans. Three variants exist today and a fourth is already announced. When variants of a behaviour multiply, what kind of type do you reach for — a class, or a contract that classes implement? (Chapter 2 calls this encapsulating what varies.)
Model answer & rubric
Classes: FitnessCentre, Room, Equipment,
Trainer, Member, ClassSession, Booking (the
member-session link carries data — who, when — so it earns classhood), and a billing service.
Rejected nouns: credit balance → a decimal attribute of
Member (state, no independent behaviour or identity); receipt → the message a
notifier sends, not a stored entity (in this scope); capacity → an int invariant
on ClassSession. The abstraction: fee plan. Standard, Premium
and the announced Student plan are three behaviours behind one contract — model it as an
IFeePlan interface now, because the brief tells you a new variant is coming.
Remember chapter 1's airplane lesson: an abstraction
keeps only what this program needs — no HeartRate on Member.
- ✔ Full credit: sensible class list (6–8 classes) + at least two rejected nouns with the "attribute, not class" argument + fee plan identified as the interface, justified by the announced variant.
- ◐ Partial: correct class list but fee plan modelled as a plain class or an enum — workable today, but misses the signalled axis of change.
- ✘ Common mistake: promoting every noun ("Balance", "Receipt", "Capacity") to a class. Class bloat is a design smell the examiner probes for.
Step 2 — Encapsulation choices
Member, what is public, and why?A member's credit balance must never go negative, and top-ups must be positive. Specify
Member's surface: which attributes are private, which operations are public, and — the part
that earns marks — which invariant each guard protects. Write the C# property/method signatures.
(4 marks)
Hint 1 — nudge
Re-read chapter 1's BankAccount. The question is never "make the field private" — it's "what are the only sanctioned doors in, and what does each door check?"
Model answer & rubric
public decimal Credits { get; private set; } // read anywhere, written only in here
public void AddCredits(decimal amount) // guard: amount > 0
public void Charge(decimal amount) // guards: amount > 0 AND amount <= Credits
Credits is readable (the UI needs it) but only settable internally — so the invariant
"credits ≥ 0" is enforced in exactly two places instead of trusted to every caller forever. The guard
on Charge rejecting non-positive amounts matters as much as the sufficiency check:
without it, Charge(-100) would increase the balance — the precise bug
encapsulation exists to prevent.
- ✔ Full credit: private-set state + both guarded operations + each guard tied to a named invariant.
- ◐ Partial: private field with public getter/setter pair — you hid the field but re-opened the door; a public setter with no validation is a public field in a suit.
- ✘ Common mistake: guarding only the "insufficient funds" case and letting negative amounts through the same method.
Step 3 — Inheritance hierarchy design
StudentMember and PremiumMember subclass Member?A junior developer proposes: class StandardMember : Member,
class PremiumMember : Member, class StudentMember : Member, each overriding
GetMonthlyFee(). It compiles and works. Critique the proposal: give two concrete failure
scenarios this hierarchy walks into, and propose the alternative structure. (6 marks)
Hint 1 — nudge
What happens on the day a Standard member upgrades to Premium? In C#, can an object change its class at runtime?
Hint 2 — stronger
Chapter 2's class-explosion argument: what happens when a second independent axis appears — say, corporate vs individual billing — across three plans?
Model answer & rubric
Failure 1 — identity is frozen: membership tier is a state that changes
(upgrades, downgrades, student graduates), but an object's class is fixed at construction. Upgrading
means destroying the member and rebuilding it — losing object identity, bookings, and history.
Failure 2 — combinatorial explosion: add one more axis (corporate billing, family
add-on) and you need CorporateStudentMember, FamilyPremiumMember… the
explosion chapter 2 warns about.
Alternative: one concrete Member that has a
IFeePlan — composition. Swapping the plan object at runtime is an upgrade. Inheritance is
the right tool when the subtype differs in what it is permanently; here the variation is in
how the fee is computed, a behaviour — behaviour
varies → compose.
- ✔ Full credit: both failure modes (runtime change + explosion) + composition alternative named with the has-a phrasing.
- ◐ Partial: "prefer composition over inheritance" recited without a scenario-specific failure — the exam wants the argument grounded in FitCore.
- ✘ Common mistake: rejecting inheritance everywhere. It's still right elsewhere in this very studio (e.g. AutoOS's engines differ in what they are).
Step 4 — The full relationship set
For each pair, name the relationship — association, aggregation, composition, or dependency — and defend it with a lifetime argument (what happens to B when A is destroyed?): Centre↔Room, Room↔Equipment, Centre↔Trainer, Member↔ClassSession, ClassSession↔Trainer, BillingService↔Notifier. (6 marks)
Hint 1 — nudge
The brief hands you the lifetimes: "if a centre closes, its rooms and equipment are scrapped; its trainers and members carry on." Match each clause to chapter 1's ladder.
Hint 2 — stronger
For BillingService↔Notifier: does the billing service store the notifier, or only receive it? If stored and injected but not owned — which rung is that? And Member↔ClassSession is many-to-many with data on the link (when booked) — what does UML do with attribute-carrying links?
Model answer & rubric
- Centre —◆ Room: composition. Rooms are created by the centre and scrapped when it closes — the part's lifetime is bounded by the whole's.
- Room —◆ Equipment: composition. Bolted down, asset-tagged, scrapped with the building. Part-of-a-part.
- Centre —◇ Trainer: aggregation. Trainers freelance across centres and survive a closure — shared, independent lifetime; the centre merely holds references.
- Member — ClassSession: association, many-to-many, reified as a
Bookinglink class because the link carries data. Both ends outlive any single booking. - ClassSession —◇ Trainer: aggregation. The session has a coach it doesn't own; cancel the session and Ravi still exists.
- BillingService ⤳ INotifier: held abstraction (aggregation of an interface).
The service stores an injected
INotifierit does not create or own. (If it only received the notifier per-call and never stored it, that would weaken to dependency — the distinction chapter 1 draws for Doctor/Patient.)
- ✔ Full credit: all six named and each defended by what-dies-with-what, not by "feels like ownership".
- ◐ Partial: right names, missing lifetime arguments — that's half the marks gone; the argument IS the answer.
- ✘ Common mistake: calling Centre↔Trainer composition because trainers "belong to" the centre. Possession language misleads; only destruction semantics decide.
Step 5 — Build it
Open the first lab and drive it to green:
Nine tests, ordered Step1_…Step6_: encapsulation guards on Member
(invalid ops throw), composition twice (OpenRoom, InstallEquipment — and the
tests assert Room/Equipment have no public constructor), the
Close() lifetime proof (rooms are decommissioned so even a retained reference is
unusable; trainers stay fully usable), aggregation (HireTrainer), and the
Booking association with capacity enforcement. Green proves your Step-4 answers are
executable, not just prose.
Reference shape — what to notice
public class Room
{
public bool IsOpen { get; private set; } = true;
internal Room(string name) => Name = name; // ← assembly-only creation: a composition convention
internal void Decommission() // ← called only by the owner
{
IsOpen = false;
_equipment.Clear();
}
}
public class FitnessCentre
{
public Room OpenRoom(string name)
{
var r = new Room(name); // part created INSIDE the whole
_rooms.Add(r);
return r;
}
public void Close()
{
foreach (var room in _rooms)
room.Decommission(); // logical invalidation: retained references become unusable
_rooms.Clear();
}
}
Notice: the relationship isn't a comment — it's the code shape, and be precise
about what that shape buys you. The internal constructor restricts construction to the
assembly: a convention that approximates "only the owner creates its parts", not something
the compiler can literally enforce — any type in the assembly could call it. And in a
garbage-collected language you cannot destroy an object, so composition's "part dies with the whole"
is a modeling claim the code makes observable through logical invalidation:
Close() decommissions each room, so even a caller who kept the reference from
OpenRoom finds it IsOpen == false and unusable. Aggregation reads opposite:
HireTrainer(Trainer trainer) accepts an object that already exists and leaves
it untouched on Close(). The test file checks these shapes with reflection and a
retained-reference lifetime test, exactly the discipline an examiner checks in your diagram.
Step 6 — Draw it (UML)
Close the laptop lid if you must: draw the class diagram for your Step-4 answer on paper. Seven boxes, correct arrowheads and diamonds at the correct ends, multiplicities on the association. Note anything you had to look up in chapter 1's UML section — that's your revision list. Then, and only then, reveal the model.
Model diagram & common drawing mistakes
Booking is the
reified many-to-many association.- ✘ Diamond at the wrong end — it always touches the whole/owner, never the part.
- ✘ Filled vs open diamond swapped — filled = composition (dies together), open = aggregation (survives).
- ✘ Arrowheads on association lines that don't need them, or an open triangle (inheritance!) used as a plain arrow.
- ✘ Missing multiplicities on the Member–ClassSession association — a many-to-many with no
0..*reads as 1-to-1. - ✘ Drawing
Bookingbut also a direct Member—ClassSession line: pick the reified form or the plain form, not both.
Step 7 — Articulate
Exam voice: "Using the FitnessCentre, Room and Trainer classes, articulate the fundamental difference between aggregation and composition, and state how each is expressed both in UML and in C# code. (6 marks)"
Model answer & rubric
Both are whole–part ("has-a") relationships; they differ in lifetime ownership.
Composition: the whole owns the part's lifecycle — a Room is created
inside FitnessCentre.OpenRoom() and logically decommissioned by Close()
(the CLR object may remain reachable, but it reports IsOpen == false and refuses further
use); the part has no independent role in the model. UML: filled diamond at the centre's end. C#: the
part is instantiated inside the owner, its constructor hidden (internal Room(...)) — an
assembly-level convention that keeps creation with the owner.
Aggregation: the whole holds a reference to a part that lives independently — a
Trainer is constructed elsewhere, passed into HireTrainer(), and survives
the centre closing. UML: open diamond. C#: a public-constructor object received as a parameter and
stored. The test is always destruction: delete the whole — does the part die?
- ✔ Full credit: lifetime contrast + both UML notations + both code shapes, all tied to the named classes.
- ◐ Partial: correct definitions but generic (no FitCore entities) — the question said "using the classes".
- ✘ Common mistake: "composition is a stronger association" with no destruction argument — true but earns almost nothing.
SOLID on the same domain
FitCore's billing started life as one class. Here is the code you inherit — it works, it ships, and it is quietly rotting. This is the shape the exam gives you with the instruction "identify the design smells":
public sealed class LegacyBillingManager
{
public decimal Charge(string planType, int visits)
{
decimal fee;
if (planType == "standard") fee = 40m;
else if (planType == "premium") fee = 75m;
// every new plan lands another branch RIGHT HERE
else throw new ArgumentException("Unknown plan type: " + planType);
Console.WriteLine("[Legacy] Emailing receipt inline...");
return fee;
}
}
public interface IFeePlan { string Name { get; } decimal MonthlyFee(int visits); }
public interface INotifier { void Send(string memberId, string message); }
public sealed class BillingService
{
public BillingService(INotifier notifier) { ... } // injected abstraction
public decimal ChargeMonthly(string memberId, IFeePlan plan, int visits) { ... }
}
Step 1 — SRP: extract the responsibilities
LegacyBillingManager have?List each distinct reason to change in the monolith (name the stakeholder or force behind each), then propose the extraction: which classes exist after the refactor, and which single responsibility does each carry? (4 marks)
Hint 1 — nudge
SRP counts reasons to change, not lines of code. Who asks for fee-rule changes? Who asks for "send receipts by SMS instead"?
Model answer & rubric
Three reasons to change: (1) fee rules change (pricing/marketing decides a new plan
or rate); (2) notification channel changes (ops wants SMS or push instead of email);
(3) the charging workflow itself changes (finance wants proration). Extraction: fee
computation → the IFeePlan implementations; message delivery → an INotifier
implementation; orchestration → BillingService, whose only job is "ask the plan for the
fee, hand the message to the notifier". Compare the bookstore Invoice split (data / printer /
persistence) from the tutorials — same move, different domain.
- ✔ Full credit: reasons tied to stakeholders/forces, plus a clean three-way extraction.
- ◐ Partial: "it does too many things" without naming the independent reasons.
- ✘ Common mistake: extracting a
FeeCalculatorthat still contains the if/else ladder — that fixes SRP's symptom and leaves the OCP disease (next step).
Step 2 — OCP: pluggable fee strategies
Marketing confirms the Student plan: 20 credits base + 2 per visit, capped at 40. Design so
that this plan — and every future plan — is added without modifying
BillingService or any existing plan. Name the pattern, sketch the interface, and state
exactly which artefact is "open" and which is "closed". (4 marks)
Hint 1 — nudge
The if/else ladder switches on a string. OCP's cure is to replace the switch with a type: each branch becomes a…?
Model answer & rubric
public interface IFeePlan
{
string Name { get; }
decimal MonthlyFee(int visits);
}
public sealed class StudentPlan : IFeePlan
{
public string Name => "Student";
public decimal MonthlyFee(int visits)
=> Math.Min(20m + 2m * Math.Max(0, visits), 40m);
}
This is the Strategy shape (formalised in
chapter 5). Closed for modification:
BillingService and the existing plan classes — nobody opens those files again.
Open for extension: the IFeePlan contract — a new plan is a new file
implementing it. The cinema-ticket tutorial made the same move: the calculator stays two lines long
no matter how many discounts exist.
- ✔ Full credit: interface + new-class-per-plan + explicit open/closed identification.
- ◐ Partial: "use polymorphism" without naming what stays untouched — the point of OCP is the list of files you never reopen.
- ✘ Common mistake: an enum + switch inside BillingService "because it's tidier" — tidier, and still modified on every new plan.
Step 3 — LSP: the substitution trap
Two proposals reach code review:
// Proposal A
public sealed class FreeTrialPlan : IFeePlan
{
public string Name => "FreeTrial";
public decimal MonthlyFee(int visits) => 0m;
}
// Proposal B
public sealed class CorporatePlan : IFeePlan
{
public string Name => "Corporate";
public decimal MonthlyFee(int visits)
=> throw new InvalidOperationException(
"Corporate members are billed by invoice — do not call this.");
}
Use the LSP checklist from chapter 2 to judge
both, and state what the violation would do to BillingService.MonthlyRevenue() at runtime.
Then propose the LSP-safe redesign for the problematic one. (6 marks)
Hint 1 — nudge
Checklist item 1 is the "No-Op or Exception" test. A fee of zero is a surprising value; an exception is a broken promise. Which is which?
Hint 2 — stronger
Same disease as the tutorials' fixed-deposit
account whose withdraw() threw PermissionError: if the type can't honour the
contract, should it be behind that contract at all?
Model answer & rubric
A is fine. Zero is a legitimate fee: every caller that sums, compares, or formats
fees keeps working. Weird business-wise, sound contract-wise. B violates LSP — it
disables the parent contract with an exception, exactly the checklist's first red flag. Runtime
consequence: MonthlyRevenue() iterates all subscriptions polymorphically; one corporate
member in the list crashes the entire month-end run — code that was correct for every other plan
breaks without being touched. Redesign: corporate members shouldn't be behind
IFeePlan at all. Model invoiced billing as a separate contract (e.g.
IInvoicedAccount) and keep the monthly-fee pipeline for types that can honour it — the
same interface-split cure as the withdrawable/fixed-deposit refactor.
- ✔ Full credit: correct verdicts on both + the "unmodified caller breaks" consequence + a split-the-contract redesign.
- ◐ Partial: spotting B but condemning A too ("a free plan makes revenue wrong") — revenue being surprising is not a contract violation.
- ✘ Common mistake: fixing B with try/catch inside MonthlyRevenue. Callers defending themselves against subtypes IS the LSP violation, formalised.
Step 4 — ISP: granular interfaces
IGymDevice interfaceThe equipment team proposes one interface for all connected hardware:
interface IGymDevice { void PowerOn(); void PowerOff(); int ReadHeartRate(); void StartIncline(double grade); void ReportUsage(); }
FitCore's fleet includes treadmills (all five), rowers (no incline), and dumb RFID lockers (power and
usage only). Diagnose the problem and propose the segregated design. (4 marks)
Hint 1 — nudge
ISP
judges from the implementer's seat: what must the rower write for
StartIncline? What must the locker write for ReadHeartRate?
Model answer & rubric
A fat interface forces rowers and lockers to stub methods they can't honour — and a stub is either
a no-op lie or a thrown exception, which is yesterday's LSP violation manufactured wholesale.
Segregate by capability: IPowered (on/off), IUsageReporting,
IHeartRateSource, IInclinable. Treadmill implements all four; rower skips
IInclinable; locker implements just IPowered + IUsageReporting.
Client code then asks only for the capability it needs — a maintenance job that powers devices down
takes IEnumerable<IPowered> and cannot even receive a device it might misuse.
(Same cure as the tutorials' printer/scanner/fax split.)
- ✔ Full credit: forced-stub diagnosis + capability-based split + per-device mapping.
- ◐ Partial: splitting into exactly two interfaces ("simple" and "advanced") — better, but still bundles unrelated capabilities.
- ✘ Common mistake: "just make the extra methods virtual with empty bodies" — institutionalising the no-op is the disease, not the cure.
Step 5 — DIP: invert the notification dependency
INotifier abstraction?Before the refactor, BillingService constructed an EmailSender internally
(new EmailSender() in the constructor). State what DIP
requires instead, which module conceptually owns the INotifier abstraction
(high-level billing or low-level email?), and one concrete benefit FitCore gains in testing.
(4 marks)
Hint 1 — nudge
"Inversion" doesn't mean "use an interface
somewhere". Ask: is INotifier written in billing's language ("send this member this
message") or in SMTP's language ("host, port, attachment")?
Model answer & rubric
DIP: the high-level policy (billing) must not depend on the low-level detail (email); both depend on
an abstraction — and crucially, the abstraction belongs to the high-level side.
INotifier.Send(memberId, message) is billing's vocabulary; EmailSender adapts
itself to it, not the other way round. That's the inversion: the detail now depends on the policy's
contract. Mechanically: inject INotifier through the constructor and delete the
new. Testing benefit: substitute a spy notifier and assert "charging sent exactly one
message to M-1" with no mail server — which is literally what lab F02's tests do.
- ✔ Full credit: inversion stated + abstraction ownership on the policy side + concrete test double benefit.
- ◐ Partial: "inject an interface" with no ownership argument — that's dependency injection described, DIP half-answered.
- ✘ Common mistake: conflating DIP with DI. Injection is the mechanism; inversion is about which side defines the contract.
Step 6 — Build it
Six tests, Step1_…Step5_. They encode your last five answers as executable assertions:
IFeePlan must be an interface; the flat plans compute; BillingService must take
an INotifier in its constructor and delegate the message (a spy notifier counts the
calls); StudentPlan must plug in with zero edits to the service; and every plan must survive
the Liskov sweep — called with 0, 1, 50 visits, never throwing, never negative. The smelly
LegacyBillingManager stays in the file as the "before" — resist improving it.
Reference shape — what to notice
public decimal ChargeMonthly(string memberId, IFeePlan plan, int visits)
{
var fee = plan.MonthlyFee(visits); // strategy does the varying part
_notifier.Send(memberId, $"Your {plan.Name} membership fee this month is {fee:0.00}.");
return fee;
}
Notice: after the refactor, BillingService contains no plan names, no
if, no new. Everything that varies arrived through a contract. When the
Step-4 test passes — StudentPlan summed correctly by a service that has never heard of it — you have
witnessed OCP as a runtime fact.
Step 7 — Articulate
Exam voice: "FitCore added its Student fee plan without modifying any existing class. Explain how the strategy-based design achieves this (OCP), and why the design remains safe only so long as every plan honours the IFeePlan contract (LSP). Refer to MonthlyRevenue() in your answer. (6 marks)"
Model answer & rubric
MonthlyRevenue() depends only on the IFeePlan abstraction: it iterates
subscriptions and calls plan.MonthlyFee(visits) polymorphically. A new plan is a new
implementing class; the service binds to it at runtime without recompilation of existing code — open
for extension, closed for modification. But OCP's promise silently assumes substitutability: the
service treats all plans identically because it may. If one implementation throws or returns
garbage (Step 3's CorporatePlan), the unmodified, "closed" MonthlyRevenue() now fails —
the extension broke the closed code. LSP is therefore not a separate nicety: it is the precondition
that makes OCP's polymorphic extension point safe. Strategy provides the mechanism; Liskov provides
the warranty.
- ✔ Full credit: OCP mechanism through the interface + LSP framed as the condition OCP relies on + MonthlyRevenue used as the concrete witness.
- ◐ Partial: both principles defined correctly but independently — the marks are in the connection.
- ✘ Common mistake: restating definitions ("open for extension, closed for modification") with no FitCore mechanics at all.
Warm-up reps
Five short reps mined from the tutorial series, re-cast in C#. Each is a mini decide-then-build: commit
the design call, then write the few lines of C# it implies (paper, scratch file, or
dotnet run playground — your choice), then reveal. Ten minutes each.
A bank models class FixedDepositAccount : BankAccount and overrides
Withdraw() to throw ("no withdrawals until maturity"). Run the four-point
LSP checklist against it, name the failing test,
then build: write the corrected C# hierarchy (base Account, a withdrawable
abstraction, both concrete accounts) so the compiler stops the crash.
Hint — nudge
Checklist test 1: does the subclass disable a parent method via no-op or exception?
Model answer
public abstract class Account
{
public decimal Balance { get; protected set; }
}
public abstract class WithdrawableAccount : Account
{
public abstract void Withdraw(decimal amount);
}
public sealed class SavingsAccount : WithdrawableAccount
{
public override void Withdraw(decimal amount)
{
if (amount <= 0 || amount > Balance) throw new InvalidOperationException();
Balance -= amount;
}
}
public sealed class FixedDepositAccount : Account // no Withdraw to betray
{
public void AddInterest() => Balance *= 1.05m;
}
void ProcessWithdrawals(IEnumerable<WithdrawableAccount> accounts) { /* type-safe */ }
Fails the No-Op/Exception test. The fix moves Withdraw down into a
contract only capable types implement — ProcessWithdrawals now cannot even receive a
fixed deposit. Note the guard inside Withdraw: same encapsulation reflex as FitCore
Step 2.
A PizzaOrder already composes a tax strategy and an oven strategy. Marketing now wants
stacked discounts: percentage off, fixed amount off, none. Decide where the discount logic lives so
PizzaOrder is never edited again, then build the interface plus one concrete discount and
show the composed constructor call.
Hint — nudge
The order already delegates tax and cooking. Discounts are the third thing that varies — same treatment.
Model answer
public interface IDiscountPolicy { decimal Apply(decimal subtotal); }
public sealed class FixedDiscount : IDiscountPolicy
{
private readonly decimal _off;
public FixedDiscount(decimal off) => _off = off;
public decimal Apply(decimal subtotal) => Math.Max(0m, subtotal - _off);
}
var order = new PizzaOrder(new UkTax(), new WoodFireOven(), new FixedDiscount(5.00m));
PizzaOrder.Total() just calls _discount.Apply(...) in its pipeline. A new
promotion is a new class — the order class is closed. Note Math.Max(0m, ...): the policy
keeps its own contract LSP-clean (never a negative total).
A WeatherReporter constructs its own LocalDatabase (data source) and
EmailService (alerting) internally. Decide the two abstractions to extract and who defines
them; then build the refactored constructor and Report() method, and show one line that
swaps to CloudApi + SmsService without touching the class.
Hint — nudge
Two independent low-level details = two separate
contracts. One fat IWeatherStuff would trade a DIP problem for an
ISP one.
Model answer
public interface IWeatherSource { double GetTemperature(); }
public interface INotifier { void Send(string message); }
public sealed class WeatherReporter
{
private readonly IWeatherSource _source;
private readonly INotifier _notifier;
public WeatherReporter(IWeatherSource source, INotifier notifier)
{ _source = source; _notifier = notifier; }
public void Report()
=> _notifier.Send($"Weather Update: {_source.GetTemperature()} degrees.");
}
var reporter = new WeatherReporter(new CloudApi(), new SmsService()); // the swap
Both abstractions are written in the reporter's vocabulary (a temperature, a message) — the
high-level module owns the contracts and the details adapt. This is the exact shape FitCore's
BillingService(INotifier) uses in lab F02.
A CameraApp hard-codes new PhysicalCamera() in its constructor. Decide what
contract the app should depend on instead and how a camera gets in; then build ICamera, a
second camera (SelfieCamera), and a MockCamera you could unit-test with — and
state in one sentence why the mock is now trivial.
Hint — nudge
Program to an interface, not an implementation — the app should know that capture happens, never how.
Model answer
public interface ICamera { string Capture(); }
public sealed class SelfieCamera : ICamera
{
public string Capture() => "Front lens: image flipped and captured.";
}
public sealed class MockCamera : ICamera
{
public string Capture() => "TEST-CAPTURE";
}
public sealed class CameraApp
{
private ICamera _camera;
public CameraApp(ICamera camera) => _camera = camera; // injected, not newed
public void SetCamera(ICamera camera) => _camera = camera; // runtime swap
public string PressShutter() => _camera.Capture();
}
The mock is trivial because the app's only requirement is the one-method contract — no hardware, no setup, one line. That "ease of faking" is the everyday payoff of loose coupling, and it's how every lab in this studio tests your code.
A Switch class stores a LightBulb field and calls
bulb.TurnOn()/TurnOff(). The homeowner buys a fan and a heater. Decide the
abstraction and then build the full refactor: ISwitchable, the refactored
Switch with its toggle state, and one device beyond the bulb. Then answer: is
Switch→ISwitchable aggregation or composition, and why?
Hint — nudge
For the relationship question: who creates the device? Who destroys it? Apply Step 4's destruction test.
Model answer
public interface ISwitchable { void TurnOn(); void TurnOff(); }
public sealed class Fan : ISwitchable
{
public void TurnOn() => Console.WriteLine("Fan is spinning");
public void TurnOff() => Console.WriteLine("Fan stopped");
}
public sealed class Switch
{
private readonly ISwitchable _device;
private bool _isOn;
public Switch(ISwitchable device) => _device = device;
public void Toggle()
{
if (_isOn) _device.TurnOff(); else _device.TurnOn();
_isOn = !_isOn;
}
}
Aggregation. The device is created outside, passed in, merely referenced — discard
the switch and the fan still exists. High-level Switch no longer depends on any concrete
device; both depend on ISwitchable — DIP in five lines.
Capstone — AutoOS
AutoOS is a hardware-agnostic platform controlling a fleet of electric vehicles, from city cars to heavy trucks. Modularity is the prime directive: hardware components and software behaviours must swap without touching the core. Four phases: 1 — Propulsion ("The Black Box Engine"), 2 — Autonomous sensing ("The Universal Eye"), 3 — Driving dynamics ("The Chameleon Car"), 4 — OTA extensibility ("The Silent Update"). Each phase carries hard constraints — treat them as an examiner's tripwires.
Work one stage at a time: commit the DECIDE, then drive that stage's tests green in lab F03 before reading the next stage.
Stage 1 — Propulsion: "The Black Box Engine"
Requirements: throttle input 0.0–1.0 becomes voltage/current commands for electric motors but fuel-flow
commands for hydrogen cells; GetStatus() must be readable regardless of engine tech.
Constraints: critical states (voltage, temperature) must be private with validation (reject > 800 V);
the DriverInput module must compile with no reference to any concrete engine class.
Propose the types and name the pillar each constraint exercises. (6 marks)
Hint 1 — nudge
"No reference to concrete engines" forces
DriverInput to hold… what kind of type? And "reject > 800 V" lives inside which kind of
member?
Model answer & rubric
An abstract PropulsionSystem (Accelerate(double),
GetStatus()) is the contract — abstraction. ElectricMotor
keeps _voltage private behind a validated SetVoltage that throws on
negatives and on > 800 V — encapsulation (NFR-1.1/1.2). DriverInput
stores a PropulsionSystem reference and calls Accelerate, so electric and
hydrogen respond each in their own way — polymorphism; and because it references only
the abstraction, CONST-1.1 is satisfied by construction, which is DIP avant la
lettre. Then Build: make the two Stage1_ tests
pass — voltage rejection and one DriverInput driving both engines.
- ✔ Full credit: abstract base + validated private state + abstraction-typed DriverInput, each constraint mapped to its pillar.
- ◐ Partial: correct types but constraints unmapped — the SRS numbered them so you'd cite them.
- ✘ Common mistake: an
EngineTypeenum with a switch in DriverInput — violates CONST-1.1 in spirit: the module now knows every engine.
Stage 2 — Autonomous: "The Universal Eye"
Requirements: cameras provide video; lidar/radar/sonar provide distance; obstacle within 5.0 m ⇒ brake. Constraints: the autopilot must run with any combination of sensors; a sonar must never be forced to expose a video method (NFR-2.2); the autopilot is forbidden from instantiating sensors (CONST-2.1). Note that lidar genuinely does both distance and point-cloud imagery. Design the interfaces and the autopilot's constructor. (6 marks)
Hint 1 — nudge
NFR-2.2 is ISP verbatim. And a class may implement two small interfaces at once…
Model answer & rubric
Split by capability: IVisualSensor { GetVideoFeed() } and
IDistanceSensor { GetDistanceMeters() }. Camera4K : IVisualSensor;
Sonar : IDistanceSensor only — it is never forced to fake video;
Lidar : IDistanceSensor, IVisualSensor — multiple small interfaces compose where one fat
one would lie. Constructor injection satisfies CONST-2.1:
Autopilot(IEnumerable<IVisualSensor>, IEnumerable<IDistanceSensor>) — the car
is assembled by a bootstrapper outside, so a camera-only city car and a lidar+sonar truck both boot
the same Autopilot code. Then Build: the two
Stage2_ tests — any-combination braking logic, and a reflection check that
Sonar is not an IVisualSensor.
- ✔ Full credit: two-way split + lidar implementing both + injected constructor, all tied to the numbered constraints.
- ◐ Partial: split correct but Autopilot still calls
new Sonar()somewhere — reread CONST-2.1. - ✘ Common mistake: one
ISensorwith both methods and sonar returning null video — the exact "empty method" the SRS forbids.
Stage 3 — Dynamics: "The Chameleon Car"
Sport, Eco and Offroad modes change throttle response and braking feel while the vehicle is
running. Constraints: no behaviour-based inheritance (SportCar : Car is forbidden);
no if/else ladders on a mode variable in the controller (CONST-3.2); behaviour must come from a
composed strategy object (NFR-3.1). Design it, and explain in one sentence why inheritance
cannot satisfy FR-3.2 (runtime switching). (4 marks)
Hint 1 — nudge
You made this exact argument for FitCore member tiers in Scenario Step 3. Same knockout punch.
Model answer & rubric
IDrivingStrategy { ApplyThrottle(); ApplyBrakes(); } with SportMode,
EcoMode, OffroadMode. CarController has a strategy and
delegates — SetMode(IDrivingStrategy) swaps it live, and Drive() contains
zero conditionals. The knockout: an object's class is fixed at construction, so a running
SportCar can never become an EcoCar — only a composed reference can
be reassigned mid-drive. (Composition
over inheritance, now with an SRS constraint enforcing it.) Then
Build: Stage3_ — a live Sport→Eco swap observed
through Drive().
- ✔ Full credit: strategy interface + delegating controller + the fixed-class-at-construction argument.
- ◐ Partial: right structure, knockout argued as "inheritance is bad practice" — the exam wants the mechanical reason.
- ✘ Common mistake: strategy interface built, but the controller keeps
if (mode == "Sport")to choose which strategy to call — the ladder just moved.
Stage 4 — Extensibility: "The Silent Update"
Valet Mode (30 mph limit, trunk locked) must be delivered as one standalone new class file,
with CarController and every existing strategy left byte-for-byte unchanged (NFR-4.1), and
must not crash or throw NotImplementedException when standard driving methods are called
(NFR-4.2). State what makes this possible, and which two principles the two NFRs correspond to.
(4 marks)
Hint 1 — nudge
You articulated exactly this pairing for FitCore's Student plan in SOLID Step 7 — mechanism and warranty.
Model answer & rubric
Stage 3 already built the extension point: CarController depends only on
IDrivingStrategy, so ValetMode : IDrivingStrategy is a pure addition —
NFR-4.1 is OCP (extend by adding a class, never by editing). It works only because
ValetMode genuinely honours the contract — throttle and brakes both respond, just restrictively —
so substituting it for SportMode cannot crash the controller: NFR-4.2 is LSP. A
ValetMode that threw from ApplyThrottle() would be Stage-3's CorporatePlan all over
again. Then Build: Stage4_ — the test hands
ValetMode to the untouched controller and drives.
- ✔ Full credit: extension-point mechanism + OCP and LSP mapped to their NFRs + the restrictive-but-conforming behaviour argument.
- ◐ Partial: OCP identified, LSP missed — the "must not throw" clause was the giveaway.
- ✘ Common mistake: proposing to subclass an existing mode ("ValetMode : EcoMode") — legal, but it couples the update to a file the SRS declared frozen.
Deliverable — the class diagram
The SRS deliverable checklist asks for a class diagram of the controller, the strategy contract and the propulsion stack. Sketch it on paper — interfaces with realization arrows, the abstract engine with inheritance arrows, the composed strategy with the correct diamond — then reveal. (6 marks)
Model diagram & common mistakes
- ✘ Solid line for realization: implementing an interface is a dashed line + open triangle; extending a class is solid + open triangle.
- ✘ CarController pointing at SportMode: the whole point is it never touches a concrete mode — the arrow stops at the interface.
- ✘ Filled diamond on the strategy: the controller doesn't own the strategy's lifetime (modes are handed in and swapped) — open diamond.
- ✘ Missing «interface» / «abstract» stereotypes, leaving the reader to guess which boxes are instantiable.
Closing quiz
Your committed answers stay saved in this browser — revisit them the week before the exam and re-mark yourself against the rubrics. When F01–F03 are all green, move to the Patterns Studio, where the same Decide → Build → Articulate loop meets the GoF catalogue.