Case Study 2: Export Formats: PDF, CSV, XML

The Problem

Rosa Martinelli needs PennyWise to export her expense data in multiple formats. Her accountant wants CSV files that can be opened in Excel. Her tax software requires XML. She would also like a formatted text report she can print. Each format has different structure, escaping rules, and conventions — but the core data is the same.

The naive approach would be to add SaveAsCSV, SaveAsXML, and SaveAsText methods directly to the TExpense class. But that violates the Single Responsibility Principle: TExpense should model an expense, not know about file formats. And every time a new format is needed, we would have to modify TExpense.

Instead, we use the IExportable interface combined with the Strategy pattern to create a clean, extensible export architecture.

The Architecture

The Interface Layer

unit Export.Interfaces;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils;

type
  IExportable = interface
    ['{AAAA1111-BBBB-CCCC-DDDD-EEEE11111111}']
    function GetFieldCount: Integer;
    function GetFieldName(Index: Integer): String;
    function GetFieldValue(Index: Integer): String;
  end;

  IExportFormatter = interface
    ['{AAAA2222-BBBB-CCCC-DDDD-EEEE22222222}']
    function GetFormatName: String;
    function GetFileExtension: String;
    procedure BeginDocument(AStream: TStream);
    procedure BeginRecord;
    procedure WriteField(const AName, AValue: String);
    procedure EndRecord;
    procedure EndDocument;
  end;

implementation

end.

Notice the separation: IExportable is what data objects implement (they expose their fields), and IExportFormatter is what format handlers implement (they know how to write a specific format). This is Interface Segregation in action — the data objects know nothing about formatting, and the formatters know nothing about specific data types.

The Data Classes

unit Finance.Expense;

{$mode objfpc}{$H+}

interface

uses
  SysUtils, Export.Interfaces;

type
  TExpense = class(TInterfacedObject, IExportable)
  private
    FDate: TDateTime;
    FCategory: String;
    FAmount: Currency;
    FDescription: String;
  public
    constructor Create(ADate: TDateTime; const ACategory: String;
      AAmount: Currency; const ADescription: String);
    // IExportable
    function GetFieldCount: Integer;
    function GetFieldName(Index: Integer): String;
    function GetFieldValue(Index: Integer): String;
    // Properties
    property Date: TDateTime read FDate;
    property Category: String read FCategory;
    property Amount: Currency read FAmount;
    property Description: String read FDescription;
  end;

implementation

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

function TExpense.GetFieldCount: Integer;
begin
  Result := 4;
end;

function TExpense.GetFieldName(Index: Integer): String;
begin
  case Index of
    0: Result := 'Date';
    1: Result := 'Category';
    2: Result := 'Amount';
    3: Result := 'Description';
  else
    Result := '';
  end;
end;

function TExpense.GetFieldValue(Index: Integer): String;
begin
  case Index of
    0: Result := FormatDateTime('yyyy-mm-dd', FDate);
    1: Result := FCategory;
    2: Result := Format('%.2f', [FAmount]);
    3: Result := FDescription;
  else
    Result := '';
  end;
end;

end.

The CSV Formatter

unit Export.CSV;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, Export.Interfaces;

type
  TCSVFormatter = class(TInterfacedObject, IExportFormatter)
  private
    FStream: TStream;
    FFirstField: Boolean;
    FFirstRecord: Boolean;
    procedure WriteStr(const S: String);
    function EscapeCSV(const S: String): String;
  public
    function GetFormatName: String;
    function GetFileExtension: String;
    procedure BeginDocument(AStream: TStream);
    procedure BeginRecord;
    procedure WriteField(const AName, AValue: String);
    procedure EndRecord;
    procedure EndDocument;
  end;

implementation

procedure TCSVFormatter.WriteStr(const S: String);
begin
  if Length(S) > 0 then
    FStream.WriteBuffer(S[1], Length(S));
end;

function TCSVFormatter.EscapeCSV(const S: String): String;
begin
  if (Pos(',', S) > 0) or (Pos('"', S) > 0) or (Pos(#10, S) > 0) then
    Result := '"' + StringReplace(S, '"', '""', [rfReplaceAll]) + '"'
  else
    Result := S;
end;

function TCSVFormatter.GetFormatName: String;
begin
  Result := 'CSV';
end;

function TCSVFormatter.GetFileExtension: String;
begin
  Result := '.csv';
end;

procedure TCSVFormatter.BeginDocument(AStream: TStream);
begin
  FStream := AStream;
  FFirstRecord := True;
end;

procedure TCSVFormatter.BeginRecord;
begin
  if not FFirstRecord then
    WriteStr(LineEnding);
  FFirstField := True;
  FFirstRecord := False;
end;

procedure TCSVFormatter.WriteField(const AName, AValue: String);
begin
  if not FFirstField then
    WriteStr(',');
  WriteStr(EscapeCSV(AValue));
  FFirstField := False;
end;

procedure TCSVFormatter.EndRecord;
begin
  { Line ending written at next BeginRecord or EndDocument }
end;

procedure TCSVFormatter.EndDocument;
begin
  WriteStr(LineEnding);
end;

end.

The XML Formatter

unit Export.XML;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, Export.Interfaces;

type
  TXMLFormatter = class(TInterfacedObject, IExportFormatter)
  private
    FStream: TStream;
    FIndent: Integer;
    procedure WriteStr(const S: String);
    procedure WriteLn(const S: String);
    function EscapeXML(const S: String): String;
  public
    function GetFormatName: String;
    function GetFileExtension: String;
    procedure BeginDocument(AStream: TStream);
    procedure BeginRecord;
    procedure WriteField(const AName, AValue: String);
    procedure EndRecord;
    procedure EndDocument;
  end;

implementation

procedure TXMLFormatter.WriteStr(const S: String);
begin
  if Length(S) > 0 then
    FStream.WriteBuffer(S[1], Length(S));
end;

procedure TXMLFormatter.WriteLn(const S: String);
begin
  WriteStr(StringOfChar(' ', FIndent * 2) + S + LineEnding);
end;

function TXMLFormatter.EscapeXML(const S: String): String;
begin
  Result := S;
  Result := StringReplace(Result, '&', '&', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
  Result := StringReplace(Result, '>', '&gt;', [rfReplaceAll]);
  Result := StringReplace(Result, '"', '&quot;', [rfReplaceAll]);
end;

function TXMLFormatter.GetFormatName: String;
begin
  Result := 'XML';
end;

function TXMLFormatter.GetFileExtension: String;
begin
  Result := '.xml';
end;

procedure TXMLFormatter.BeginDocument(AStream: TStream);
begin
  FStream := AStream;
  FIndent := 0;
  WriteLn('<?xml version="1.0" encoding="UTF-8"?>');
  WriteLn('<expenses>');
  Inc(FIndent);
end;

procedure TXMLFormatter.BeginRecord;
begin
  WriteLn('<expense>');
  Inc(FIndent);
end;

procedure TXMLFormatter.WriteField(const AName, AValue: String);
begin
  WriteLn(Format('<%s>%s</%s>', [AName, EscapeXML(AValue), AName]));
end;

procedure TXMLFormatter.EndRecord;
begin
  Dec(FIndent);
  WriteLn('</expense>');
end;

procedure TXMLFormatter.EndDocument;
begin
  Dec(FIndent);
  WriteLn('</expenses>');
end;

end.

The Text Formatter

unit Export.Text;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, Export.Interfaces;

type
  TTextFormatter = class(TInterfacedObject, IExportFormatter)
  private
    FStream: TStream;
    FRecordNum: Integer;
    procedure WriteStr(const S: String);
    procedure WriteLnStr(const S: String);
  public
    function GetFormatName: String;
    function GetFileExtension: String;
    procedure BeginDocument(AStream: TStream);
    procedure BeginRecord;
    procedure WriteField(const AName, AValue: String);
    procedure EndRecord;
    procedure EndDocument;
  end;

implementation

procedure TTextFormatter.WriteStr(const S: String);
begin
  if Length(S) > 0 then
    FStream.WriteBuffer(S[1], Length(S));
end;

procedure TTextFormatter.WriteLnStr(const S: String);
begin
  WriteStr(S + LineEnding);
end;

function TTextFormatter.GetFormatName: String;
begin
  Result := 'Text Report';
end;

function TTextFormatter.GetFileExtension: String;
begin
  Result := '.txt';
end;

procedure TTextFormatter.BeginDocument(AStream: TStream);
begin
  FStream := AStream;
  FRecordNum := 0;
  WriteLnStr('==================================');
  WriteLnStr('       EXPENSE REPORT');
  WriteLnStr('==================================');
  WriteLnStr('');
end;

procedure TTextFormatter.BeginRecord;
begin
  Inc(FRecordNum);
  WriteLnStr(Format('--- Expense #%d ---', [FRecordNum]));
end;

procedure TTextFormatter.WriteField(const AName, AValue: String);
begin
  WriteLnStr(Format('  %-15s %s', [AName + ':', AValue]));
end;

procedure TTextFormatter.EndRecord;
begin
  WriteLnStr('');
end;

procedure TTextFormatter.EndDocument;
begin
  WriteLnStr('==================================');
  WriteLnStr(Format('  Total records: %d', [FRecordNum]));
  WriteLnStr(Format('  Generated: %s', [DateTimeToStr(Now)]));
  WriteLnStr('==================================');
end;

end.

The Export Engine

The TExportEngine ties everything together. It accepts any collection of IExportable items and any IExportFormatter, producing output without knowing the concrete types of either:

unit Export.Engine;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, Export.Interfaces;

type
  TExportEngine = class
  public
    class procedure Export(const Items: array of IExportable;
      Formatter: IExportFormatter; AStream: TStream);
    class procedure ExportToFile(const Items: array of IExportable;
      Formatter: IExportFormatter; const AFileName: String);
  end;

implementation

class procedure TExportEngine.Export(const Items: array of IExportable;
  Formatter: IExportFormatter; AStream: TStream);
var
  i, j: Integer;
begin
  Formatter.BeginDocument(AStream);
  for i := Low(Items) to High(Items) do
  begin
    Formatter.BeginRecord;
    for j := 0 to Items[i].GetFieldCount - 1 do
      Formatter.WriteField(Items[i].GetFieldName(j), Items[i].GetFieldValue(j));
    Formatter.EndRecord;
  end;
  Formatter.EndDocument;
end;

class procedure TExportEngine.ExportToFile(const Items: array of IExportable;
  Formatter: IExportFormatter; const AFileName: String);
var
  FS: TFileStream;
begin
  FS := TFileStream.Create(AFileName, fmCreate);
  try
    Export(Items, Formatter, FS);
  finally
    FS.Free;
  end;
end;

end.

Demonstration

program ExportDemo;

{$mode objfpc}{$H+}

uses
  Classes, SysUtils, Export.Interfaces, Export.Engine,
  Export.CSV, Export.XML, Export.Text, Finance.Expense;

var
  Expenses: array[0..2] of IExportable;
begin
  Expenses[0] := TExpense.Create(EncodeDate(2024, 1, 15), 'Groceries', 45.99, 'Weekly groceries');
  Expenses[1] := TExpense.Create(EncodeDate(2024, 1, 16), 'Transport', 12.50, 'Bus pass');
  Expenses[2] := TExpense.Create(EncodeDate(2024, 1, 17), 'Software', 29.99, 'IDE license');

  TExportEngine.ExportToFile(Expenses, TCSVFormatter.Create, 'expenses.csv');
  WriteLn('Exported to CSV.');

  TExportEngine.ExportToFile(Expenses, TXMLFormatter.Create, 'expenses.xml');
  WriteLn('Exported to XML.');

  TExportEngine.ExportToFile(Expenses, TTextFormatter.Create, 'expenses.txt');
  WriteLn('Exported to text.');

  WriteLn('All exports complete!');
end.

SOLID Analysis

Principle How This Design Satisfies It
Single Responsibility Each formatter handles exactly one format. The engine handles orchestration. Expense handles data.
Open/Closed Adding JSON export means writing one new TJSONFormatter class. Nothing else changes.
Liskov Substitution Any IExportFormatter can be passed to the engine. All formatters produce valid output for their format.
Interface Segregation IExportable and IExportFormatter are separate, focused interfaces. Data objects do not need to know about formatting.
Dependency Inversion The engine depends on IExportable and IExportFormatter — abstractions. It never references any concrete class.

Key Takeaways

  1. Two interface types cooperate: One for data exposure (IExportable), one for format handling (IExportFormatter). This separation lets you combine any data type with any format.
  2. The engine is format-agnostic: TExportEngine.Export works with any formatter, including ones that do not exist yet.
  3. Adding formats is trivial: One class, one unit, zero changes to existing code. This is the Open/Closed Principle in its purest form.
  4. Testing is straightforward: Create a TMockFormatter that records method calls instead of writing to a stream, and you can verify the engine's behavior without touching the filesystem.