Design Questions — Model Answers¶
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.