Case Study 2: Application Architecture Patterns in Lazarus

Overview

As applications grow beyond a single form, their structure matters increasingly. This case study examines three architecture patterns — Model-View-Controller (MVC), Model-View-Presenter (MVP), and the Document-View pattern — and shows how to apply them in Lazarus. We use PennyWise as the running example.


The Problem: Tangled Code

In a naive Lazarus application, the form unit does everything: it handles events, validates input, stores data, performs calculations, reads/writes files, and updates the display. This works for small applications, but as the application grows, the form unit becomes a monolith — hundreds or thousands of lines where business logic is tangled with UI code.

The symptoms are familiar:

  • Cannot test logic without the UI. If the total calculation is inside btnAddClick, you cannot test it from a console unit test.
  • Cannot change the UI without risk. If the grid population logic references specific column indices, changing the grid layout risks breaking the calculation.
  • Cannot reuse logic. If the CSV export logic is inside a menu handler, you cannot call it from a command-line batch mode.

The cure is separation of concerns, achieved through architecture patterns.


Pattern 1: Model-View-Controller (MVC)

MVC separates an application into three roles:

  • Model: The data and business logic. Knows nothing about the UI.
  • View: The visual presentation. Displays data from the model and captures user input.
  • Controller: The coordinator. Receives user input from the view, calls the model, and updates the view.

PennyWise in MVC

ModelPennyWiseModel.pas:

unit PennyWiseModel;

{$mode objfpc}{$H+}

interface

uses Classes, SysUtils;

type
  TExpense = record
    Date: TDateTime;
    Description: string;
    Category: string;
    Amount: Double;
    Notes: string;
  end;

  TExpenseList = class
  private
    FItems: array of TExpense;
    FCount: Integer;
    FModified: Boolean;
  public
    procedure Add(const AExpense: TExpense);
    procedure Delete(Index: Integer);
    function GetTotal: Double;
    function GetTotalByCategory(const ACategory: string): Double;
    procedure SaveToFile(const AFileName: string);
    procedure LoadFromFile(const AFileName: string);
    procedure Clear;
    property Count: Integer read FCount;
    property Modified: Boolean read FModified;
    function GetItem(Index: Integer): TExpense;
  end;

implementation

{ Implementation omitted for brevity — pure data operations,
  no references to Forms, Controls, or any LCL unit }

end.

View — the form (.pas + .lfm): displays the grid, input controls, menus. It has no business logic.

ControllerPennyWiseController.pas:

unit PennyWiseController;

{$mode objfpc}{$H+}

interface

uses PennyWiseModel;

type
  { Interface that the view must implement }
  IPennyWiseView = interface
    procedure DisplayExpenses(const AExpenses: TExpenseList);
    procedure DisplayTotal(ATotal: Double);
    procedure ShowError(const AMessage: string);
    procedure ShowStatus(const AMessage: string);
    function GetInputDate: TDateTime;
    function GetInputDescription: string;
    function GetInputCategory: string;
    function GetInputAmount: Double;
    procedure ClearInputFields;
  end;

  TPennyWiseController = class
  private
    FModel: TExpenseList;
    FView: IPennyWiseView;
    FCurrentFile: string;
  public
    constructor Create(AView: IPennyWiseView);
    destructor Destroy; override;
    procedure AddExpense;
    procedure DeleteExpense(Index: Integer);
    procedure SaveFile(const AFileName: string);
    procedure LoadFile(const AFileName: string);
    procedure RefreshView;
  end;

implementation

constructor TPennyWiseController.Create(AView: IPennyWiseView);
begin
  FModel := TExpenseList.Create;
  FView := AView;
end;

destructor TPennyWiseController.Destroy;
begin
  FModel.Free;
  inherited;
end;

procedure TPennyWiseController.AddExpense;
var
  Expense: TExpense;
begin
  Expense.Date := FView.GetInputDate;
  Expense.Description := FView.GetInputDescription;
  Expense.Category := FView.GetInputCategory;
  Expense.Amount := FView.GetInputAmount;

  if Expense.Description = '' then
  begin
    FView.ShowError('Description is required.');
    Exit;
  end;
  if Expense.Amount <= 0 then
  begin
    FView.ShowError('Amount must be positive.');
    Exit;
  end;

  FModel.Add(Expense);
  FView.ClearInputFields;
  RefreshView;
  FView.ShowStatus('Expense added');
end;

procedure TPennyWiseController.RefreshView;
begin
  FView.DisplayExpenses(FModel);
  FView.DisplayTotal(FModel.GetTotal);
end;

{ ... remaining methods follow the same pattern ... }

end.

The form implements IPennyWiseView and delegates all logic to the controller. The controller calls the model and updates the view through the interface.


Pattern 2: Model-View-Presenter (MVP)

MVP is a variation of MVC where the Presenter (instead of Controller) has a direct reference to the view and explicitly updates it. The key difference: in MVC, the view may observe the model directly; in MVP, all communication goes through the presenter.

MVP is often more natural in RAD environments like Lazarus because the form is already the view, and it is natural for the presenter to explicitly call View.DisplayTotal(...).

The PennyWise Controller above is actually closer to MVP than MVC, since the controller directly calls view methods rather than relying on observer notifications.


Pattern 3: Document-View

The Document-View pattern (used by MFC and many desktop frameworks) separates data (the Document) from display (the View). It is simpler than MVC:

  • Document: Holds data and provides operations (load, save, modify).
  • View: Displays the document and captures input.
type
  TPennyWiseDocument = class
  private
    FExpenses: TExpenseList;
    FFileName: string;
    FModified: Boolean;
  public
    procedure NewDocument;
    procedure Open(const AFileName: string);
    procedure Save;
    procedure SaveAs(const AFileName: string);
    procedure AddExpense(const AExpense: TExpense);
    property Expenses: TExpenseList read FExpenses;
    property FileName: string read FFileName;
    property Modified: Boolean read FModified;
  end;

The form holds a reference to the document and calls its methods. This is the simplest pattern and works well for applications with a single primary data structure.


Which Pattern for PennyWise?

For a personal finance manager:

  • MVP is the best fit. It gives clean separation without over-engineering. The presenter contains the workflow logic (add, delete, save, load). The model contains the data operations. The view displays and captures input.
  • Document-View is acceptable for a simpler version. The document handles persistence; the form handles display.
  • Full MVC with observer notifications is overkill for a single-form application but becomes valuable if PennyWise grows to have multiple views (e.g., a chart view and a table view both observing the same expense list).

Unit Organization

Regardless of which pattern you choose, organize your units clearly:

PennyWiseGUI/
├── PennyWiseGUI.lpr          { project file }
├── MainFormUnit.pas           { view: main form }
├── MainFormUnit.lfm           { form layout }
├── ExpenseDetailUnit.pas      { view: expense dialog }
├── ExpenseDetailUnit.lfm
├── PennyWiseModel.pas         { model: TExpense, TExpenseList }
├── PennyWiseController.pas    { controller/presenter }
├── PennyWiseFileIO.pas        { file I/O: CSV, custom format }
├── PennyWiseTypes.pas         { shared type definitions }
└── PennyWiseConst.pas         { constants: categories, default values }

The model units (PennyWiseModel, PennyWiseFileIO, PennyWiseTypes) have no uses references to Forms, Controls, or any LCL unit. They are pure Pascal. You can test them from a console program.


Lessons Learned

  1. Architecture patterns are not dogma — they are guidelines. Choose the simplest pattern that provides the separation you need.
  2. The key rule is: model units must not reference LCL units. If your business logic imports Forms or Controls, the separation has been violated.
  3. Interfaces enable testing and flexibility. The IPennyWiseView interface lets you create a mock view for unit testing the controller without any GUI.
  4. Start simple, refactor when needed. A small application can begin as Document-View and evolve to MVP as it grows. Do not architect for a million users on day one.
  5. Lazarus supports all these patterns. The PME model and OOP support in Free Pascal make any of these architectures straightforward to implement.