Skip to content

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.

  1. 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).
  2. Capability queries on the interface, so a caller can ask before using a feature.
  3. A registry that maps a name to an exporter, plus a way to list what is available.
  4. Four exporters: CSV, JSON, fixed-width, and a debug one that writes to a stream supplied at registration.
  5. 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.
  6. 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.
  7. A registration validator: an exporter that claims streaming but whose write_one throws "not implemented" is caught by a self-check at registration, reported, and rejected.
  8. 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

  1. 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.
  2. Listing the registry gives names and capabilities, with no knowledge of the set compiled in.
  3. An unsupported capability request fails in a documented, testable way — an exception, or a documented return value. Choose one and be consistent.
  4. Two exporters writing the same dataset produce outputs that both parse back to the same records.
  5. The debug exporter writes to the stream supplied at registration, and emits nothing when that stream is a discarding stream.
  6. A bogus exporter claiming streaming is rejected at registration, with the reason printed; the registry continues to work.
  7. Registration refuses a duplicate name, and the outcome is documented (first wins, or replaced — choose).
  8. The registry's destruction releases every exporter exactly once, proven by counts.
  9. A caller exports through every capability-matching exporter with one loop and no type test.
  10. 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 switch on a format name, and no if (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:

  1. Write the interface's contract as at least five rules a third-party author must follow.
  2. List the capabilities, define each precisely, and mark which of the four exporters has which.
  3. Decide what an unsupported request does, and write the caller's pattern for adapting.
  4. Write the registration validator's checks.
  5. Write what the interface deliberately does not allow — for example, taking ownership of the caller's container — and why.

Implementation tasks

  1. Implement the interface with the capability queries.
  2. Implement the four exporters, each declaring its capabilities truthfully.
  3. Implement the registry: registration, listing, lookup, ownership, destruction.
  4. Implement the capability-matching caller loop and the export of the same dataset through all of them.
  5. Implement the registration validator and the bogus exporter that it rejects.
  6. Verify round-trip parsing for the two parseable formats.
  7. 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:

  1. How many owners does the exporter have, and how many times is its destructor called?
  2. What does the double deletion produce on this toolchain, and what does the language say the behaviour is?
  3. 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.
  4. Which test case in the table would have caught it earlier, and what would it have printed?

Reflection questions

  1. Your capability queries let callers adapt. What would the alternative be, and why does adapting beat branching on a name?
  2. Adding the fifth exporter — how many existing files did you change? What would have made it zero?
  3. What does your interface forbid, and what bug class does that forbid? Give the bug as code.
  4. 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?
  5. 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?
  6. 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 -Werror and no warnings
  • [ ] contract.md lists 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 switch or 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