Skip to content

Design Questions — Model Answers

Back to the questions

These are model answers: one defensible design, with its trade-off stated. They are not the only answers. What is assessed is whether your design answers the question and whether you can say why you chose it.


DQ1 — Bank account

class Account {                          // owns its history
public:
    Account(std::string owner, Money opening);
    void deposit(Money amount, std::string note);
    void withdraw(Money amount, std::string note);   // throws InsufficientFunds
    Money balance() const;
    const std::vector<Movement>& history() const;
private:
    std::string owner_;
    Money balance_;
    std::vector<Movement> history_;
};
class Bank {                             // aggregates accounts; they outlive it
public:
    Account& open(std::string owner);
    Account* find(const std::string& id);
private:
    std::map<std::string, std::unique_ptr<Account>> accounts_;
};

Invariant: balance_ >= 0, and always equal to the sum of the movements' signed amounts. The one operation that enforces it is withdraw — it is the only place a balance can decrease, so the check belongs there and nowhere else. deposit enforces the non-negative half by rejecting non-positive amounts.

Relationships: Bank aggregates Accounts (accounts exist independently; the bank looks them up, it does not own their lifetime in the "dies with the whole" sense — though here unique_ptr gives it destruction responsibility, which is a deliberate choice for a self-contained system). Account composes Movements: a movement has no meaning without its account.

Trade-off: storing the full history in memory is simple and fast but unbounded; a real bank would page it out. I chose simplicity because the question is about the invariant, not about scale.


DQ2 — Library lending

class Loan {
public:
    Loan(Member& member, Item& item, Date due);   // both are aggregated
    Date due() const;
    bool overdue(Date today) const;
    void returnItem();
private:
    Member& member_;
    Item& item_;
    Date due_;
};
class Catalogue {                 // owns items and lends them out
public:
    Loan& lend(Member& member, Item& item, Date today);   // throws if beyond limit
    std::vector<Loan*> loansOf(const Member& member) const;
    void takeBack(Loan& loan);
private:
    std::vector<std::unique_ptr<Item>> items_;
    std::vector<std::unique_ptr<Loan>> loans_;
};

Ownership: Item and Member are aggregates — they exist independently of any loan and outlive it, so Loan holds references, not copies and not owners. Catalogue composes its Loans (a loan is created and destroyed by the catalogue) and owns the items it holds.

Where the "at most n" rule lives: in Catalogue::lend, because it is a rule about the relationship between a member and the catalogue, not about either object. Putting it on Member would force the member to know about the catalogue's loans, which it should not.

Trade-off: a reference from Loan to Member means a loan cannot outlive its member; that is a real constraint the compiler now helps enforce, at the cost of making Loan non-copyable in the obvious way (rules of reference or reference_wrapper).


DQ3 — Shape rendering

class Shape {
public:
    virtual ~Shape() = default;
    virtual double area() const = 0;
    virtual void draw(Canvas&) const = 0;
};
class Circle   : public Shape { /* ... */ };
class Rectangle: public Shape { /* ... */ };
class Triangle : public Shape { /* ... */ };

double totalArea(const std::vector<std::unique_ptr<Shape>>& shapes) {
    double sum = 0.0;
    for (const auto& s : shapes) sum += s->area();     // one loop, no switch
    return sum;
}

Where a new shape goes: a new class. totalArea and every existing class are untouched. Adding Hexagon is one new file, no edits — that is the open/closed principle.

Principle: open for extension, closed for modification; also Liskov substitution, since each shape is used only through area()/draw(), which all satisfy.

Trade-off: polymorphic shapes cost a virtual call and a heap allocation each, and the draw behaviour is spread across classes rather than visible in one place. For a fixed, small set of shapes a std::variant + std::visit would avoid the allocation, at the cost of recompiling every visitor when a shape is added.


DQ4 — Payment processing

class PaymentResult {                     // value type, carries success or reason
public:
    static PaymentResult ok(std::string ref);
    static PaymentResult declined(std::string reason);
    bool succeeded() const;
};
class PaymentMethod {
public:
    virtual ~PaymentMethod() = default;
    virtual std::string name() const = 0;
    virtual PaymentResult charge(Money amount) const = 0;   // may also throw on network faults
};
class CardPayment   : public PaymentMethod { /* ... */ };
class WalletPayment : public PaymentMethod { /* ... */ };
class BankTransfer  : public PaymentMethod { /* ... */ };

Failure handling: two channels, deliberately. Declined is an expected business outcome and is returned as a PaymentResult value; transport/permission failure is exceptional and is thrown as a typed exception (PaymentError with a derived NetworkError, AuthorisationError). A caller checks the result for the normal path and catches only the unusual one.

Adding a method: class CryptoPayment : public PaymentMethod — nothing else changes. The checkout takes a PaymentMethod&, so it never learns the new name.

Trade-off: every method must be able to describe itself as PaymentResult; a method with an outcome the value type cannot express would force it to grow. I accept that over a free-form variant because the number of outcomes is much smaller than the number of methods.


DQ5 — Notification hub

class Observer {
public:
    virtual ~Observer() = default;
    virtual void onEvent(const Event&) = 0;
};
class Hub {
public:
    void subscribe(Observer& o);      // non-owning: the observers outlive their subscription
    void unsubscribe(Observer& o);
    void publish(const Event& e) const;
private:
    std::vector<Observer*> observers_;   // or std::vector<std::weak_ptr<Observer>>
};

Interface: one method, onEvent(const Event&) — narrow, so any consumer can implement it (interface segregation). Emailer, Logger and Dashboard implement it.

Ownership: the hub does not own its observers — they exist independently and may outlive or precede it, so it holds pointers (raw, deliberately non-owning, or weak_ptr if the observers are heap objects the hub must not keep alive).

Unsubscription: unsubscribe erases the observer from the vector. The hard case is unsubscribing during publish: iterating the vector while it is modified invalidates the iterator. The two honest fixes are (a) publish over a copy of the observer list, or (b) build a to-remove list and apply it after dispatch. (a) allocates per publish; (b) is faster but must handle an observer removed between iterations. Either is acceptable — state which and why.

Trade-off: non-owning pointers mean the hub must not be used after its observers die; the alternative, shared_ptr on both sides, creates a cycle and leaks. The weak_ptr version costs a lock check per publish.


DQ6 — Inventory lookup

class Inventory {
public:
    void add(Item item);                     // keeps all three structures in step
    const Item* byBarcode(const Barcode& b) const;   // O(1) average
    std::vector<const Item*> inBarcodeOrder() const; // O(n)
    std::size_t lowStockCount() const;               // O(n)
private:
    std::unordered_map<Barcode, Item> by_barcode_;   // O(1) average lookup
    std::map<Barcode, std::reference_wrapper<const Item>> ordered_;  // O(log n) insert, sorted
    std::size_t low_stock_ = 0;                      // maintained incrementally, O(1) read
};

Justification: barcode lookup is the hot path and needs no order, so std::unordered_map (average O(1)) beats std::map (O(log n)). Reports need sorted output, so a second index ordered by barcode gives it in O(n) with no per-report sort. Low-stock count is maintained as items change, so reading it is O(1) instead of O(n).

Invariant: every barcode in ordered_ names an item in by_barcode_, and low_stock_ equals the number of items whose stock is below the threshold. add is the only mutator, so it is the only place that must restore all three.

Trade-off: three structures to keep consistent — more state, more to get wrong — bought with faster reads. If writes dominated, one std::map and a per-report count would be simpler and barely slower.


DQ7 — Plugin registry

class Exporter {
public:
    virtual ~Exporter() = default;
    virtual std::string extension() const = 0;      // ".csv"
    virtual void exportDocument(const Document&, std::ostream&) const = 0;
};
class Registry {
public:
    void registerExporter(std::unique_ptr<Exporter> e);   // throws if it lies
    const Exporter* forExtension(const std::string& ext) const;
    std::vector<std::string> extensions() const;
private:
    std::map<std::string, std::unique_ptr<Exporter>> by_extension_;
};

Registration checks: the registry holds its own invariants, so it validates at the boundary rather than trusting the plugin: the extension is non-empty and canonical (one lowercase, leading-dot form), and not already claimed — a duplicate is a RegistrationError, not a silent overwrite. A plugin that "lies" here means one whose declared extension does not match what it handles, which cannot be checked in general; what can be checked is consistency of the declaration, so the registry enforces that and documents the rest.

Ownership: the registry owns the exporters through unique_ptr — they have no separate lifetime once registered.

Trade-off: first-come-wins on duplicates versus last-wins; I chose reject, because a silent overwrite is the kind of bug that shows up in production.


DQ8 — Persistence boundary

class Store {                              // the abstraction the domain depends on
public:
    virtual ~Store() = default;
    virtual std::vector<Record> load() const = 0;
    virtual void save(const std::vector<Record>&) const = 0;
};
class FileStore  : public Store { /* reads and writes the file format */ };
class MemoryStore: public Store { /* a std::vector, used in tests */ };

class Workshop {                            // depends on the abstraction, not on files
public:
    explicit Workshop(std::unique_ptr<Store> store);
    void persist() const;
    static Workshop restore(std::unique_ptr<Store> store);
private:
    std::unique_ptr<Store> store_;
};

Where the format lives: entirely inside FileStore. The domain knows only Records; the parsing, the delimiters and any version header never leak past FileStore, so a format change is one file.

Dependency direction: Workshop → Store ← FileStore. The domain is at the top of nothing; the concrete details depend on the domain's interface, not the reverse (dependency inversion). Tests substitute MemoryStore and never touch the disk.

Trade-off: an extra interface and an allocation for a single-file application. Worth it the moment a test needs to run without I/O, which it always does.


DQ9 — Refactoring a conditional

class ShippingPolicy {
public:
    virtual ~ShippingPolicy() = default;
    virtual Money cost(const Order&) const = 0;
};
class Standard : public ShippingPolicy { /* ... */ };
class Express  : public ShippingPolicy { /* ... */ };
class Pickup   : public ShippingPolicy { /* ... */ };
class Freight  : public ShippingPolicy { /* ... */ };

class Checkout {                            // holds one policy, chosen at construction
public:
    explicit Checkout(std::unique_ptr<ShippingPolicy> policy);
    Money shipping(const Order& o) const { return policy_->cost(o); }
private:
    std::unique_ptr<ShippingPolicy> policy_;
};

What a caller does to add a fifth method: writes one new class implementing cost, and passes it in. No if, no edit to Checkout, no recompilation of anything but the new file.

Why this beats the if: the original function had four reasons to change (one per method) and had to be re-read completely to add a fifth. Each policy now has one reason to change and is testable alone.

Trade-off: for two or three methods that never change, a switch is genuinely simpler. The refactor earns its keep when the set grows, which the question says it does every quarter.


DQ10 — Capstone architecture

Layers, outer to inner:

   presentation   (console menus, prompts)          depends on ↓
   application    (use cases: borrow, return, maintain)   depends on ↓
   domain         (Equipment, Member, Loan, MaintenanceRecord, rules)   depends on ↓
   ports          (Store, Clock) — interfaces owned by the domain
   adapters       (FileStore, SystemClock)          depend on → ports

Dependency direction: always inward. The domain knows nothing about a console or a file; it declares the interfaces it needs (Store, Clock) and the adapters implement them. That one rule gives both testability and substitutability.

Interfaces that allow substitution: Store (file vs in-memory for tests), Clock (real time vs a fixed date, so overdue logic is deterministic), and Notifier if reminders are added. Each is a small, single-method interface — segregated so a test double implements one method, not five.

Ownership: the domain aggregates Members and Equipment (they outlive loans) and composes Loans (a loan has no meaning without its participants). std::unique_ptr where ownership transfers, references where it does not.

Built last: persistence. Building FileStore first would freeze the domain model around a file format; building it last, behind Store, means the domain was shaped by the rules rather than by the parser. The MemoryStore used in tests is enough until then.

Trade-off: four layers is more indirection than a course project strictly needs, and a smaller system would legitimately collapse application into presentation. The trade is accepted because the question explicitly demands module-by-module testing, which needs the seams.


Scoring. Ten questions. A good answer names the types, justifies each relationship, and states a trade-off. See Marking Yourself.