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, '<', '<', [rfReplaceAll]);
Result := StringReplace(Result, '>', '>', [rfReplaceAll]);
Result := StringReplace(Result, '"', '"', [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
- 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. - The engine is format-agnostic:
TExportEngine.Exportworks with any formatter, including ones that do not exist yet. - Adding formats is trivial: One class, one unit, zero changes to existing code. This is the Open/Closed Principle in its purest form.
- Testing is straightforward: Create a
TMockFormatterthat records method calls instead of writing to a stream, and you can verify the engine's behavior without touching the filesystem.