Practice Lab 29 — A plugin registry behind one interface¶
Difficulty: Advanced · Estimated time: 6 hours · Module 15: SOLID Principles and Design Patterns
The system must accept exporters it has never heard of, discover what they can do, and refuse the ones that claim more than they deliver.
At a glance¶
| Difficulty | Advanced |
| Estimated time | 6 hours |
| Module | Module 15 — SOLID Principles and Design Patterns |
| Concepts practised | Interface segregation, dependency inversion, abstraction boundaries, registries keyed by name, polymorphic objects in standard containers, capability queries, factory functions returning owning pointers, validation of registered components |
| Prerequisite | Practice Lab 28 |
| Class design | yours — the interface contract is given, the implementations are yours |
Scenario¶
A reporting application exports data through interchangeable exporters: CSV, JSON, a fixed-width text format, and a "print to screen" debug exporter. Third-party exporters will be added later, written by people who cannot modify the reporting application.
The current code has a switch (format) with four cases, so adding a format means editing the export
function — and the technical lead has said the next release must not require that. The exporters also differ
in what they can do: only some support headers, only some support a caller-chosen delimiter, and one cannot
stream and needs the whole dataset at once. Callers must be able to ask before using an exporter.
Problem statement¶
Build an exporter registry.
- An exporter interface every exporter implements, with a documented contract: what a caller may assume, what the exporter must guarantee, and what it may not do (such as holding a reference to the data after the call).
- Capability queries on the interface, so a caller can ask before using a feature.
- A registry that maps a name to an exporter, plus a way to list what is available.
- Four exporters: CSV, JSON, fixed-width, and a debug one that writes to a stream supplied at registration.
- Each exporter declares its capabilities truthfully, and a test verifies the claim: if an exporter says it supports headers, the test exercises headers; if it says it does not, the test confirms the request is refused rather than silently ignored.
- A caller that does not know the set of exporters: enumerate the registry, choose the ones that have a
capability, and export with all of them — no
switch, no string comparison in the caller. - A registration validator: an exporter that claims
streamingbut whosewrite_onethrows "not implemented" is caught by a self-check at registration, reported, and rejected. - Clean ownership: exporters are owned by the registry, live for its lifetime, and are destroyed with it. Counts prove it.
In contract.md:
- The interface contract, as the rules a third-party author must follow — at least five.
- Which capabilities exist, what each means precisely, and which exporters have which.
- What a caller must do when a capability is absent, and the pattern used instead of
switch. - The registration validator's checks and what it rejects.
- One thing the interface deliberately does not allow, and why not.
Learning objectives¶
By the end of this lab you will be able to:
- Define an interface whose contract includes what implementations may not do.
- Expose capabilities and let callers adapt, instead of branching on a type name.
- Own polymorphic objects in a registry and release them with it.
- Validate a component at the boundary where it enters the system.
- Add a new implementation without editing any existing code — and prove it with a diff.
Functional requirements¶
- A new exporter requires zero changes to the reporting application — only a new file and one registration line. Prove it by describing the diff before and after adding the fourth exporter.
- Listing the registry gives names and capabilities, with no knowledge of the set compiled in.
- An unsupported capability request fails in a documented, testable way — an exception, or a documented return value. Choose one and be consistent.
- Two exporters writing the same dataset produce outputs that both parse back to the same records.
- The debug exporter writes to the stream supplied at registration, and emits nothing when that stream is a discarding stream.
- A bogus exporter claiming streaming is rejected at registration, with the reason printed; the registry continues to work.
- Registration refuses a duplicate name, and the outcome is documented (first wins, or replaced — choose).
- The registry's destruction releases every exporter exactly once, proven by counts.
- A caller exports through every capability-matching exporter with one loop and no type test.
- The data passed to an exporter is not retained: after the export call returns, a mutation of the source does not change any output written later.
Constraints¶
- Header and source per exporter, with a documented build command.
- No
switchon a format name, and noif (name == "csv"), anywhere — searched for and recorded. - Registration takes an owning pointer, not a reference or a raw borrowed pointer.
- No warnings.
Starter task¶
In contract.md, before coding:
- Write the interface's contract as at least five rules a third-party author must follow.
- List the capabilities, define each precisely, and mark which of the four exporters has which.
- Decide what an unsupported request does, and write the caller's pattern for adapting.
- Write the registration validator's checks.
- Write what the interface deliberately does not allow — for example, taking ownership of the caller's container — and why.
Implementation tasks¶
- Implement the interface with the capability queries.
- Implement the four exporters, each declaring its capabilities truthfully.
- Implement the registry: registration, listing, lookup, ownership, destruction.
- Implement the capability-matching caller loop and the export of the same dataset through all of them.
- Implement the registration validator and the bogus exporter that it rejects.
- Verify round-trip parsing for the two parseable formats.
- Add a fifth exporter, and record the diff — files touched, lines changed.
Test cases¶
| # | Action | Expected result |
|---|---|---|
| 1 | List the registry | All registered names with their capabilities |
| 2 | Look up an unknown name | Documented failure, no crash |
| 3 | Export the same dataset through CSV and JSON | Both parse back to identical records |
| 4 | Ask CSV for headers | Supported; headers written |
| 5 | Ask the debug exporter for headers | Refused in the documented way |
| 6 | Ask for a capability nobody has | Documented result; no silent ignore |
| 7 | Export through all streaming exporters with one loop | No type test in the caller |
| 8 | Register the bogus exporter | Rejected, reason printed, registry still usable |
| 9 | Register a duplicate name | Documented outcome, and it is enforced |
| 10 | Destroy the registry | Every exporter destroyed exactly once; counts zero |
| 11 | Mutate the source after an export call | No output changes |
| 12 | Add a fifth exporter | No existing file edited except the registration line — recorded |
| 13 | Search for switch on a format name and for name == " |
Nothing found in the caller |
Boundary cases to run deliberately: an empty dataset; a dataset with a field containing the CSV separator and the JSON quote character; a very long field; a registry with no exporters.
Expected behaviour¶
registry: csv (stream, headers, delimiter), json (stream),
fixed (stream, headers), debug (stream)
capability-matched export: csv, fixed
csv: code,description,qty
A-1,"gasket, large",12
fixed: A-1 gasket, large 12
both parse back to: {A-1, gasket, large, 12}
debug exporter asked for headers -> CapabilityUnsupported: debug does not support headers
register bogus (claims streaming, throws):
rejected: claims 'streaming' but write_one threw NotSupported
registry still has 4 exporters
duplicate name 'csv': first registration kept (documented)
registry destroyed: 4 exporters destroyed, 4 constructions, 0 alive
after export, source mutated: output unchanged
Extension challenge¶
Support exporters that need the whole dataset and those that stream, through the same caller.
Record in contract.md:
- How the interface accommodates both without forcing one to pretend to be the other.
- What the caller does differently for the buffered case, and whether the caller must know which it has.
- The memory implication of the buffered path for a large dataset, and a bound you would impose.
- The change cost: files, operations, lines, and whether any caller must change.
Debugging challenge¶
The registry works until an exporter is registered twice under two names, and then crashes on destruction:
class Registry {
public:
void add(const std::string& name, Exporter* exporter) {
exporters_[name] = std::shared_ptr<Exporter>(exporter); // same pointer, two owners
}
private:
std::map<std::string, std::shared_ptr<Exporter>> exporters_;
};
Reproduce it — register one exporter under csv and comma — and answer:
- How many owners does the exporter have, and how many times is its destructor called?
- What does the double deletion produce on this toolchain, and what does the language say the behaviour is?
- Fix it so that one exporter can answer to two names with one owner, and say what the fix costs at registration and at lookup.
- Which test case in the table would have caught it earlier, and what would it have printed?
Reflection questions¶
- Your capability queries let callers adapt. What would the alternative be, and why does adapting beat branching on a name?
- Adding the fifth exporter — how many existing files did you change? What would have made it zero?
- What does your interface forbid, and what bug class does that forbid? Give the bug as code.
- An unsupported request fails in the documented way. Which is better here, an exception or a return value, and what in the caller's situation decided it?
- The registration validator caught one lie. What other lies could a third-party exporter tell that your validator cannot catch, and what would catch those?
- Who owns the exporters, and what happens to an exporter that a caller stored a pointer to after the registry is destroyed?
Completion checklist¶
- [ ] It builds from a documented command with
-Wall -Wextra -Werrorand no warnings - [ ]
contract.mdlists at least five rules, the capabilities with their meanings, and the forbidden thing - [ ] Four exporters implement the interface and declare their capabilities truthfully
- [ ] Capability claims are verified by tests in both directions
- [ ] The caller adapts with one loop and no type test — searched for and recorded
- [ ] No
switchor name comparison in the caller — searched for and recorded - [ ] The bogus exporter is rejected at registration and the registry survives
- [ ] The registry owns and destroys every exporter exactly once
- [ ] Outputs parse back to identical records where the format allows
- [ ] All thirteen test cases run, with output recorded
- [ ] The debugging challenge was explained before being fixed
- [ ] I can state the contract a third-party exporter author must follow
Related: Lab index · Module 15 — SOLID Principles and Design Patterns · Previous: Practice Lab 28 · Next: Practice Lab 30 — Refactoring a conditional into a design