35 min read

> — Erich Gamma, Ralph Johnson, Richard Helm, John Vlissides, Design Patterns (1994)

Learning Objectives

  • Define and implement interfaces (IInterface, GUID, reference counting)
  • Use interfaces for loose coupling and dependency injection
  • Apply SOLID design principles (especially Interface Segregation, Dependency Inversion)
  • Distinguish between abstract classes and interfaces (when to use each)
  • Implement multiple interfaces on a single class

Chapter 18: Interfaces, Abstract Classes, and Design Principles

"Program to an interface, not an implementation." — Erich Gamma, Ralph Johnson, Richard Helm, John Vlissides, Design Patterns (1994)

In Chapter 17, we built class hierarchies. We derived TRecurringExpense from TExpense, overrode virtual methods, and discovered the power of polymorphism: the ability to treat a collection of different objects uniformly through a shared ancestor. Inheritance gave us code reuse and substitutability, and for many problems, it is exactly the right tool.

But inheritance has a structural limitation that becomes painful as software grows. It is a single chain. A class in Object Pascal can inherit from exactly one parent. If TRecurringExpense inherits from TExpense, it cannot also inherit from TSerializable and TPrintable and TComparable. In languages with single inheritance — and that includes Object Pascal, Java, C#, and Swift — you cannot build a class that belongs to two unrelated hierarchies simultaneously.

This is where interfaces enter the picture. An interface defines a contract — a list of methods that a class promises to implement — without providing any implementation at all. A class can implement as many interfaces as it likes, escaping the single-inheritance restriction entirely. And because interfaces are pure contracts, they enable a style of programming that is more flexible, more testable, and more resilient to change than inheritance alone can provide.

This chapter teaches you how to define and implement interfaces in Object Pascal, how to combine them with abstract classes, and how to apply the SOLID design principles that guide professional software architecture. By the end, you will understand not just the mechanics of interfaces but the thinking behind them — and PennyWise will gain an IExportable interface and an abstract TReportGenerator class that demonstrate both.

This is arguably the most architecturally important chapter in the entire textbook. Classes and inheritance (Chapters 16-17) gave us the building blocks. Interfaces give us the blueprints for how those building blocks fit together. The difference between a program that is easy to maintain and one that is a nightmare to modify often comes down to how well interfaces and abstractions are used. The time you invest in understanding this chapter will repay itself many times over in every program you write from this point forward.

The concepts here are not Pascal-specific. They are universal to object-oriented design. Java developers spend years mastering interfaces and SOLID principles. C# developers build entire careers on dependency injection and interface-driven architecture. By learning these patterns now, in Pascal, you are building skills that transfer directly to every modern programming language and framework.


18.1 Why Interfaces?

Let us start with a concrete problem. Suppose PennyWise needs to export data. Rosa wants her expense reports in CSV format for her accountant. Tomas wants a plain-text summary he can paste into a message. Later, we might add PDF, XML, or JSON export. Each format is different, but the concept is the same: take some data, produce a formatted output.

The Inheritance Approach (and Its Limits)

One approach is to create a class hierarchy:

type
  TExporter = class
  public
    procedure ExportToStream(AStream: TStream); virtual; abstract;
  end;

  TCSVExporter = class(TExporter)
  public
    procedure ExportToStream(AStream: TStream); override;
  end;

  TTextExporter = class(TExporter)
  public
    procedure ExportToStream(AStream: TStream); override;
  end;

This works. But now consider: what if TExpense also needs to be exportable? And TBudget? And TCategory? These classes already inherit from their own parents. TExpense inherits from TObject (or perhaps from TFinancialItem). It cannot also inherit from TExporter. We are stuck.

You might try to solve this by making everything inherit from TExporter, but that violates the "is-a" relationship that inheritance represents. An expense is not an exporter. An expense is an expense that can be exported. The difference is crucial.

The Interface Approach

An interface expresses the "can-do" relationship. Instead of saying "an expense is an exporter," we say "an expense can export itself":

type
  IExportable = interface
    procedure ExportToStream(AStream: TStream);
    function ExportAsString: String;
  end;

Now any class — TExpense, TBudget, TCategory, TUser, anything — can declare that it implements IExportable without changing its inheritance chain. A class can implement IExportable and IPrintable and IComparable all at once. Interfaces give us multiple type compatibility without the dangers of multiple inheritance.

💡 Key Insight: Inheritance models "is-a" relationships (a TRecurringExpense is a TExpense). Interfaces model "can-do" relationships (a TExpense can be exported). Both are essential; they serve different purposes.

A Deeper Look: Why Single Inheritance Is Not Enough

To fully appreciate why interfaces exist, consider a more complex scenario. Suppose PennyWise has the following classes:

TObject
  └── TFinancialItem
        ├── TExpense
        │     └── TRecurringExpense
        ├── TIncome
        └── TTransfer

Now we need these cross-cutting capabilities: - Exportable: TExpense, TIncome, and TTransfer should all be exportable to CSV/XML. - Schedulable: TRecurringExpense and recurring income should support scheduling logic. - Auditable: Every financial item should log who created it and when. - Printable: Some items should be printable on paper with formatted output.

With single inheritance, there is no clean way to give all these capabilities to the right classes. You might try to push everything into TFinancialItem:

TFinancialItem = class
  procedure ExportToCSV; virtual; abstract;   { Not all items are exportable }
  procedure Schedule; virtual; abstract;       { Not all items are schedulable }
  procedure Audit; virtual; abstract;          { This belongs here }
  procedure Print; virtual; abstract;          { Not all items are printable }
end;

But now TTransfer must implement Schedule even though transfers are not schedulable. And TExpense must implement Print even though not all expenses are printable. You are forcing every class to implement methods it does not need. This is the "fat base class" antipattern, and it leads to empty stub implementations, misleading APIs, and fragile code.

Interfaces solve this cleanly:

TExpense = class(TFinancialItem, IExportable, IAuditable)
TRecurringExpense = class(TExpense, ISchedulable)
TIncome = class(TFinancialItem, IExportable, IAuditable)
TTransfer = class(TFinancialItem, IAuditable)  { Not exportable, not schedulable }

Each class declares exactly which capabilities it supports. No fat base class. No empty stubs. The type system tells you precisely what each object can do, and the compiler enforces it.

Why This Matters in the Real World

Large software systems — the kind that run hospitals, banks, and logistics companies — are built on interfaces. When a team of twenty developers works on the same codebase, interfaces define the boundaries between components. Team A writes the data layer and exposes an interface. Team B writes the UI layer and programs against that interface. The two teams can work independently, test independently, and change their implementations independently, because the contract — the interface — holds everything together.

This is not an academic concern. It is how professional Delphi and Free Pascal applications are architected, and it is one of the reasons Object Pascal remains a productive choice for large-scale development decades after its creation.


18.2 Defining Interfaces in Object Pascal

An interface in Object Pascal is declared with the interface keyword. Every interface implicitly descends from IInterface (also known as IUnknown in COM terminology), which defines three methods: QueryInterface, _AddRef, and _Release. You almost never call these directly, but they are there — and understanding them will save you from subtle bugs later.

Before we dive into the syntax, let us be clear about what an interface is not. An interface is not a class. It cannot be instantiated. It has no constructors, no destructors, no data fields, and no method implementations. It is purely declarative — it states what methods must exist, not how they work. This radical simplicity is what makes interfaces so powerful. Because they contain no implementation, they can never become a source of bugs. Because they contain no state, they can never introduce unexpected coupling. An interface is a contract in the purest sense: it says what you must do, not how you must do it.

The word "interface" has multiple meanings in programming. In Pascal, the interface section of a unit declares what is publicly visible — the public API. An interface type declares a behavioral contract for classes. These are related ideas: both define a surface that other code interacts with, without revealing internal details. When we say "interface" in this chapter, we mean the type, not the unit section.

Basic Syntax

type
  IGreeter = interface
    ['{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}']
    procedure SayHello;
    function GetGreeting: String;
  end;

Let us break this apart:

  1. interface — declares an interface type, not a class.
  2. The GUID — the string in brackets is a Globally Unique Identifier. It uniquely identifies this interface across all programs, all machines, all time. In the Free Pascal IDE or Lazarus, press Ctrl+Shift+G to generate a new GUID. Every interface that you plan to query at runtime (using Supports() or as) needs a GUID.
  3. Method signatures — the methods have no virtual, no abstract, no override. They are implicitly abstract. An interface provides zero implementation. It only declares what methods must exist.
  4. No fields — interfaces cannot contain data fields. They cannot contain constructors or destructors. They are pure behavioral contracts.

Properties in Interfaces

Interfaces can declare properties, but only through getter and setter methods:

type
  INameable = interface
    ['{B2C3D4E5-F6A7-8901-BCDE-F12345678901}']
    function GetName: String;
    procedure SetName(const AValue: String);
    property Name: String read GetName write SetName;
  end;

The property declaration in the interface means that any implementing class must provide GetName and SetName methods. The property line is syntactic sugar that makes client code read naturally: Obj.Name := 'Rosa'; instead of Obj.SetName('Rosa').

Interface Inheritance

Interfaces can inherit from other interfaces:

type
  ISerializable = interface(IExportable)
    ['{C3D4E5F6-A7B8-9012-CDEF-123456789012}']
    procedure LoadFromStream(AStream: TStream);
  end;

Here ISerializable extends IExportable, adding a LoadFromStream method to the existing ExportToStream and ExportAsString requirements. Any class implementing ISerializable must implement all methods from both interfaces.

⚠️ Common Mistake: Forgetting the GUID. Without a GUID, you cannot use Supports(), as, or QueryInterface to test whether an object implements an interface at runtime. The compiler will not warn you — but your runtime queries will silently fail. Always generate a GUID for every interface.


18.3 Implementing Interfaces

To implement an interface, a class lists the interface in its declaration and provides concrete implementations of every method the interface requires.

The Simplest Implementation

type
  TFriendlyGreeter = class(TInterfacedObject, IGreeter)
  public
    procedure SayHello;
    function GetGreeting: String;
  end;

procedure TFriendlyGreeter.SayHello;
begin
  WriteLn(GetGreeting);
end;

function TFriendlyGreeter.GetGreeting: String;
begin
  Result := 'Hello! Welcome to Pascal.';
end;

Note the parent class: TInterfacedObject, not just TObject. This is important. TInterfacedObject provides the default implementations of QueryInterface, _AddRef, and _Release that every interface requires. These three methods handle reference counting — automatic memory management for interface references.

Reference Counting: How It Works

When you assign an interface variable, Pascal's compiler automatically inserts calls to _AddRef (incrementing a counter) and _Release (decrementing it). When the counter reaches zero, the object is automatically freed. This means that objects accessed through interface variables are, in a sense, garbage-collected:

procedure DemoReferenceCount;
var
  Greeter: IGreeter;  // Interface variable
begin
  Greeter := TFriendlyGreeter.Create;  // _AddRef called, count = 1
  Greeter.SayHello;                     // Object is alive
  // When Greeter goes out of scope, _Release is called, count = 0
  // Object is automatically freed — no need to call Free!
end;

This is radically different from the manual memory management we practiced with classes in Chapters 16 and 17, where forgetting to call Free meant a memory leak. Interface reference counting handles cleanup for you.

⚠️ Critical Rule: Do not mix interface references and object references to the same instance carelessly. If you create an object, assign it to an interface variable, and also keep an object reference, the reference count can reach zero (freeing the object) while the object reference still points to the now-freed memory. The golden rule: once you start using an object through interfaces, use only interface references. We will see patterns for managing this in Section 18.7.

Practical Reference Counting Example

Let us trace reference counting through a realistic scenario to make sure the concept is solid:

procedure ProcessReport;
var
  Exp1: IExportable;   { Interface reference }
  Exp2: IExportable;   { Another interface reference }
begin
  { Step 1: Create object, assign to interface variable }
  Exp1 := TExpense.Create(42.50, 'Groceries', 'Shop', Date);
  { _AddRef called. RefCount = 1 }

  { Step 2: Copy interface reference }
  Exp2 := Exp1;
  { _AddRef called again. RefCount = 2 }

  { Step 3: Use both references }
  WriteLn(Exp1.ExportAsString);
  WriteLn(Exp2.ExportAsString);

  { Step 4: One reference goes out of scope (or is reassigned) }
  Exp2 := nil;
  { _Release called. RefCount = 1. Object still alive! }

  { Step 5: Last reference goes out of scope }
  { When ProcessReport exits, Exp1's _Release is called. RefCount = 0. }
  { Object is automatically freed. No memory leak. No manual Free call. }
end;

This is remarkably different from the manual memory management you have been practicing. With classes and Free, you must remember to destroy every object you create. With interfaces and reference counting, the runtime does it for you. The price you pay is a slight performance overhead (incrementing and decrementing the reference counter on every assignment) and the requirement to use interface references consistently.

One subtle but critical detail: circular references defeat reference counting. If object A holds an interface reference to object B, and object B holds an interface reference to object A, both reference counts will be at least 1 forever, and neither object will be freed. This is a genuine memory leak. The solution is to use weak references (plain object references that do not participate in reference counting) for one direction of the cycle. We will revisit this issue when we discuss more complex object graphs in later chapters.

The Supports Function

To check at runtime whether an object implements a specific interface, use the Supports function:

var
  Obj: TObject;
  Exp: IExportable;
begin
  Obj := TExpense.Create;
  if Supports(Obj, IExportable, Exp) then
  begin
    WriteLn('This object can be exported!');
    WriteLn(Exp.ExportAsString);
  end;
end;

Supports takes an object (or interface) reference, an interface type, and an output variable. It returns True if the object implements the interface, and if so, stores the interface reference in the output variable. This is the interface equivalent of the is and as operators we used with classes in Chapter 17.

Method Resolution Clauses

Sometimes a class implements two interfaces that have methods with the same name. Object Pascal handles this with method resolution clauses:

type
  IAmericanGreeter = interface
    ['{D4E5F6A7-B8C9-0123-DEFA-234567890123}']
    function GetGreeting: String;
  end;

  IBritishGreeter = interface
    ['{E5F6A7B8-C9D0-1234-EFAB-345678901234}']
    function GetGreeting: String;
  end;

  TBilingualGreeter = class(TInterfacedObject, IAmericanGreeter, IBritishGreeter)
  public
    function IAmericanGreeter.GetGreeting = GetAmericanGreeting;
    function IBritishGreeter.GetGreeting = GetBritishGreeting;
    function GetAmericanGreeting: String;
    function GetBritishGreeting: String;
  end;

The lines function IAmericanGreeter.GetGreeting = GetAmericanGreeting tell the compiler: "When this object is accessed through an IAmericanGreeter reference, route GetGreeting calls to GetAmericanGreeting." This is elegant and explicit — you can see exactly which method services which interface.


18.4 Multiple Interface Implementation

One of the great strengths of interfaces is that a single class can implement many of them. This is how you model objects that have multiple capabilities without resorting to multiple inheritance.

A Real Example: TExpense with Multiple Interfaces

Consider our TExpense class from PennyWise. An expense can be exported, it can be compared to other expenses, and it can be validated. Each of these capabilities is a separate concern:

type
  IExportable = interface
    ['{F6A7B8C9-D0E1-2345-FABC-456789012345}']
    procedure ExportToStream(AStream: TStream);
    function ExportAsString: String;
  end;

  IComparable = interface
    ['{A7B8C9D0-E1F2-3456-ABCD-567890123456}']
    function CompareTo(const AOther: IComparable): Integer;
  end;

  IValidatable = interface
    ['{B8C9D0E1-F2A3-4567-BCDE-678901234567}']
    function IsValid: Boolean;
    function GetValidationErrors: String;
  end;

  TExpense = class(TInterfacedObject, IExportable, IComparable, IValidatable)
  private
    FAmount: Currency;
    FCategory: String;
    FDate: TDateTime;
    FDescription: String;
  public
    constructor Create(AAmount: Currency; const ACategory, ADescription: String; ADate: TDateTime);
    // IExportable
    procedure ExportToStream(AStream: TStream);
    function ExportAsString: String;
    // IComparable
    function CompareTo(const AOther: IComparable): Integer;
    // IValidatable
    function IsValid: Boolean;
    function GetValidationErrors: String;
    // Own properties
    property Amount: Currency read FAmount;
    property Category: String read FCategory;
    property Date: TDateTime read FDate;
    property Description: String read FDescription;
  end;

Now TExpense can be used wherever any of those three interfaces is expected. A sorting routine can accept anything IComparable. An export engine can accept anything IExportable. A form validator can accept anything IValidatable. None of these systems needs to know about TExpense specifically — they only need to know about the interface.

The Power of Interface Collections

This opens up a powerful pattern: collecting disparate objects by shared interface:

procedure ExportAll(const Items: array of IExportable; AStream: TStream);
var
  Item: IExportable;
begin
  for Item in Items do
    Item.ExportToStream(AStream);
end;

You can pass this procedure an array containing a TExpense, a TBudget, a TCategory, and a TUser — objects from completely different class hierarchies — and they will all export correctly, because they all implement IExportable. This is polymorphism without inheritance, and it is extraordinarily flexible.

💡 Coming From Java/C#: Java interfaces work similarly to Pascal interfaces, though Java 8+ added default methods (method implementations in interfaces). C# interfaces are nearly identical in concept. If you know either language, Pascal interfaces will feel familiar — the syntax differs, but the concept is the same.

Understanding GUIDs

The GUID (Globally Unique Identifier) assigned to each interface deserves deeper explanation. A GUID is a 128-bit number formatted as five groups of hexadecimal digits: {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}. The probability of two randomly generated GUIDs colliding is astronomically small — roughly 1 in 2^122, which is more than the number of atoms in a grain of sand.

Why does an interface need a globally unique identifier? Because interfaces enable runtime type queries. When you call Supports(Obj, IExportable, Exp), the runtime searches the object's interface table for a GUID matching IExportable. Without GUIDs, two interfaces with the same method signatures (but different semantic meanings) would be indistinguishable at runtime.

In COM (Component Object Model) programming — the Windows technology that originally motivated interface GUIDs — interfaces are shared across process boundaries, across machines, and across programming languages. A GUID ensures that when your Pascal program asks a C++ COM server for IExportable, both sides agree on exactly which interface is being requested. Even if you never write COM code, GUIDs are essential for runtime interface queries within your own programs.

Generating GUIDs: In the Lazarus IDE, press Ctrl+Shift+G to generate a new GUID at the cursor position. From the command line, you can use uuidgen (Linux/macOS) or [System.Guid]::NewGuid() (PowerShell). Never reuse a GUID from one interface for another — always generate a fresh one.

Reference Counting Deep Dive

Reference counting is the mechanism by which Pascal automatically frees objects accessed through interface variables. The three methods inherited from IInterface_AddRef, _Release, and QueryInterface — are the machinery behind this automatic memory management.

The reference count is an integer stored in the object (specifically, in TInterfacedObject.FRefCount). Every time you assign an interface variable, _AddRef increments this counter. Every time an interface variable goes out of scope, is reassigned, or is set to nil, _Release decrements the counter. When the counter reaches zero, _Release calls Destroy (the destructor), freeing the object.

This means that objects managed through interfaces have a fundamentally different lifecycle from objects managed with Free. With Free, you control exactly when the object dies. With reference counting, the object dies when the last interface reference disappears — and you might not know exactly when that happens.

The practical consequence is the golden rule of interface references: once you assign an object to an interface variable, use only interface variables to access it. Mixing object references and interface references to the same instance leads to dangling pointers, because the reference count might hit zero (freeing the object) while an object reference still points to it.

{ DANGEROUS: mixing object and interface references }
var
  Obj: TExpense;
  Intf: IExportable;
begin
  Obj := TExpense.Create(42.50, 'Food', 'Lunch', Date);
  Intf := Obj;   { _AddRef: RefCount = 1 }
  Intf := nil;   { _Release: RefCount = 0, object is FREED }
  WriteLn(Obj.Amount);  { CRASH: Obj points to freed memory! }
end;

The fix is to use only interface references after the initial assignment, or to use CORBA interfaces (which disable reference counting) when you need to mix reference styles.

Interface Delegation

Free Pascal supports interface delegation, a feature that lets one class delegate the implementation of an interface to a field or property. This is a sophisticated technique for composing behavior:

type
  TExportHandler = class(TInterfacedObject, IExportable)
  public
    procedure ExportToStream(AStream: TStream);
    function ExportAsString: String;
  end;

  TExpense = class(TInterfacedObject, IExportable, IValidatable)
  private
    FExporter: IExportable;
  public
    property Exporter: IExportable read FExporter implements IExportable;
    // IValidatable implemented directly
    function IsValid: Boolean;
    function GetValidationErrors: String;
  end;

The implements keyword tells the compiler: "When someone asks this object for IExportable, delegate to the FExporter field." This is powerful for composition-over-inheritance designs. Instead of inheriting export behavior, TExpense has an export handler and delegates to it. You can swap the export handler at runtime — giving TExpense CSV export one moment and JSON export the next — without changing TExpense itself.

This is the delegation pattern — one of the most important patterns in object-oriented design. Instead of "is-a" (inheritance) or "can-do" (interface implementation), delegation says "has-a-thing-that-does-it."

Let us see delegation in a more complete PennyWise example. Suppose we want expenses to be exportable in multiple formats, with the format changeable at runtime:

type
  TCSVExportHandler = class(TInterfacedObject, IExportable)
  public
    procedure ExportToStream(AStream: TStream);
    function ExportAsString: String;
  end;

  TJSONExportHandler = class(TInterfacedObject, IExportable)
  public
    procedure ExportToStream(AStream: TStream);
    function ExportAsString: String;
  end;

  TExpense = class(TInterfacedObject, IExportable, IValidatable)
  private
    FExporter: IExportable;
    FAmount: Currency;
  public
    property Exporter: IExportable read FExporter write FExporter
      implements IExportable;
    procedure SetCSVExport;
    procedure SetJSONExport;
  end;

procedure TExpense.SetCSVExport;
begin
  FExporter := TCSVExportHandler.Create;
end;

procedure TExpense.SetJSONExport;
begin
  FExporter := TJSONExportHandler.Create;
end;

Now the same expense object can export itself as CSV or JSON depending on which handler is assigned. The export format is a runtime configuration, not a compile-time decision. This is delegation at its most flexible — the expense does not know or care how export works; it delegates to whatever handler is currently assigned.

This technique is particularly valuable when: - A class needs to implement an interface but the implementation is complex enough to warrant its own class. - You want to share an interface implementation across unrelated classes without code duplication. - You want to change behavior at runtime by swapping the delegate — which is exactly what we did above with SetCSVExport and SetJSONExport.

COM vs. CORBA Interface Modes

Free Pascal supports two interface modes that control whether reference counting is active:

{$interfaces com}    { Default — reference counting active }
{$interfaces corba}  { No reference counting, no _AddRef/_Release }

COM mode (the default) provides automatic reference counting. Objects accessed through interface references are freed automatically when the last reference goes out of scope. This is the mode we use throughout this chapter and the one you should use for most application code.

CORBA mode disables reference counting entirely. Interface references behave like regular object references — you must call Free manually. CORBA mode is useful when you want the contract-defining power of interfaces without the automatic memory management, which can cause issues in complex object ownership scenarios.

For PennyWise and most applications, stick with COM mode. The automatic memory management it provides is one of the major advantages of using interfaces.

⚠️ Mixing Modes Warning: If you mix COM and CORBA interfaces in the same program, you must be very careful about which objects are reference-counted and which are not. This is an advanced technique that requires thorough understanding of both modes. For now, use COM mode exclusively.

Interface Queries with as

In addition to the Supports function, you can use the as operator to query interfaces. The difference is that as raises an exception if the interface is not supported:

var
  Obj: TObject;
  Exp: IExportable;
begin
  Obj := TExpense.Create;
  // Using 'as' — raises exception if TExpense does not support IExportable
  Exp := Obj as IExportable;
  Exp.ExportToStream(SomeStream);
end;

Use as when you are confident the object supports the interface and want a clear error if it does not. Use Supports when you want to check conditionally (like an if statement).


18.5 Abstract Classes Revisited

In Chapter 17, we introduced the abstract keyword for methods that must be overridden. Now let us revisit abstract classes with a more nuanced understanding, because the relationship between abstract classes and interfaces is one of the most important design decisions you will make.

What Is an Abstract Class?

An abstract class is a class that cannot be instantiated directly. It exists solely to be inherited from. In Object Pascal, a class becomes abstract when it contains one or more abstract methods:

type
  TReportGenerator = class
  protected
    FTitle: String;
    FData: TExpenseList;
    procedure WriteHeader; virtual; abstract;
    procedure WriteBody; virtual; abstract;
    procedure WriteFooter; virtual; abstract;
  public
    constructor Create(const ATitle: String; AData: TExpenseList);
    procedure Generate; // Template method — not abstract
    property Title: String read FTitle;
  end;

The Generate method uses the Template Method pattern — it calls the abstract methods in a fixed order, letting subclasses fill in the details:

procedure TReportGenerator.Generate;
begin
  WriteHeader;
  WriteBody;
  WriteFooter;
end;

Concrete subclasses provide the actual implementations:

type
  TCSVReportGenerator = class(TReportGenerator)
  protected
    procedure WriteHeader; override;
    procedure WriteBody; override;
    procedure WriteFooter; override;
  end;

  TTextReportGenerator = class(TReportGenerator)
  protected
    procedure WriteHeader; override;
    procedure WriteBody; override;
    procedure WriteFooter; override;
  end;

What Abstract Classes Can Do That Interfaces Cannot

Abstract classes can contain:

  1. Fields — they can hold state (FTitle, FData).
  2. Concrete methods — they can provide default behavior (Generate).
  3. Constructors — they can initialize shared state.
  4. Access modifiers on members — they can make some members protected (visible to descendants) and others public.

Interfaces can do none of these things. An interface is a pure contract — all behavior, no state, no defaults. This distinction drives the choice between them.

When to Use Abstract Classes

Use an abstract class when:

  • You want to share code (not just a contract) among related classes.
  • The classes in question form a true "is-a" hierarchy.
  • You need to hold shared state that all subclasses use.
  • You want to implement the Template Method pattern, where a base class defines the skeleton of an algorithm and subclasses fill in the steps.

In our example, every report generator shares the same three-step generation process (WriteHeaderWriteBodyWriteFooter). The abstract class captures this shared structure. If we used an interface instead, every implementing class would have to re-implement the Generate method with the same header-body-footer sequence — duplicating code.


18.6 Interfaces vs. Abstract Classes: A Decision Framework

This is one of the most frequently asked questions in object-oriented design, and the answer is not "always use interfaces" or "always use abstract classes." It depends on what you are modeling and what flexibility you need. Here is a decision framework.

The Decision Matrix

Criterion Interface Abstract Class
Multiple implementation Yes — a class can implement many interfaces No — single inheritance only
Shared code No — interfaces have no implementation Yes — concrete methods inherited by all
Shared state No — interfaces cannot have fields Yes — fields inherited by all
"Can-do" relationship Yes — models capability No — models identity
"Is-a" relationship Weak (it is more "behaves-like-a") Strong — true taxonomic relationship
Evolving contracts Risky — adding a method breaks all implementors Safer — add a concrete method with default behavior
Reference counting / lifetime management Automatic (via _AddRef/_Release) Manual (Free / try..finally)
Performance Slight indirection cost (vtable + interface table) Slightly faster (single vtable)

Guidelines for Choosing

  1. Start with an interface when defining a capability that crosses class hierarchies. If things from different families need to share a behavior (exporting, comparing, serializing), use an interface.

  2. Use an abstract class when you have a family of closely related classes that share significant code. If you find yourself duplicating twenty lines of identical code across three interface implementations, that is a signal that an abstract class should hold the shared logic.

  3. Combine both when you need the best of each. Define an interface for the public contract, then provide an abstract base class that implements the interface and provides shared defaults:

type
  IExportable = interface
    ['{F6A7B8C9-D0E1-2345-FABC-456789012345}']
    procedure ExportToStream(AStream: TStream);
    function ExportAsString: String;
  end;

  TBaseExporter = class(TInterfacedObject, IExportable)
  protected
    function GetHeader: String; virtual;
    function GetFooter: String; virtual;
  public
    procedure ExportToStream(AStream: TStream); virtual; abstract;
    function ExportAsString: String; virtual; abstract;
  end;

Client code programs against IExportable. Implementation classes inherit from TBaseExporter and get the shared header/footer logic for free. New implementations that do not fit the base class can implement IExportable directly, bypassing TBaseExporter entirely. This is the "interface + abstract base class" pattern, and it is extremely common in professional Pascal code.

💡 Design Wisdom: When in doubt, start with an interface. It is easier to add an abstract base class later (to reduce duplication) than to refactor a deep inheritance hierarchy into interfaces after the fact. Interfaces impose the least commitment; abstract classes impose more.


18.7 SOLID Principles in Pascal

The SOLID principles are five design guidelines formulated by Robert C. Martin ("Uncle Bob") that, when followed, produce software that is easier to maintain, extend, and test. They are language-agnostic, but they are particularly well-supported by Object Pascal's type system. Let us examine each one with Pascal-specific examples.

S — Single Responsibility Principle

A class should have one, and only one, reason to change.

This principle says that each class should do one thing well. If a class handles both expense calculation and file I/O, it has two reasons to change: when the calculation logic changes and when the file format changes. Split it into two classes.

// WRONG: TExpense handles data AND file I/O
type
  TExpense = class
    procedure SaveToFile(const AFileName: String);
    procedure LoadFromFile(const AFileName: String);
    function CalculateTotal: Currency;
  end;

// RIGHT: Separate responsibilities
type
  TExpense = class
    function CalculateTotal: Currency;
  end;

  TExpenseFileHandler = class
    procedure SaveExpenses(const AFileName: String; AExpenses: TExpenseList);
    procedure LoadExpenses(const AFileName: String; AExpenses: TExpenseList);
  end;

O — Open/Closed Principle

Software entities should be open for extension, but closed for modification.

You should be able to add new behavior without changing existing code. Virtual methods and interfaces make this possible. When we add a new export format (say, JSON), we create a new class that implements IExportable — we do not modify any existing exporter class.

// Adding JSON export: create a new class, change nothing existing
type
  TJSONExporter = class(TBaseExporter)
  public
    procedure ExportToStream(AStream: TStream); override;
    function ExportAsString: String; override;
  end;

The existing CSV and text exporters are untouched. The ExportAll procedure that accepts IExportable items works with JSON exporters automatically. The system is open for extension (new exporters) and closed for modification (existing code unchanged).

L — Liskov Substitution Principle

Subclasses should be substitutable for their base classes without altering the correctness of the program.

If a function accepts a TExpense, it must work correctly when passed a TRecurringExpense. This is the principle we practiced in Chapter 17 with polymorphism. In interface terms: if a function accepts IExportable, every implementation of IExportable must produce valid export output — not crash, not return garbage, not violate the contract's semantic meaning.

I — Interface Segregation Principle

No client should be forced to depend on methods it does not use.

This is where interfaces truly shine. Instead of one "fat" interface with many methods, define several small, focused interfaces:

// WRONG: One fat interface
type
  IFinancialItem = interface
    procedure ExportToStream(AStream: TStream);
    function ExportAsString: String;
    function CompareTo(const AOther: IFinancialItem): Integer;
    function IsValid: Boolean;
    function GetValidationErrors: String;
    procedure Print;
    procedure SendEmail(const ARecipient: String);
  end;

// RIGHT: Segregated interfaces
type
  IExportable = interface
    procedure ExportToStream(AStream: TStream);
    function ExportAsString: String;
  end;

  IComparable = interface
    function CompareTo(const AOther: IComparable): Integer;
  end;

  IValidatable = interface
    function IsValid: Boolean;
    function GetValidationErrors: String;
  end;

  IPrintable = interface
    procedure Print;
  end;

  IEmailable = interface
    procedure SendEmail(const ARecipient: String);
  end;

Now a simple data validation routine only needs to accept IValidatable. It does not need to know about exporting, printing, or emailing. If the export interface changes, the validation code is unaffected. Each interface is small, focused, and independent.

How ISP guides interface design in practice. When you are designing interfaces, start by listing all the methods a class needs. Then group them by client: which methods does each consumer of the interface actually use? If different consumers use different subsets, split the interface along those lines.

For PennyWise, the report generator needs ExportToStream and ExportAsString — it never validates. The form validator needs IsValid and GetValidationErrors — it never exports. The budget checker needs CompareTo — it does neither of the above. Three different clients, three different method subsets, three different interfaces. ISP makes this explicit.

The cost of violating ISP is subtle but real. If IFinancialItem contains all seven methods, and you add an eighth (say, SendSMS), every class implementing IFinancialItem must add a SendSMS method — even TTransfer, which has no reason to send SMS messages. You end up with empty stub implementations scattered throughout your codebase, each one a maintenance burden and a trap for future developers who might assume the method does something meaningful.

The test for ISP compliance is simple: look at each method in an interface and ask, "Does every implementor need this?" If the answer is no for even one implementor, the interface is too fat.

D — Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions.

This is perhaps the most important SOLID principle for architecture, and the one most directly enabled by interfaces. Without Dependency Inversion, changes in low-level code (like switching from file storage to database storage) force changes in high-level code (like the report generator). With Dependency Inversion, both levels depend on an interface, and changes on either side are invisible to the other.

The name "inversion" refers to reversing the traditional dependency direction. In naive designs, high-level modules depend on low-level modules: TReportService creates and uses TCSVExporter directly. With inversion, the dependency is reversed through an interface: TReportService depends on IExportable, and TCSVExporter implements IExportable. Neither depends on the other — both depend on the abstraction.

This principle is the foundation of dependency injection, one of the most powerful architectural patterns in software. Instead of a high-level class directly creating the low-level objects it needs, it receives them through interfaces:

// WRONG: High-level depends on low-level
type
  TReportService = class
  private
    FFileExporter: TCSVExporter;  // Concrete dependency!
  public
    constructor Create;
  end;

constructor TReportService.Create;
begin
  FFileExporter := TCSVExporter.Create;  // Tightly coupled
end;

// RIGHT: Depend on abstraction
type
  TReportService = class
  private
    FExporter: IExportable;  // Interface dependency!
  public
    constructor Create(AExporter: IExportable);  // Injected
  end;

constructor TReportService.Create(AExporter: IExportable);
begin
  FExporter := AExporter;  // Loosely coupled
end;

In the "right" version, TReportService does not know or care whether it has a CSV exporter, a text exporter, or a JSON exporter. It only knows it has something IExportable. The concrete implementation is injected from outside — typically by the code that creates the service. This makes TReportService testable (you can inject a mock exporter in tests), flexible (swap formats without changing the service), and resilient to change.

Dependency Injection in Practice

Let us see a concrete example of how dependency injection works in PennyWise. The top-level program creates the concrete implementations and injects them:

{ Application startup — the "composition root" }
var
  Exporter: IExportable;
  Validator: IValidatable;
  Service: TReportService;
begin
  { Choose implementations based on configuration }
  if Config.ExportFormat = 'CSV' then
    Exporter := TCSVExporter.Create
  else if Config.ExportFormat = 'XML' then
    Exporter := TXMLExporter.Create
  else
    Exporter := TTextExporter.Create;

  { Inject into the service }
  Service := TReportService.Create(Exporter);
  try
    Service.GenerateReport;
  finally
    Service.Free;
  end;
end;

The composition root — the one place in the program where concrete types are selected and injected — is typically the main program or a dedicated factory class. Everything else works with interfaces. This centralizes the "what implementation?" decision in one place and keeps the rest of the codebase decoupled.

This pattern is used by virtually every large Delphi and Lazarus application, every Java Spring application, every .NET application with dependency injection containers, and every modern software system that takes testability and flexibility seriously.

Why "Inversion"? The word "inversion" refers to the fact that the dependency arrow reverses direction. In a naive design, high-level code depends on low-level code: TReportService directly imports and creates TCSVExporter. The dependency points downward (high to low). With Dependency Inversion, the high-level code depends on an abstraction (IExportable), and the low-level code also depends on that same abstraction (it implements IExportable). Both point toward the abstraction, which lives in the middle. The dependency has been inverted from pointing downward to pointing inward.

This inversion has a profound practical consequence: you can change the low-level code (swap CSV for JSON) without touching the high-level code, and you can change the high-level code (add a new report type) without touching the low-level code. Both sides evolve independently, connected only by the stable interface between them. In a codebase with fifty units and two hundred classes, this independence is not a luxury — it is the difference between a maintainable system and an unmaintainable one.

Testing with Mock Implementations

One of the most practical benefits of dependency injection is testability. Instead of injecting a real file exporter in your tests, you inject a mock that records what was exported:

type
  TMockExporter = class(TInterfacedObject, IExportable)
  public
    ExportCalled: Boolean;
    LastExportString: String;
    procedure ExportToStream(AStream: TStream);
    function ExportAsString: String;
  end;

function TMockExporter.ExportAsString: String;
begin
  ExportCalled := True;
  LastExportString := 'mock export data';
  Result := LastExportString;
end;

In your test, you inject the mock, run the operation, and check the mock's state:

procedure TestReportGeneration;
var
  Mock: TMockExporter;
  Service: TReportService;
begin
  Mock := TMockExporter.Create;
  Service := TReportService.Create(Mock);
  try
    Service.GenerateReport;
    Assert(Mock.ExportCalled, 'Export should have been called');
    Assert(Mock.LastExportString <> '', 'Export should produce output');
  finally
    Service.Free;
  end;
end;

No real files are written. No real network calls are made. The test runs in milliseconds and verifies the behavior in isolation. This is the testing superpower that interfaces and dependency injection give you.

📊 SOLID Summary: The SOLID principles are not rules to follow blindly. They are guidelines that, when applied with judgment, produce code that is easier to understand, test, and extend. In a small program, strict SOLID adherence can be over-engineering. In a program with ten units and fifty classes, ignoring SOLID produces a maintenance nightmare. Know the principles; apply them when they reduce complexity.


18.8 Design Patterns Preview: Strategy and Observer

Design patterns are named, well-tested solutions to recurring software design problems. They were catalogued famously by the "Gang of Four" (Gamma, Helm, Johnson, and Vlissides) in 1994, and they remain the shared vocabulary of professional software developers. This section previews two patterns that rely heavily on interfaces: Strategy and Observer.

We will fully implement these patterns in later chapters, but here we preview the concepts because they are interface-driven.

The Strategy Pattern

The Strategy pattern defines a family of algorithms, encapsulates each one in its own class (behind an interface), and makes them interchangeable. The client code selects which strategy to use at runtime.

Problem: PennyWise needs to sort expenses by different criteria — by date, by amount, by category. We do not want a giant case statement selecting the sort order. We want to add new sort orders without modifying existing code.

Solution: Define an interface for comparison strategies:

type
  IExpenseComparer = interface
    ['{C9D0E1F2-A3B4-5678-CDEF-789012345678}']
    function Compare(const A, B: TExpense): Integer;
  end;

  TDateComparer = class(TInterfacedObject, IExpenseComparer)
    function Compare(const A, B: TExpense): Integer;
  end;

  TAmountComparer = class(TInterfacedObject, IExpenseComparer)
    function Compare(const A, B: TExpense): Integer;
  end;

  TCategoryComparer = class(TInterfacedObject, IExpenseComparer)
    function Compare(const A, B: TExpense): Integer;
  end;

The sorting code accepts any IExpenseComparer:

procedure SortExpenses(var Expenses: array of TExpense; Comparer: IExpenseComparer);
var
  i, j: Integer;
  Temp: TExpense;
begin
  for i := Low(Expenses) to High(Expenses) - 1 do
    for j := i + 1 to High(Expenses) do
      if Comparer.Compare(Expenses[i], Expenses[j]) > 0 then
      begin
        Temp := Expenses[i];
        Expenses[i] := Expenses[j];
        Expenses[j] := Temp;
      end;
end;

At runtime, the user chooses a sort order, and we inject the appropriate comparer. Adding a new sort order means creating one new class — zero changes to the sorting code.

The Observer Pattern

The Observer pattern defines a one-to-many dependency between objects: when one object (the subject) changes state, all its dependents (observers) are notified and updated automatically.

Problem: When an expense is added to PennyWise, the budget tracker needs to update remaining amounts, the UI needs to refresh the expense list, and the auto-save system needs to write to disk. The expense system should not know about any of these other systems.

Solution: Define observer and subject interfaces:

type
  IExpenseObserver = interface
    ['{D0E1F2A3-B4C5-6789-DEFA-890123456789}']
    procedure OnExpenseAdded(const AExpense: TExpense);
    procedure OnExpenseRemoved(const AExpense: TExpense);
    procedure OnExpenseModified(const AExpense: TExpense);
  end;

  IExpenseSubject = interface
    ['{E1F2A3B4-C5D6-7890-EFAB-901234567890}']
    procedure RegisterObserver(AObserver: IExpenseObserver);
    procedure UnregisterObserver(AObserver: IExpenseObserver);
    procedure NotifyObservers;
  end;

The expense list implements IExpenseSubject. The budget tracker, UI, and auto-save each implement IExpenseObserver. When an expense is added, the list notifies all registered observers. The list does not know what the observers do with the notification — that is each observer's business.

This is loose coupling at its finest: the subject and observers communicate through interfaces and know nothing about each other's internals.

🔗 Forward Reference: We will implement these patterns fully in later chapters. Chapter 27 (GUI programming) uses the Observer pattern extensively for event handling. Chapter 23 (sorting algorithms) uses the Strategy pattern for custom comparisons. This preview gives you the conceptual vocabulary.

Bringing It Together: Why Interfaces Matter for PennyWise

Let us step back and consider how these patterns transform PennyWise's architecture. Before interfaces, PennyWise was a monolithic program where every component knew about every other component. The expense list knew about CSV files. The report generator knew about the console. The budget checker knew about the specific expense class.

With interfaces, each component communicates through contracts:

  • The expense list stores anything IExportable. It does not know or care whether items are TExpense, TRecurringExpense, or TIncome objects.
  • The report generator writes to any TStream descendant. It does not know whether the output goes to a file, the console, a network socket, or a memory buffer.
  • The budget checker accepts any IValidatable item. It does not know the internal structure of what it validates.
  • The export engine accepts any IExportFormatter. Adding a new format means writing one class — zero changes to the engine.

This is not over-engineering for a small project. It is establishing patterns that will scale as PennyWise grows from a console application to a GUI application (Part V), from flat files to a database (Chapter 31), from a single user to a networked application (Chapter 35). Every one of those transitions will be easier because the interfaces are in place now.

The professional developer's instinct is to ask: "What might change?" The answer to that question determines where interfaces belong. File formats change (use IExportFormatter). Storage backends change (use IRepository). Display mechanisms change (use IDisplayable). Validation rules change (use IValidatable). By putting interfaces at the boundaries of change, you create a program that bends instead of breaking.


18.9 Project Checkpoint: PennyWise Gets Interfaces

Time to apply what we have learned. In this checkpoint, we add two major architectural elements to PennyWise: the IExportable interface and the abstract TReportGenerator class.

Step 1: Define the IExportable Interface

unit PennyWise.Interfaces;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils;

type
  IExportable = interface
    ['{1A2B3C4D-5E6F-7A8B-9C0D-E1F2A3B4C5D6}']
    procedure ExportToStream(AStream: TStream);
    function ExportAsString: String;
    function GetExportFormat: String;
  end;

  IValidatable = interface
    ['{2B3C4D5E-6F7A-8B9C-0DE1-F2A3B4C5D6E7}']
    function IsValid: Boolean;
    function GetValidationErrors: String;
  end;

implementation

end.

Step 2: Implement IExportable on TExpense

unit PennyWise.Expense;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, PennyWise.Interfaces;

type
  TExpense = class(TInterfacedObject, IExportable, IValidatable)
  private
    FAmount: Currency;
    FCategory: String;
    FDate: TDateTime;
    FDescription: String;
  public
    constructor Create(AAmount: Currency; const ACategory, ADesc: String; ADate: TDateTime);
    // IExportable
    procedure ExportToStream(AStream: TStream);
    function ExportAsString: String;
    function GetExportFormat: String;
    // IValidatable
    function IsValid: Boolean;
    function GetValidationErrors: String;
    // Properties
    property Amount: Currency read FAmount write FAmount;
    property Category: String read FCategory write FCategory;
    property Date: TDateTime read FDate write FDate;
    property Description: String read FDescription write FDescription;
  end;

implementation

constructor TExpense.Create(AAmount: Currency; const ACategory, ADesc: String; ADate: TDateTime);
begin
  inherited Create;
  FAmount := AAmount;
  FCategory := ACategory;
  FDescription := ADesc;
  FDate := ADate;
end;

procedure TExpense.ExportToStream(AStream: TStream);
var
  Line: String;
begin
  Line := ExportAsString + LineEnding;
  AStream.WriteBuffer(Line[1], Length(Line));
end;

function TExpense.ExportAsString: String;
begin
  Result := Format('%s,%s,%.2f,%s',
    [FormatDateTime('yyyy-mm-dd', FDate), FCategory,
     FAmount, FDescription]);
end;

function TExpense.GetExportFormat: String;
begin
  Result := 'CSV';
end;

function TExpense.IsValid: Boolean;
begin
  Result := (FAmount > 0) and (FCategory <> '') and (FDate > 0);
end;

function TExpense.GetValidationErrors: String;
begin
  Result := '';
  if FAmount <= 0 then
    Result := Result + 'Amount must be positive. ';
  if FCategory = '' then
    Result := Result + 'Category is required. ';
  if FDate <= 0 then
    Result := Result + 'Date is required. ';
end;

end.

Step 3: Create the Abstract TReportGenerator

unit PennyWise.Reports;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, PennyWise.Interfaces;

type
  TReportGenerator = class abstract
  protected
    FTitle: String;
    FOutputStream: TStream;
    procedure WriteHeader; virtual; abstract;
    procedure WriteBody; virtual; abstract;
    procedure WriteFooter; virtual; abstract;
    procedure WriteString(const S: String);
    procedure WriteLnString(const S: String);
  public
    constructor Create(const ATitle: String);
    procedure Generate(AOutput: TStream);
    property Title: String read FTitle;
  end;

  TCSVReportGenerator = class(TReportGenerator)
  protected
    procedure WriteHeader; override;
    procedure WriteBody; override;
    procedure WriteFooter; override;
  end;

  TTextReportGenerator = class(TReportGenerator)
  protected
    procedure WriteHeader; override;
    procedure WriteBody; override;
    procedure WriteFooter; override;
  end;

implementation

{ TReportGenerator }

constructor TReportGenerator.Create(const ATitle: String);
begin
  inherited Create;
  FTitle := ATitle;
end;

procedure TReportGenerator.Generate(AOutput: TStream);
begin
  FOutputStream := AOutput;
  WriteHeader;
  WriteBody;
  WriteFooter;
end;

procedure TReportGenerator.WriteString(const S: String);
begin
  if (Length(S) > 0) and Assigned(FOutputStream) then
    FOutputStream.WriteBuffer(S[1], Length(S));
end;

procedure TReportGenerator.WriteLnString(const S: String);
begin
  WriteString(S + LineEnding);
end;

{ TCSVReportGenerator }

procedure TCSVReportGenerator.WriteHeader;
begin
  WriteLnString('Date,Category,Amount,Description');
end;

procedure TCSVReportGenerator.WriteBody;
begin
  WriteLnString('2024-01-15,Groceries,45.99,Weekly groceries');
  WriteLnString('2024-01-16,Transport,12.50,Bus pass');
  { In a real application, this would iterate over an expense list }
end;

procedure TCSVReportGenerator.WriteFooter;
begin
  WriteLnString('');
  WriteLnString('Report: ' + FTitle);
  WriteLnString('Generated: ' + DateTimeToStr(Now));
end;

{ TTextReportGenerator }

procedure TTextReportGenerator.WriteHeader;
begin
  WriteLnString('==============================');
  WriteLnString('  ' + FTitle);
  WriteLnString('==============================');
  WriteLnString('');
end;

procedure TTextReportGenerator.WriteBody;
begin
  WriteLnString('  2024-01-15  Groceries    $45.99  Weekly groceries');
  WriteLnString('  2024-01-16  Transport    $12.50  Bus pass');
  { In a real application, this would iterate over an expense list }
end;

procedure TTextReportGenerator.WriteFooter;
begin
  WriteLnString('');
  WriteLnString('------------------------------');
  WriteLnString('Generated: ' + DateTimeToStr(Now));
end;

end.

Step 4: Tying It Together

Here is how Rosa and Tomas use the new architecture. Rosa wants CSV for her accountant:

var
  Report: TReportGenerator;
  FileStream: TFileStream;
begin
  Report := TCSVReportGenerator.Create('January 2024 Expenses');
  FileStream := TFileStream.Create('january_report.csv', fmCreate);
  try
    Report.Generate(FileStream);
    WriteLn('CSV report exported successfully.');
  finally
    FileStream.Free;
    Report.Free;
  end;
end.

Tomas wants a readable text summary:

var
  Report: TReportGenerator;
  FileStream: TFileStream;
begin
  Report := TTextReportGenerator.Create('January 2024 Expenses');
  FileStream := TFileStream.Create('january_report.txt', fmCreate);
  try
    Report.Generate(FileStream);
    WriteLn('Text report exported successfully.');
  finally
    FileStream.Free;
    Report.Free;
  end;
end.

Notice how the calling code is almost identical. The variable type is the abstract TReportGenerator, not the concrete class. The only difference is which concrete class is created. This is the Open/Closed Principle in action: we can add THTMLReportGenerator, TXMLReportGenerator, or TPDFReportGenerator in the future without changing the calling code.

Rosa's PennyWise and Tomas's PennyWise are starting to look like the same application with different configurations. That is exactly the point. Good architecture makes customization a matter of swapping implementations, not rewriting code.

Checkpoint Status: PennyWise now has an IExportable interface (implemented by TExpense), an IValidatable interface, and an abstract TReportGenerator class with CSV and Text concrete implementations. The architecture is ready for new export formats — the Open/Closed Principle ensures we never need to modify existing exporters to add new ones.


What Rosa and Tomas Think

Rosa, the pragmatic freelance designer, immediately sees the value: "So I can export my expenses to CSV now and switch to JSON later without rewriting anything? And I just add a new class?" Exactly. The interface-based architecture means that adding a new export format — or a new report type, or a new storage backend — is always a matter of creating a new class, never modifying existing ones.

Tomas, the enthusiastic student, is initially overwhelmed. "This seems like a lot of code for something that was simpler with just procedures." He is not wrong — for a tiny program, interfaces add complexity. But PennyWise is no longer tiny. It has multiple data types, file I/O, validation, reporting, and export. And it is about to grow more: GUI, database, networking. The investment in interfaces pays for itself the first time Tomas adds a feature without breaking anything. "Oh," he says after adding a Markdown exporter in five minutes. "I get it now."

This is a universal experience in software development. Good architecture feels like overhead when a project is small. It feels like salvation when the project grows. The art is knowing when to invest — and Part III is the right time for PennyWise.


18.10 Summary

This chapter introduced two of the most important concepts in object-oriented design: interfaces and abstract classes. Let us recap what we have learned and why it matters.

Let us also acknowledge what we did not cover in full depth. COM interoperability, implementing interfaces without TInterfacedObject (using manual reference counting), advanced patterns like the Abstract Factory and the Adapter — these are topics for further study. The foundations we have built are strong enough to support all of these advanced uses. The foundations we have built in this chapter are solid enough to support all of these advanced uses when you need them.

Interfaces define pure contracts — lists of methods that a class promises to implement, with no implementation code and no data fields. They are declared with the interface keyword, should always have a GUID, and are implemented by listing them after the parent class in a class declaration. Objects accessed through interface references benefit from automatic reference counting, which eliminates certain classes of memory leaks.

Abstract classes are classes that cannot be instantiated directly. They can contain both abstract methods (which subclasses must override) and concrete methods (which provide shared default behavior). They can hold state in fields and define constructors. They model "is-a" relationships within a family of closely related classes.

The decision framework is straightforward: use interfaces for cross-hierarchy capabilities ("can-do"), abstract classes for shared code within a family ("is-a"), and combine both when you need the flexibility of interfaces with the code sharing of abstract classes.

The SOLID principles — Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — provide guidelines for structuring classes and interfaces in ways that produce maintainable, extensible software. Of these, Interface Segregation and Dependency Inversion are most directly enabled by the interface features we learned in this chapter.

The Strategy and Observer patterns preview how interfaces enable flexible, loosely-coupled architectures that we will develop further in later chapters.

PennyWise gained IExportable, IValidatable, and the abstract TReportGenerator with CSV and Text implementations — a meaningful architectural upgrade that will pay dividends as the application grows.

In Chapter 19, we turn to a different kind of robustness: exception handling. We will learn how try..except and try..finally blocks transform fragile code into code that handles failure gracefully — because in the real world, files go missing, networks drop, and users type letters where numbers belong. Exceptions give us a structured way to deal with all of it.


Key Terms Introduced in This Chapter

Term Definition
Interface A type that defines a pure contract — method signatures without implementation — that classes can implement
GUID Globally Unique Identifier assigned to an interface for runtime type querying
IInterface The root interface in Object Pascal from which all interfaces implicitly descend
TInterfacedObject A base class providing default implementations of _AddRef, _Release, and QueryInterface
Reference counting Automatic memory management for interface references: objects are freed when no interface references remain
Supports function Runtime check for whether an object implements a specific interface
Method resolution clause Syntax for mapping an interface method to a differently-named class method
Abstract class A class containing abstract methods that cannot be instantiated directly
Template Method pattern A pattern where a base class defines an algorithm skeleton, with subclasses overriding specific steps
Strategy pattern A pattern where algorithms are encapsulated behind an interface and can be swapped at runtime
Observer pattern A pattern where a subject notifies registered observers of state changes through an interface
SOLID Five design principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion
Dependency injection Providing a class's dependencies from outside (through interfaces) rather than creating them internally
Loose coupling A design goal where components interact through abstractions, minimizing direct dependencies