Case Study 2: A Generic Repository Pattern
The Scenario
As PennyWise grows, we need more than just lists. We need a data access layer — a component that manages storing, retrieving, updating, and deleting records. The operations are the same regardless of what type of record we are managing: expenses, categories, budgets, or users all need Create, Read, Update, Delete (CRUD) operations.
Rather than writing separate repository classes for each entity, we build a generic repository — a single class that provides CRUD operations for any entity type. This pattern is widely used in enterprise software (it is one of the core patterns in Martin Fowler's Patterns of Enterprise Application Architecture) and maps naturally to generics.
The Design
The Entity Base Class
Every entity stored in our repository must have an ID. We enforce this with a base class:
unit Repository.Entity;
{$mode objfpc}{$H+}
interface
type
TEntity = class
private
FID: Integer;
public
constructor Create; virtual;
property ID: Integer read FID write FID;
end;
implementation
constructor TEntity.Create;
begin
inherited Create;
FID := -1; { -1 means not yet assigned }
end;
end.
The Generic Repository
unit Repository.Generic;
{$mode objfpc}{$H+}
interface
uses
SysUtils, fgl, Repository.Entity;
type
generic TRepository<T: TEntity> = class
private
FItems: specialize TFPGObjectList<T>;
FNextID: Integer;
public
type
TPredicateFunc = function(const AItem: T): Boolean;
constructor Create;
destructor Destroy; override;
{ CRUD operations }
function Add(AItem: T): Integer; { Returns assigned ID }
function GetByID(AID: Integer): T; { Returns nil if not found }
function GetByIndex(AIndex: Integer): T;
function GetAll: specialize TFPGObjectList<T>; { Caller does NOT own result }
procedure Update(AItem: T);
function Remove(AID: Integer): Boolean;
{ Query operations }
function FindFirst(APredicate: TPredicateFunc): T;
function FindAll(APredicate: TPredicateFunc): specialize TFPGObjectList<T>;
function Exists(AID: Integer): Boolean;
{ Properties }
property Count: Integer read GetCount;
private
function GetCount: Integer;
function IndexOfID(AID: Integer): Integer;
end;
ERepositoryError = class(Exception);
EEntityNotFoundError = class(ERepositoryError);
EDuplicateEntityError = class(ERepositoryError);
implementation
constructor TRepository.Create;
begin
inherited Create;
FItems := specialize TFPGObjectList<T>.Create;
FItems.FreeObjects := True;
FNextID := 1;
end;
destructor TRepository.Destroy;
begin
FItems.Free;
inherited;
end;
function TRepository.GetCount: Integer;
begin
Result := FItems.Count;
end;
function TRepository.IndexOfID(AID: Integer): Integer;
var
i: Integer;
begin
Result := -1;
for i := 0 to FItems.Count - 1 do
if FItems[i].ID = AID then
begin
Result := i;
Exit;
end;
end;
function TRepository.Add(AItem: T): Integer;
begin
if AItem.ID >= 0 then
if IndexOfID(AItem.ID) >= 0 then
raise EDuplicateEntityError.CreateFmt(
'Entity with ID %d already exists', [AItem.ID]);
AItem.ID := FNextID;
Inc(FNextID);
FItems.Add(AItem);
Result := AItem.ID;
end;
function TRepository.GetByID(AID: Integer): T;
var
Idx: Integer;
begin
Idx := IndexOfID(AID);
if Idx < 0 then
Result := nil
else
Result := FItems[Idx];
end;
function TRepository.GetByIndex(AIndex: Integer): T;
begin
if (AIndex < 0) or (AIndex >= FItems.Count) then
raise ERepositoryError.CreateFmt(
'Index %d out of range (0..%d)', [AIndex, FItems.Count - 1]);
Result := FItems[AIndex];
end;
function TRepository.GetAll: specialize TFPGObjectList<T>;
begin
Result := FItems; { Returns reference — caller does NOT free }
end;
procedure TRepository.Update(AItem: T);
var
Idx: Integer;
begin
Idx := IndexOfID(AItem.ID);
if Idx < 0 then
raise EEntityNotFoundError.CreateFmt(
'Cannot update: entity with ID %d not found', [AItem.ID]);
{ Item is already in the list (same reference), so no action needed
unless we implement value-type entities later }
end;
function TRepository.Remove(AID: Integer): Boolean;
var
Idx: Integer;
begin
Idx := IndexOfID(AID);
Result := Idx >= 0;
if Result then
FItems.Delete(Idx); { FreeObjects will Free the item }
end;
function TRepository.FindFirst(APredicate: TPredicateFunc): T;
var
i: Integer;
begin
Result := nil;
for i := 0 to FItems.Count - 1 do
if APredicate(FItems[i]) then
begin
Result := FItems[i];
Exit;
end;
end;
function TRepository.FindAll(APredicate: TPredicateFunc): specialize TFPGObjectList<T>;
var
i: Integer;
begin
Result := specialize TFPGObjectList<T>.Create;
Result.FreeObjects := False; { Caller gets references, not ownership }
for i := 0 to FItems.Count - 1 do
if APredicate(FItems[i]) then
Result.Add(FItems[i]);
end;
function TRepository.Exists(AID: Integer): Boolean;
begin
Result := IndexOfID(AID) >= 0;
end;
end.
Using the Repository: PennyWise Entities
The Expense Entity
unit PennyWise.Entities;
{$mode objfpc}{$H+}
interface
uses
SysUtils, Repository.Entity;
type
TExpense = class(TEntity)
private
FDate: TDateTime;
FCategory: String;
FAmount: Currency;
FDescription: String;
public
constructor Create; override;
constructor Create(ADate: TDateTime; const ACategory: String;
AAmount: Currency; const ADescription: String);
function ToString: String; override;
property Date: TDateTime read FDate write FDate;
property Category: String read FCategory write FCategory;
property Amount: Currency read FAmount write FAmount;
property Description: String read FDescription write FDescription;
end;
TCategory = class(TEntity)
private
FName: String;
FBudgetLimit: Currency;
public
constructor Create; override;
constructor Create(const AName: String; ABudgetLimit: Currency);
property Name: String read FName write FName;
property BudgetLimit: Currency read FBudgetLimit write FBudgetLimit;
end;
implementation
{ TExpense }
constructor TExpense.Create;
begin
inherited Create;
FDate := Now;
FAmount := 0;
end;
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.ToString: String;
begin
Result := Format('[#%d] %s | %s | $%.2f | %s',
[ID, FormatDateTime('yyyy-mm-dd', FDate), FCategory, FAmount, FDescription]);
end;
{ TCategory }
constructor TCategory.Create;
begin
inherited Create;
FBudgetLimit := 0;
end;
constructor TCategory.Create(const AName: String; ABudgetLimit: Currency);
begin
inherited Create;
FName := AName;
FBudgetLimit := ABudgetLimit;
end;
end.
Demonstration
program RepositoryDemo;
{$mode objfpc}{$H+}
uses
SysUtils, fgl, Repository.Entity, Repository.Generic,
PennyWise.Entities;
type
TExpenseRepo = specialize TRepository<TExpense>;
TCategoryRepo = specialize TRepository<TCategory>;
function IsGrocery(const E: TExpense): Boolean;
begin
Result := E.Category = 'Groceries';
end;
function IsOverFifty(const E: TExpense): Boolean;
begin
Result := E.Amount > 50.00;
end;
var
Expenses: TExpenseRepo;
Categories: TCategoryRepo;
Exp: TExpense;
Results: specialize TFPGObjectList<TExpense>;
i, NewID: Integer;
begin
WriteLn('=== Generic Repository Pattern Demo ===');
WriteLn;
{ Create repositories }
Expenses := TExpenseRepo.Create;
Categories := TCategoryRepo.Create;
try
{ Add categories }
Categories.Add(TCategory.Create('Groceries', 200.00));
Categories.Add(TCategory.Create('Transport', 100.00));
Categories.Add(TCategory.Create('Software', 50.00));
WriteLn('Categories: ', Categories.Count);
{ Add expenses }
Expenses.Add(TExpense.Create(EncodeDate(2024, 1, 15), 'Groceries', 45.99, 'Weekly shop'));
Expenses.Add(TExpense.Create(EncodeDate(2024, 1, 16), 'Transport', 12.50, 'Bus pass'));
Expenses.Add(TExpense.Create(EncodeDate(2024, 1, 17), 'Software', 89.99, 'IDE license'));
Expenses.Add(TExpense.Create(EncodeDate(2024, 1, 18), 'Groceries', 62.30, 'Dinner party'));
NewID := Expenses.Add(TExpense.Create(EncodeDate(2024, 1, 19), 'Groceries', 28.15, 'Snacks'));
WriteLn('Expenses: ', Expenses.Count);
WriteLn;
{ List all }
WriteLn('--- All Expenses ---');
for i := 0 to Expenses.Count - 1 do
WriteLn(' ', Expenses.GetByIndex(i).ToString);
WriteLn;
{ Find by ID }
WriteLn('--- Find by ID ---');
Exp := Expenses.GetByID(3);
if Exp <> nil then
WriteLn(' Found ID 3: ', Exp.ToString)
else
WriteLn(' ID 3 not found');
WriteLn;
{ Find all groceries }
WriteLn('--- All Grocery Expenses ---');
Results := Expenses.FindAll(@IsGrocery);
try
for i := 0 to Results.Count - 1 do
WriteLn(' ', Results[i].ToString);
WriteLn(' Total grocery expenses: ', Results.Count);
finally
Results.Free;
end;
WriteLn;
{ Find expensive items }
WriteLn('--- Expenses Over $50 ---');
Results := Expenses.FindAll(@IsOverFifty);
try
for i := 0 to Results.Count - 1 do
WriteLn(' ', Results[i].ToString);
finally
Results.Free;
end;
WriteLn;
{ Remove an expense }
WriteLn('--- Remove Expense #', NewID, ' ---');
if Expenses.Remove(NewID) then
WriteLn(' Removed successfully. Remaining: ', Expenses.Count)
else
WriteLn(' Not found!');
WriteLn;
{ Try to get removed item }
Exp := Expenses.GetByID(NewID);
WriteLn(' GetByID(', NewID, ') after removal: ',
IfThen(Exp = nil, 'nil (correct)', 'still exists (bug!)'));
finally
Categories.Free;
Expenses.Free;
end;
WriteLn;
WriteLn('=== Demo complete ===');
end.
Design Analysis
Type Constraint: T: TEntity
The constraint T: TEntity ensures that every item in the repository has an ID property. Without this constraint, the GetByID, IndexOfID, and Remove methods could not access ID — they would not know that T has such a property. The constraint communicates to both the compiler and the reader: "This repository works with any entity that has an ID."
Query Methods Return References, Not Copies
FindAll returns a list of references to existing objects, not copies. The returned list has FreeObjects := False to prevent double-freeing. The repository owns the objects; callers just borrow them. This is an important ownership pattern in Object Pascal.
One Repository Class, Multiple Entity Types
The same TRepository<T> serves expenses, categories, budgets, and any future entity type — without code duplication. Adding a new entity (say, TUser) requires zero changes to the repository. Just specialize it:
type
TUserRepo = specialize TRepository<TUser>;
Future Extensions
In a production application, you would extend this pattern to:
- Persistence: Add SaveToFile/LoadFromFile methods (Chapter 34: JSON/XML serialization)
- Database backend: Replace the in-memory list with SQL queries (Chapter 31: SQLite)
- Interface abstraction: Define IRepository<T> so the business logic depends on an interface, not a concrete class (combining Chapter 18 and Chapter 20)
Key Takeaways
- The Repository pattern centralizes data access. Instead of scattered arrays and manual CRUD code, one class manages all data operations.
- Generics make the pattern reusable. One
TRepository<T>serves every entity type in the application. - Type constraints ensure safety.
T: TEntityguarantees that all items have an ID, enabling generic lookup and removal. - Ownership semantics matter. The repository owns its items (
FreeObjects := True). Query results are borrowed references (FreeObjects := False). Getting this wrong causes either memory leaks or use-after-free crashes. - The pattern scales. From a simple in-memory list to a database-backed repository, the interface remains the same. Only the implementation changes.