Case Study 1: Building a Plugin System
The Scenario
Imagine you are building a text editor called MicroEdit. The editor itself handles basic text operations — opening files, editing content, saving. But users want more: word counting, spell checking, auto-formatting, syntax highlighting, and dozens of other features you cannot predict in advance.
You could build every feature into the editor, but that creates a monolithic codebase where every new feature means modifying existing code (violating the Open/Closed Principle). Instead, you decide to build a plugin system: a framework that lets anyone — including third-party developers — write new features that the editor can load and execute without modification.
This case study walks through the design and implementation of such a system using interfaces.
Defining the Plugin Interface
The core of any plugin system is its contract — the interface that every plugin must implement:
unit MicroEdit.PluginAPI;
{$mode objfpc}{$H+}
interface
type
TPluginCapability = (pcTextTransform, pcAnalysis, pcAutoAction, pcUIExtension);
TPluginCapabilities = set of TPluginCapability;
IPlugin = interface
['{A1A1A1A1-B2B2-C3C3-D4D4-E5E5E5E5E5E5}']
function GetName: String;
function GetVersion: String;
function GetDescription: String;
function GetCapabilities: TPluginCapabilities;
procedure Initialize;
procedure Execute(var AText: String);
procedure Shutdown;
end;
implementation
end.
This interface defines the complete lifecycle of a plugin: it can identify itself (GetName, GetVersion, GetDescription), declare its capabilities, start up (Initialize), do work (Execute), and clean up (Shutdown).
Implementing Concrete Plugins
The Word Count Plugin
unit MicroEdit.Plugins.WordCount;
{$mode objfpc}{$H+}
interface
uses
SysUtils, MicroEdit.PluginAPI;
type
TWordCountPlugin = class(TInterfacedObject, IPlugin)
private
FWordCount: Integer;
function CountWords(const S: String): Integer;
public
function GetName: String;
function GetVersion: String;
function GetDescription: String;
function GetCapabilities: TPluginCapabilities;
procedure Initialize;
procedure Execute(var AText: String);
procedure Shutdown;
end;
implementation
function TWordCountPlugin.GetName: String;
begin
Result := 'Word Count';
end;
function TWordCountPlugin.GetVersion: String;
begin
Result := '1.0.0';
end;
function TWordCountPlugin.GetDescription: String;
begin
Result := 'Counts words in the current document and displays the result.';
end;
function TWordCountPlugin.GetCapabilities: TPluginCapabilities;
begin
Result := [pcAnalysis];
end;
procedure TWordCountPlugin.Initialize;
begin
FWordCount := 0;
WriteLn('[WordCount] Plugin initialized.');
end;
function TWordCountPlugin.CountWords(const S: String): Integer;
var
i: Integer;
InWord: Boolean;
begin
Result := 0;
InWord := False;
for i := 1 to Length(S) do
begin
if S[i] in [' ', #9, #10, #13] then
begin
if InWord then
begin
Inc(Result);
InWord := False;
end;
end
else
InWord := True;
end;
if InWord then
Inc(Result);
end;
procedure TWordCountPlugin.Execute(var AText: String);
begin
FWordCount := CountWords(AText);
WriteLn(Format('[WordCount] Document contains %d words.', [FWordCount]));
end;
procedure TWordCountPlugin.Shutdown;
begin
WriteLn('[WordCount] Plugin shut down. Final count: ', FWordCount);
end;
end.
The Auto-Trimmer Plugin
unit MicroEdit.Plugins.AutoTrim;
{$mode objfpc}{$H+}
interface
uses
SysUtils, MicroEdit.PluginAPI;
type
TAutoTrimPlugin = class(TInterfacedObject, IPlugin)
private
FLinesProcessed: Integer;
public
function GetName: String;
function GetVersion: String;
function GetDescription: String;
function GetCapabilities: TPluginCapabilities;
procedure Initialize;
procedure Execute(var AText: String);
procedure Shutdown;
end;
implementation
function TAutoTrimPlugin.GetName: String;
begin
Result := 'Auto Trim';
end;
function TAutoTrimPlugin.GetVersion: String;
begin
Result := '1.0.0';
end;
function TAutoTrimPlugin.GetDescription: String;
begin
Result := 'Removes trailing whitespace from every line.';
end;
function TAutoTrimPlugin.GetCapabilities: TPluginCapabilities;
begin
Result := [pcTextTransform, pcAutoAction];
end;
procedure TAutoTrimPlugin.Initialize;
begin
FLinesProcessed := 0;
WriteLn('[AutoTrim] Plugin initialized.');
end;
procedure TAutoTrimPlugin.Execute(var AText: String);
var
Lines: TStringArray;
i: Integer;
begin
Lines := AText.Split([LineEnding]);
FLinesProcessed := Length(Lines);
for i := 0 to High(Lines) do
Lines[i] := TrimRight(Lines[i]);
AText := String.Join(LineEnding, Lines);
WriteLn(Format('[AutoTrim] Trimmed %d lines.', [FLinesProcessed]));
end;
procedure TAutoTrimPlugin.Shutdown;
begin
WriteLn('[AutoTrim] Plugin shut down. Total lines processed: ', FLinesProcessed);
end;
end.
The Uppercase Converter Plugin
unit MicroEdit.Plugins.UpperCase;
{$mode objfpc}{$H+}
interface
uses
SysUtils, MicroEdit.PluginAPI;
type
TUpperCasePlugin = class(TInterfacedObject, IPlugin)
public
function GetName: String;
function GetVersion: String;
function GetDescription: String;
function GetCapabilities: TPluginCapabilities;
procedure Initialize;
procedure Execute(var AText: String);
procedure Shutdown;
end;
implementation
function TUpperCasePlugin.GetName: String;
begin
Result := 'Uppercase Converter';
end;
function TUpperCasePlugin.GetVersion: String;
begin
Result := '1.0.0';
end;
function TUpperCasePlugin.GetDescription: String;
begin
Result := 'Converts all text to uppercase.';
end;
function TUpperCasePlugin.GetCapabilities: TPluginCapabilities;
begin
Result := [pcTextTransform];
end;
procedure TUpperCasePlugin.Initialize;
begin
WriteLn('[UpperCase] Plugin initialized.');
end;
procedure TUpperCasePlugin.Execute(var AText: String);
begin
AText := UpperCase(AText);
WriteLn('[UpperCase] Text converted to uppercase.');
end;
procedure TUpperCasePlugin.Shutdown;
begin
WriteLn('[UpperCase] Plugin shut down.');
end;
end.
The Plugin Manager
The TPluginManager class manages the lifecycle of all registered plugins:
unit MicroEdit.PluginManager;
{$mode objfpc}{$H+}
interface
uses
SysUtils, MicroEdit.PluginAPI;
type
TPluginManager = class
private
FPlugins: array of IPlugin;
FCount: Integer;
public
constructor Create;
procedure RegisterPlugin(APlugin: IPlugin);
procedure InitializeAll;
procedure ExecuteAll(var AText: String);
procedure ShutdownAll;
procedure ListPlugins;
property Count: Integer read FCount;
end;
implementation
constructor TPluginManager.Create;
begin
inherited Create;
FCount := 0;
SetLength(FPlugins, 0);
end;
procedure TPluginManager.RegisterPlugin(APlugin: IPlugin);
begin
Inc(FCount);
SetLength(FPlugins, FCount);
FPlugins[FCount - 1] := APlugin;
end;
procedure TPluginManager.InitializeAll;
var
i: Integer;
begin
WriteLn('=== Initializing ', FCount, ' plugins ===');
for i := 0 to FCount - 1 do
FPlugins[i].Initialize;
WriteLn('=== All plugins initialized ===');
WriteLn;
end;
procedure TPluginManager.ExecuteAll(var AText: String);
var
i: Integer;
begin
WriteLn('=== Executing all plugins ===');
for i := 0 to FCount - 1 do
FPlugins[i].Execute(AText);
WriteLn('=== All plugins executed ===');
WriteLn;
end;
procedure TPluginManager.ShutdownAll;
var
i: Integer;
begin
WriteLn('=== Shutting down all plugins ===');
for i := FCount - 1 downto 0 do
FPlugins[i].Shutdown;
SetLength(FPlugins, 0);
FCount := 0;
WriteLn('=== All plugins shut down ===');
end;
procedure TPluginManager.ListPlugins;
var
i: Integer;
begin
WriteLn('Registered Plugins:');
for i := 0 to FCount - 1 do
WriteLn(Format(' %d. %s v%s — %s',
[i + 1, FPlugins[i].GetName, FPlugins[i].GetVersion,
FPlugins[i].GetDescription]));
WriteLn;
end;
end.
Putting It All Together
program MicroEditDemo;
{$mode objfpc}{$H+}
uses
SysUtils, MicroEdit.PluginAPI, MicroEdit.PluginManager,
MicroEdit.Plugins.WordCount, MicroEdit.Plugins.AutoTrim,
MicroEdit.Plugins.UpperCase;
var
Manager: TPluginManager;
DocumentText: String;
begin
DocumentText := 'Hello, world! ' + LineEnding +
'This is MicroEdit. ' + LineEnding +
'Plugins make it extensible. ';
WriteLn('Original text:');
WriteLn(DocumentText);
WriteLn;
Manager := TPluginManager.Create;
try
Manager.RegisterPlugin(TWordCountPlugin.Create);
Manager.RegisterPlugin(TAutoTrimPlugin.Create);
Manager.RegisterPlugin(TUpperCasePlugin.Create);
Manager.ListPlugins;
Manager.InitializeAll;
Manager.ExecuteAll(DocumentText);
WriteLn('Processed text:');
WriteLn(DocumentText);
WriteLn;
Manager.ShutdownAll;
finally
Manager.Free;
end;
end.
Design Analysis
Why Interfaces Were the Right Choice
- Open/Closed Principle: Adding a new plugin means writing one new class. The
TPluginManager, theIPlugininterface, and all existing plugins remain unchanged. - Loose coupling: The manager knows nothing about specific plugin implementations. It only knows
IPlugin. - Independent development: Each plugin is in its own unit. Different developers can work on different plugins without conflicts.
- Testability: You can create a
TMockPluginfor testing the manager without any real plugin logic.
Extension Points
In a production system, you would extend this further:
- Priority ordering: Add a GetPriority method to IPlugin so the manager executes plugins in the correct order.
- Capability filtering: Execute only plugins with specific capabilities (e.g., only pcTextTransform plugins).
- Dynamic loading: Load plugins from DLLs at runtime using LoadLibrary and function pointers — a technique we will explore in Chapter 37.
- Error isolation: Wrap each Execute call in a try..except block (Chapter 19) so one crashing plugin does not bring down the entire editor.
The Takeaway
A plugin system is the canonical use case for interfaces. The system defines a contract; plugins fulfill it. The system does not depend on any specific plugin; plugins do not depend on each other. This is the essence of good software architecture: components that communicate through abstractions and can evolve independently.