STUDIO 14 · DO THE WORK

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:

Decide

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.

Build

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.

Articulate

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.

🛠 The dotnet workflow (once)

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

📋 Brief

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

Decide Which nouns become classes — and which don't?

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)

Your answer is saved locally in your browser.
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.

Marking rubric
  • ✔ 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

Decide What is private on 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)

Your answer is saved locally in your browser.
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.

Marking rubric
  • ✔ 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

Decide Should 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)

Your answer is saved locally in your browser.
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.

Marking rubric
  • ✔ 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

Decide Name every relationship, with lifetime arguments

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)

Your answer is saved locally in your browser.
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 Booking link 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 INotifier it 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.)
Marking rubric
  • ✔ 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

Build Lab F01 — encode the relationships so the code makes them observable

Open the first lab and drive it to green:

cd Labs-Code/labs/F01-relationships/Starter && dotnet test

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)

Decide Sketch the FitCore class diagram — on paper, first

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.

Your answer is saved locally in your browser.
Model diagram & common drawing mistakes
FitnessCentre + OpenRoom() + Close() Room + InstallEquipment() Equipment Trainer composition 1..* composition 0..* aggregation — trainers survive Close() Member Booking link class ClassSession + Book(member) 1 0..* 0..* 1 association (many-to-many, reified) coach ◇
Figure: the FitCore model. Filled diamonds (composition) sit at the owner end; open diamonds (aggregation) at the holder end; the plain line through Booking is the reified many-to-many association.
Common drawing mistakes (each costs marks)
  • ✘ 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 Booking but also a direct Member—ClassSession line: pick the reified form or the plain form, not both.

Step 7 — Articulate

Articulate Aggregation vs composition, on FitCore's entities

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)"

Your answer is saved locally in your browser.
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?

Marking rubric
  • ✔ 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":

❌ The inherited monolith
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;
    }
}
Two principles are being violated at once. Find both before Step 1 tells you.
✅ Where we're heading
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) { ... }
}
Fee variation behind a strategy interface; notification behind an injected abstraction. You'll justify every move first.

Step 1 — SRP: extract the responsibilities

Decide How many reasons to change does 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)

Your answer is saved locally in your browser.
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.

Marking rubric
  • ✔ 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 FeeCalculator that still contains the if/else ladder — that fixes SRP's symptom and leaves the OCP disease (next step).

Step 2 — OCP: pluggable fee strategies

Decide Make the Student plan land without editing anything that exists

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)

Your answer is saved locally in your browser.
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.

Marking rubric
  • ✔ 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

Decide One of these two proposed plans is a Liskov violation. Which, and why?

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)

Your answer is saved locally in your browser.
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.

Marking rubric
  • ✔ 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

Decide Critique the proposed IGymDevice interface

The 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)

Your answer is saved locally in your browser.
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.)

Marking rubric
  • ✔ 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

Decide Who should own the 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)

Your answer is saved locally in your browser.
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.

Marking rubric
  • ✔ 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

Build Lab F02 — refactor the monolith to the tested shape
cd Labs-Code/labs/F02-solid-refactor/Starter && dotnet test

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

Articulate Why strategy, and what guards it

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)"

Your answer is saved locally in your browser.
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.

Marking rubric
  • ✔ 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.

Decide + Build Rep 1 — The fixed-deposit trap (LSP checklist)

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.

Your answer is saved locally in your browser.
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.

Decide + Build Rep 2 — Pizza discounts without reopening the order (OCP/DIP)

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.

Your answer is saved locally in your browser.
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).

Decide + Build Rep 3 — The weather reporter (DIP with two ports)

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.

Your answer is saved locally in your browser.
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.

Decide + Build Rep 4 — The universal camera app (program to an interface)

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.

Your answer is saved locally in your browser.
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.

Decide + Build Rep 5 — The smart-home switch (DIP, completed)

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?

Your answer is saved locally in your browser.
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

📋 The SRS, condensed

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.

cd Labs-Code/labs/F03-autoos-capstone/Starter && dotnet test

Stage 1 — Propulsion: "The Black Box Engine"

Decide Design the propulsion contract under the constraints

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)

Your answer is saved locally in your browser.
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.

Marking rubric
  • ✔ 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 EngineType enum with a switch in DriverInput — violates CONST-1.1 in spirit: the module now knows every engine.

Stage 2 — Autonomous: "The Universal Eye"

Decide One ISensor, or several? And who constructs the sensors?

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)

Your answer is saved locally in your browser.
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.

Marking rubric
  • ✔ 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 ISensor with both methods and sonar returning null video — the exact "empty method" the SRS forbids.

Stage 3 — Dynamics: "The Chameleon Car"

Decide Behaviour modes without subclassing the 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)

Your answer is saved locally in your browser.
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().

Marking rubric
  • ✔ 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"

Decide Ship Valet Mode over the air, touching nothing

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)

Your answer is saved locally in your browser.
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.

Marking rubric
  • ✔ 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

Articulate Draw AutoOS: CarController, IDrivingStrategy, PropulsionSystem

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)

Your answer is saved locally in your browser.
Model diagram & common mistakes
CarController + SetMode() + Drive() «interface» IDrivingStrategy + ApplyThrottle() + ApplyBrakes() has-a (swappable) SportMode EcoMode ValetMode OTA addition realization (dashed, open triangle) DriverInput + PressPedal() «abstract» PropulsionSystem ElectricMotor HydrogenCell inheritance (solid, open triangle) has-a PropulsionSystem
Figure: the AutoOS deliverable. Both clients hold abstractions (open diamonds at the holder end); concrete types hang below their contracts.
Common drawing 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

➡️ Where next

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.