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

  1. The Repository pattern centralizes data access. Instead of scattered arrays and manual CRUD code, one class manages all data operations.
  2. Generics make the pattern reusable. One TRepository<T> serves every entity type in the application.
  3. Type constraints ensure safety. T: TEntity guarantees that all items have an ID, enabling generic lookup and removal.
  4. 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.
  5. The pattern scales. From a simple in-memory list to a database-backed repository, the interface remains the same. Only the implementation changes.