Skip to Content
Course content

193: Practice Exercise: Building a Simple Plugin Architecture with Abstract Interfaces

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I worked with a developer named Marcus who was building a data ingestion tool. Every time a client wanted to support a new file format—say, moving from CSV to Parquet or Avro—Marcus had to open the core processing loop and add another else if block. By the time they supported ten formats, that single function was a three-thousand-line nightmare. The worst part? Every time he added a new format, he risked breaking the existing ones because he was touching the "golden" core logic. He was terrified to compile the project.

This is exactly why we use abstract interfaces for plugin architectures. Instead of the core application knowing every possible format, the application defines a "contract" that any plugin must follow. The core doesn't care how a file is parsed; it only cares that the object it's holding has a parse() method. This transforms your code from a rigid monolith into a flexible system where you can add functionality without ever touching the existing, tested logic.

Defining the Interface Contract

To get this right, you need a base class that consists entirely of pure virtual functions. I always tell people to prefix these with an 'I' (like IPlugin or IDataParser)—it's a convention that immediately tells anyone reading your code, "You cannot instantiate this; it's a blueprint."

class IDataParser {
public:
    virtual ~IDataParser() = default; // Never forget the virtual destructor in interfaces!
    virtual bool canHandle(const std::string& extension) = 0;
    virtual std::string parse(const std::string& filePath) = 0;
};

That virtual destructor is non-negotiable. Since the main application will be deleting these plugins via a pointer to the interface, failing to make the destructor virtual leads to undefined behavior and memory leaks. It's one of those C++ "gotchas" that can haunt you for weeks if you forget it.

Decoupling Execution from Implementation

Once the interface is set, your main application logic should operate on a collection of these interfaces, not the concrete classes. I prefer using std::unique_ptr here to ensure ownership is clear. The main engine simply iterates through its list of registered plugins and asks, "Can you handle this file?"

std::vector<std::unique_ptr<IDataParser>> plugins;

// Adding plugins (this could happen via a config file or a registry)
plugins.push_back(std::make_unique<CsvParser>());
plugins.push_back(std::make_unique<JsonParser>());

// The core logic remains blissfully ignorant of the concrete types
for (const auto& plugin : plugins) {
    if (plugin->canHandle(".json")) {
        std::string data = plugin->parse("config.json");
        // process data...
    }
}

Notice how the loop doesn't change regardless of whether you have two plugins or two hundred. You've successfully isolated the "what" (parsing data) from the "how" (the specific logic for JSON or CSV).

Managing Plugin Lifetimes and Registration

In a real-world production system, you wouldn't manually push_back every plugin in main(). You'd likely have a PluginManager class. I've found that a simple Factory pattern works best here. The manager maintains a map of creators or simply loads shared libraries (.dll or .so files) at runtime using dlopen or LoadLibrary.

Even in a simpler, compile-time plugin system, keeping the registration logic separate from the execution logic is key. It allows you to swap out implementations—perhaps replacing a slow, legacy parser with a high-performance one—without the rest of the system even noticing the change. You're essentially treating your own code as a set of interchangeable parts.




📋 Practical Task

Exercise: Building a Multi-Format Log Analyzer Plugin System

Your goal is to implement a plugin-based log analysis system. The system should be able to process different log formats (e.g., Simple Text and JSON) without the main engine knowing the details of those formats.

  • The Interface: Create an abstract base class ILogParser with a virtual destructor and two pure virtual methods: bool isValid(const std::string& line) and std::string extractMessage(const std::string& line).
  • The Concrete Plugins:
    • Implement TextLogParser: It considers a line valid if it starts with "[INFO]" or "[ERROR]" and extracts everything after the prefix.
    • Implement JsonLogParser: It considers a line valid if it contains the characters {"msg": and extracts the value associated with the "msg" key.
  • The Engine: Create a LogProcessor class that holds a std::vector of std::unique_ptr<ILogParser>. Implement a method processLine(const std::string& line) that iterates through all loaded plugins and prints the extracted message if a plugin identifies the line as valid.
  • The Test: In your main function, register both parsers into the LogProcessor and pass it a list of mixed strings (some JSON-like, some text-like) to verify that the correct plugin handles the correct line.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.