34 min read

> "The art of programming is the art of organizing complexity."

Learning Objectives

  • Explain why modular design is essential for maintainable software
  • Create Pascal units with properly separated interface and implementation sections
  • Use the uses clause to manage unit dependencies and resolve circular references
  • Implement unit initialization and finalization sections for resource management
  • Build static and dynamic libraries from Pascal units
  • Apply namespace management techniques to resolve unit conflicts
  • Evaluate code against cohesion, coupling, and dependency management principles
  • Refactor PennyWise into a multi-unit architecture

Chapter 33: Units, Packages, and Modular Design: Organizing Large Programs

"The art of programming is the art of organizing complexity." — Edsger W. Dijkstra


33.1 Why Modular Design?

There is a moment in the life of every growing program when something breaks — not in the code, but in the programmer's mind. The program compiles. It runs. It even produces correct output. But when you open the source file and scroll, and scroll, and keep scrolling past two thousand, three thousand, five thousand lines of code, a quiet dread settles in. You need to change how expense categories work, but you cannot remember where that logic lives. You find it in three places, slightly different in each. You fix one, break another, and spend an hour discovering that the third was the one actually being called. The program still works, technically. But it has become hostile — hostile to you, hostile to anyone who might read it after you, hostile to change.

This is the scaling problem, and it is not a minor inconvenience. It is the central challenge of software engineering. Programs that work are easy. Programs that work and can be understood, maintained, and extended by human beings over time — that is the hard part.

The solution has been known since the 1970s, and Niklaus Wirth was one of its strongest advocates: modular design. Break the program into separate pieces, each with a clear purpose, a clean public interface, and hidden internal details. Let each piece be understood on its own. Let the compiler enforce the boundaries. Let the programmer think about one thing at a time.

Pascal's module system — the unit — is one of the cleanest implementations of this idea in any programming language. If you have used Python, you know about modules and import. If you have used Java, you know about classes in packages. If you have used C, you know about header files and separate compilation. Pascal's units are simpler and more explicit than any of these, and they enforce separation in a way that is harder to circumvent. This is not an accident. It is design.

💡 Intuition: The Filing Cabinet Analogy Imagine your PennyWise source code as a stack of paper on your desk. Right now, every function, every type, every variable is on that one enormous pile. Finding anything requires shuffling through everything. Now imagine instead that you have a filing cabinet with labeled drawers: "Financial Models" (types and records), "Data Storage" (file I/O and database), "User Interface" (menus and display), "Reports" (calculations and output). Each drawer contains only what belongs there. Each drawer has a label on the front (the interface) telling you what is inside, without requiring you to open it. That filing cabinet is what a unit system gives you.

The Cost of Monolithic Code

Let us be concrete about what goes wrong when a program grows without modular structure.

Problem 1: Cognitive overload. The human brain can hold roughly seven items in working memory at once. A 5,000-line program has hundreds of functions, types, and variables. You cannot hold them all in your head. Without modules, every symbol is in scope everywhere, and changing any one of them might affect any other.

Problem 2: Name collisions. In a single-file program, every identifier must be unique. As the program grows, you find yourself writing names like ExpenseRecordValidationHelperForCSV because Validate was already taken. Modules give you namespaces — the same name can exist in different modules without conflict.

Problem 3: Compilation time. If everything is in one file, changing a single line means recompiling everything. With units, only the changed unit and its dependents need recompilation. For large projects, this is the difference between a one-second build and a five-minute one.

Problem 4: Team development. Two programmers cannot easily work on the same file simultaneously. With units, Alice works on the reporting module while Bob works on the database module, and their changes do not conflict.

Problem 5: Testing. Testing a monolithic program means testing everything at once. Testing a unit means testing its public interface in isolation, which is simpler, faster, and more reliable.

Problem 6: Reuse. A well-designed unit can be used in multiple programs. A function buried in the middle of a 5,000-line program cannot.

📊 Industry Perspective Every major software methodology — from structured programming to object-oriented design to microservices — is fundamentally about the same thing: managing complexity through decomposition. The unit is Pascal's answer to this universal problem, and it is an exceptionally good one. When you move to other languages, you will find their module systems more complex but serving the same purpose. The principles you learn here — cohesion, coupling, interface design, dependency management — are universal.


33.2 Pascal Units: Interface and Implementation

A Pascal unit is a separate source file (with a .pas extension) that contains types, constants, variables, procedures, and functions organized into two distinct sections: the interface (what the outside world can see) and the implementation (how things actually work). This separation is the heart of modular design in Pascal.

Anatomy of a Unit

Here is the simplest possible unit:

unit Greeting;

{$mode objfpc}{$H+}

interface

procedure SayHello(const Name: string);

implementation

procedure SayHello(const Name: string);
begin
  WriteLn('Hello, ', Name, '! Welcome to modular Pascal.');
end;

end.

Let us dissect every line.

unit Greeting; — This declares the unit name. The name must match the filename: this file must be saved as Greeting.pas (case-insensitive on Windows, case-sensitive on Linux). This is not a suggestion; it is a compiler requirement.

{$mode objfpc}{$H+} — Compiler directives. {$mode objfpc}` enables Object Pascal mode (the dialect we have been using since Chapter 16). `{$H+} enables long strings (AnsiString) instead of short strings. These appear in virtually every Free Pascal unit.

interface — Everything between interface and implementation is the public face of the unit. Other units and programs that use this unit can see everything declared here. This section contains declarations only — no code bodies.

implementation — Everything between implementation and end. is private. Other units cannot access anything declared here. This section contains the actual code for the procedures and functions declared in the interface, plus any additional types, variables, and helper routines that are not part of the public API.

end. — The unit ends with end. (note the period, not a semicolon). Just like a program.

The Interface Section

The interface section is a contract. It tells the world: "Here is what I offer, and here is the exact signature of each offering." It can contain:

  • Constants: const MaxExpenses = 1000;
  • Types: type TExpense = record ... end;
  • Variables: var GlobalConfig: TConfig; (use sparingly — global variables are usually poor design)
  • Procedure and function headers: procedure AddExpense(const E: TExpense); (declaration only, no body)

The interface section cannot contain code bodies. You declare procedure AddExpense(const E: TExpense); in the interface, and you provide the body in the implementation. This is not redundancy — it is the separation of "what" from "how."

⚠️ Critical Rule: Interface = Declaration Only You cannot write begin..end blocks in the interface section. The interface declares what is available; the implementation provides the code. This is enforced by the compiler. Attempting to put a procedure body in the interface will produce a compile error.

The Implementation Section

The implementation section is where the work happens. It must provide the body for every procedure and function declared in the interface. It can also contain:

  • Private types, constants, and variables — visible only within this unit
  • Private helper procedures and functions — called by the public routines but not accessible from outside
  • A separate uses clause — for dependencies needed only by the implementation (more on this in a moment)
unit MathHelpers;

{$mode objfpc}{$H+}

interface

function Factorial(N: Integer): Int64;
function IsPrime(N: Integer): Boolean;

implementation

{ Private helper — not visible outside this unit }
function SmallestFactor(N: Integer): Integer;
var
  I: Integer;
begin
  for I := 2 to Trunc(Sqrt(N)) do
    if N mod I = 0 then
      Exit(I);
  Result := N;
end;

function Factorial(N: Integer): Int64;
var
  I: Integer;
begin
  Result := 1;
  for I := 2 to N do
    Result := Result * I;
end;

function IsPrime(N: Integer): Boolean;
begin
  if N < 2 then
    Exit(False);
  Result := SmallestFactor(N) = N;
end;

end.

In this example, SmallestFactor is a private helper. Programs that use MathHelpers can call Factorial and IsPrime, but they cannot call SmallestFactor. It is hidden behind the wall of the implementation section. This is information hiding — one of the most important principles in software design.

Using a Unit

To use a unit in a program or another unit, add it to the uses clause:

program TestMath;

{$mode objfpc}{$H+}

uses
  SysUtils, MathHelpers;

begin
  WriteLn('10! = ', Factorial(10));
  WriteLn('Is 97 prime? ', IsPrime(97));
end.

The uses clause is a comma-separated list of unit names. When the compiler encounters uses MathHelpers, it reads MathHelpers.pas (or its pre-compiled .ppu file), processes the interface section, and makes all public symbols available in the using program.

Two Uses Clauses: Interface vs. Implementation

A unit can have two uses clauses — one in the interface section and one in the implementation section:

unit Reports;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils;  { Needed by interface declarations }

type
  TReportItem = class
    Name: string;
    Value: Currency;
  end;

procedure GenerateReport(Items: TList);

implementation

uses
  DateUtils, StrUtils;  { Needed only by implementation code }

procedure GenerateReport(Items: TList);
var
  I: Integer;
  Item: TReportItem;
begin
  WriteLn('Report generated on ', DateToStr(Now));
  for I := 0 to Items.Count - 1 do
  begin
    Item := TReportItem(Items[I]);
    WriteLn(PadRight(Item.Name, 30), Format('%.2f', [Item.Value]));
  end;
end;

end.

The rule: put a unit in the interface uses only if the interface declarations need it. If a unit is needed only by the implementation code, put it in the implementation uses. This matters because interface dependencies are transitive — if unit A's interface uses unit B, then any unit that uses A implicitly depends on B. Minimizing interface dependencies keeps the dependency graph clean.

Circular References

What happens when unit A needs unit B and unit B needs unit A? This is a circular reference, and it is a common challenge in modular design.

Pascal has a clear rule: you cannot have circular references in interface uses clauses. If UnitA's interface uses UnitB, and UnitB's interface uses UnitA, the compiler will refuse to compile either one. This is not a bug — it is the compiler protecting you from an impossible situation (which unit's interface should be processed first?).

The solution: move one of the dependencies to the implementation uses:

unit UnitA;
interface
uses UnitB;  { UnitA's interface needs UnitB }
{ ... }
implementation
{ ... }
end.

unit UnitB;
interface
{ UnitB's interface does NOT use UnitA }
{ ... }
implementation
uses UnitA;  { UnitB's implementation can use UnitA }
{ ... }
end.

This works because the compiler processes interface sections first (in dependency order), then implementation sections. By the time UnitB's implementation needs UnitA, UnitA's interface has already been processed.

💡 Design Insight: Circular Dependencies Are a Smell If you find yourself needing circular references frequently, it usually means your units are not well-decomposed. The solution is often to extract the shared types into a third unit that both can depend on. For example, if UnitA and UnitB both need a TExpense type, create SharedTypes and put TExpense there. Both units then depend on SharedTypes, and the circularity disappears.

Resolving Circular References: Three Strategies

Because circular dependencies come up so often in practice, let us examine three concrete resolution strategies.

Strategy 1: Extract a shared types unit. This is the most common and cleanest solution. If FinanceUI needs TExpense (from FinanceDB) and FinanceDB needs TDisplayOptions (from FinanceUI), move both types into a FinanceTypes unit that both depend on:

Before (circular):  FinanceUI <--> FinanceDB

After (acyclic):    FinanceUI --> FinanceTypes <-- FinanceDB

Strategy 2: Use an interface (abstract class). Instead of FinanceDB depending on FinanceUI for display callbacks, define an IDisplayCallback interface in a shared unit. FinanceUI implements it; FinanceDB accepts it as a parameter. Neither unit depends on the other — both depend on the interface.

Strategy 3: Refactor to remove the need. Often a circular dependency reveals a design flaw. If the database unit needs to update the UI, perhaps it should not. Instead, the database unit could raise an event, and the main program (which uses both units) connects the event to the UI. This removes the dependency entirely — the database unit does not know or care about the UI.


33.3 Creating Your Own Units

Let us build two practical units from scratch. The first is a StringUtils unit with commonly needed string operations. The second is a MathUtils unit demonstrating a complete mathematical utilities library.

StringUtils: A Complete String Utility Unit

Step 1: Design the Interface

Before writing any code, decide what the unit should offer. Think of this as designing an API:

unit StringUtils;

{$mode objfpc}{$H+}

interface

{ String testing }
function IsEmpty(const S: string): Boolean;
function IsNumeric(const S: string): Boolean;
function ContainsOnly(const S, ValidChars: string): Boolean;

{ String transformation }
function Capitalize(const S: string): string;
function Reverse(const S: string): string;
function RepeatStr(const S: string; Count: Integer): string;

{ String extraction }
function LeftPad(const S: string; Width: Integer; PadChar: Char = ' '): string;
function RightPad(const S: string; Width: Integer; PadChar: Char = ' '): string;
function Between(const S, StartDelim, EndDelim: string): string;

implementation

uses
  SysUtils;

Notice the organization: related functions are grouped together with comments. The interface reads like a table of contents. A programmer browsing this unit can immediately see what it offers without reading a single line of implementation code.

Step 2: Implement Each Function

function IsEmpty(const S: string): Boolean;
begin
  Result := Length(Trim(S)) = 0;
end;

function IsNumeric(const S: string): Boolean;
var
  I: Integer;
  FoundDecimal: Boolean;
  Start: Integer;
begin
  if IsEmpty(S) then
    Exit(False);
  FoundDecimal := False;
  Start := 1;
  if (S[1] = '-') or (S[1] = '+') then
    Start := 2;
  for I := Start to Length(S) do
  begin
    if S[I] = '.' then
    begin
      if FoundDecimal then
        Exit(False);  { Second decimal point }
      FoundDecimal := True;
    end
    else if not (S[I] in ['0'..'9']) then
      Exit(False);
  end;
  Result := True;
end;

function ContainsOnly(const S, ValidChars: string): Boolean;
var
  I: Integer;
begin
  for I := 1 to Length(S) do
    if Pos(S[I], ValidChars) = 0 then
      Exit(False);
  Result := True;
end;

function Capitalize(const S: string): string;
begin
  if Length(S) = 0 then
    Exit('');
  Result := UpperCase(S[1]) + Copy(S, 2, Length(S) - 1);
end;

function Reverse(const S: string): string;
var
  I: Integer;
begin
  SetLength(Result, Length(S));
  for I := 1 to Length(S) do
    Result[Length(S) - I + 1] := S[I];
end;

function RepeatStr(const S: string; Count: Integer): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Count do
    Result := Result + S;
end;

function LeftPad(const S: string; Width: Integer; PadChar: Char = ' '): string;
begin
  Result := S;
  while Length(Result) < Width do
    Result := PadChar + Result;
end;

function RightPad(const S: string; Width: Integer; PadChar: Char = ' '): string;
begin
  Result := S;
  while Length(Result) < Width do
    Result := Result + PadChar;
end;

function Between(const S, StartDelim, EndDelim: string): string;
var
  PosStart, PosEnd: Integer;
begin
  PosStart := Pos(StartDelim, S);
  if PosStart = 0 then
    Exit('');
  PosStart := PosStart + Length(StartDelim);
  PosEnd := Pos(EndDelim, Copy(S, PosStart, Length(S)));
  if PosEnd = 0 then
    Exit('');
  Result := Copy(S, PosStart, PosEnd - 1);
end;

end.

MathUtils: A Complete Math Utility Unit

Here is a second example — a math utilities unit that provides functions beyond the standard library:

unit MathUtils;

{$mode objfpc}{$H+}

interface

{ Basic math }
function Clamp(Value, MinVal, MaxVal: Double): Double;
function Lerp(A, B, T: Double): Double;
function MapRange(Value, InMin, InMax, OutMin, OutMax: Double): Double;

{ Statistical functions }
function Mean(const Values: array of Double): Double;
function Median(const Values: array of Double): Double;
function StdDev(const Values: array of Double): Double;
function Min(const Values: array of Double): Double;
function Max(const Values: array of Double): Double;

{ Financial math }
function CompoundInterest(Principal, Rate: Double; Periods: Integer): Double;
function MonthlyPayment(Principal, AnnualRate: Double; Months: Integer): Double;

implementation

uses
  SysUtils, Math;

function Clamp(Value, MinVal, MaxVal: Double): Double;
begin
  if Value < MinVal then Result := MinVal
  else if Value > MaxVal then Result := MaxVal
  else Result := Value;
end;

function Lerp(A, B, T: Double): Double;
begin
  Result := A + (B - A) * T;
end;

function MapRange(Value, InMin, InMax, OutMin, OutMax: Double): Double;
begin
  Result := OutMin + (Value - InMin) * (OutMax - OutMin) / (InMax - InMin);
end;

function Mean(const Values: array of Double): Double;
var
  I: Integer;
  Sum: Double;
begin
  if Length(Values) = 0 then
    raise Exception.Create('Mean of empty array');
  Sum := 0;
  for I := 0 to High(Values) do
    Sum := Sum + Values[I];
  Result := Sum / Length(Values);
end;

{ Private helper for Median — not in the interface }
procedure SortDoubles(var Arr: array of Double);
var
  I, J: Integer;
  Temp: Double;
begin
  for I := 0 to High(Arr) - 1 do
    for J := I + 1 to High(Arr) do
      if Arr[J] < Arr[I] then
      begin
        Temp := Arr[I];
        Arr[I] := Arr[J];
        Arr[J] := Temp;
      end;
end;

function Median(const Values: array of Double): Double;
var
  Sorted: array of Double;
  N: Integer;
begin
  N := Length(Values);
  if N = 0 then
    raise Exception.Create('Median of empty array');
  SetLength(Sorted, N);
  Move(Values[0], Sorted[0], N * SizeOf(Double));
  SortDoubles(Sorted);
  if N mod 2 = 1 then
    Result := Sorted[N div 2]
  else
    Result := (Sorted[N div 2 - 1] + Sorted[N div 2]) / 2;
end;

function StdDev(const Values: array of Double): Double;
var
  I: Integer;
  Avg, SumSqDiff: Double;
begin
  if Length(Values) < 2 then
    raise Exception.Create('StdDev requires at least 2 values');
  Avg := Mean(Values);
  SumSqDiff := 0;
  for I := 0 to High(Values) do
    SumSqDiff := SumSqDiff + Sqr(Values[I] - Avg);
  Result := Sqrt(SumSqDiff / (Length(Values) - 1));
end;

function Min(const Values: array of Double): Double;
var
  I: Integer;
begin
  if Length(Values) = 0 then
    raise Exception.Create('Min of empty array');
  Result := Values[0];
  for I := 1 to High(Values) do
    if Values[I] < Result then
      Result := Values[I];
end;

function Max(const Values: array of Double): Double;
var
  I: Integer;
begin
  if Length(Values) = 0 then
    raise Exception.Create('Max of empty array');
  Result := Values[0];
  for I := 1 to High(Values) do
    if Values[I] > Result then
      Result := Values[I];
end;

function CompoundInterest(Principal, Rate: Double; Periods: Integer): Double;
begin
  Result := Principal * Power(1 + Rate, Periods);
end;

function MonthlyPayment(Principal, AnnualRate: Double; Months: Integer): Double;
var
  MonthlyRate: Double;
begin
  MonthlyRate := AnnualRate / 12;
  if MonthlyRate = 0 then
    Result := Principal / Months
  else
    Result := Principal * MonthlyRate * Power(1 + MonthlyRate, Months)
              / (Power(1 + MonthlyRate, Months) - 1);
end;

end.

This MathUtils unit demonstrates several important patterns: the SortDoubles helper is private (not in the interface), the functions raise exceptions for invalid inputs rather than returning garbage, and the financial functions use mathematically correct formulas that PennyWise could use for loan calculations.

Design Principles Applied

Notice several design principles at work in both units:

High cohesion: Every function in StringUtils relates to string manipulation. Every function in MathUtils relates to numerical computation. There is nothing about dates, files, or databases in either unit.

Clean interface: The interface reads like a table of contents. A programmer can scan the function names and default parameters and immediately understand what the unit offers, without reading any implementation code.

Private helpers: SortDoubles is in the implementation section. External code has no business calling it directly. By keeping it private, we prevent external dependencies on internal details.

Const parameters: Every string parameter is declared const, preventing unnecessary string copies and signaling that the function does not modify the caller's string.

Default parameters: LeftPad and RightPad have a default PadChar of space. This makes the common case simple (LeftPad('42', 6)) while allowing customization when needed (LeftPad('42', 6, '0')).

These are not just good habits — they are the practices that distinguish professional library code from quick-and-dirty helper functions.

Step 3: Compile and Use

Save the file as StringUtils.pas. Now use it from a program:

program TestStringUtils;

{$mode objfpc}{$H+}

uses
  StringUtils;

begin
  WriteLn(Capitalize('hello world'));       { Hello world }
  WriteLn(LeftPad('42', 6, '0'));           { 000042 }
  WriteLn(IsNumeric('3.14'));               { TRUE }
  WriteLn(Reverse('Pascal'));               { lacsaP }
  WriteLn(Between('<tag>content</tag>', '<tag>', '</tag>'));  { content }
end.

Compile with: fpc TestStringUtils.pas — the compiler will automatically find and compile StringUtils.pas if it is in the same directory or in the search path.

Compilation and File Types

When you compile a unit, Free Pascal produces several files:

File Purpose
StringUtils.pas Source code (your file)
StringUtils.ppu Compiled unit interface (like a C header + metadata)
StringUtils.o Object code (compiled implementation, linked into the final executable)

The .ppu file is what the compiler reads when another unit says uses StringUtils. It contains the interface declarations in compiled form, which is much faster to process than re-reading the source. The .o file is what the linker uses to include the unit's code in the final executable.

⚠️ Important: File Name Must Match Unit Name If your unit is declared as unit StringUtils, the source file must be named StringUtils.pas. On Linux (where filenames are case-sensitive), this means exact case matching. On Windows, the filesystem is case-insensitive, but it is good practice to match the case anyway for cross-platform compatibility.


33.4 Unit Initialization and Finalization

Units can have special code that runs automatically when the program starts (initialization) and when the program ends (finalization). This is useful for setting up resources, registering handlers, opening log files, or any other setup and teardown work.

The Initialization Section

unit Logger;

{$mode objfpc}{$H+}

interface

procedure Log(const Msg: string);
procedure LogError(const Msg: string);

implementation

uses
  SysUtils;

var
  LogFile: TextFile;
  LogFileName: string;

procedure Log(const Msg: string);
begin
  WriteLn(LogFile, FormatDateTime('yyyy-mm-dd hh:nn:ss', Now), ' [INFO] ', Msg);
  Flush(LogFile);
end;

procedure LogError(const Msg: string);
begin
  WriteLn(LogFile, FormatDateTime('yyyy-mm-dd hh:nn:ss', Now), ' [ERROR] ', Msg);
  Flush(LogFile);
end;

initialization
  LogFileName := ChangeFileExt(ParamStr(0), '.log');
  AssignFile(LogFile, LogFileName);
  if FileExists(LogFileName) then
    Append(LogFile)
  else
    Rewrite(LogFile);
  Log('Application started');

finalization
  Log('Application shutting down');
  CloseFile(LogFile);

end.

The initialization section runs automatically when the program starts — specifically, when the unit is loaded. If unit A uses unit B, then B's initialization runs before A's. This ordering is deterministic: the compiler processes the uses graph and initializes units in dependency order.

The finalization section runs when the program exits, in reverse order. If B initialized before A, then A finalizes before B. This ensures that resources are released in the opposite order from which they were acquired — the same principle as a stack.

Initialization Order Guarantees

Consider this scenario:

program MyApp;
uses
  Logger,      { Initialized first }
  Database,    { Initialized second — may use Logger }
  MainForm;    { Initialized third — may use both }
begin
  { All three units are initialized before this point }
end.

The guarantee: by the time Database's initialization runs, Logger is already initialized. By the time MainForm's initialization runs, both Logger and Database are ready. Finalization happens in reverse: MainForm first, then Database, then Logger.

Practical Example: A Logger Unit

The Logger unit above is a practical illustration. When the main program starts, the logger opens its log file automatically. When the program ends, it closes the file automatically. No explicit initialization call is needed — just add Logger to the uses clause and start logging. This is the convenience that initialization sections provide.

However, there is a subtle issue: what if the log file cannot be created (permissions, disk full)? The initialization section runs before the main program's begin..end block, so there is no opportunity for the main program to handle the error. This is why the principle below matters so much.

When to Use Initialization

Initialization is appropriate for:

  • Opening log files or database connections
  • Registering event handlers or factory methods
  • Setting default configuration values
  • Allocating global resources (memory pools, thread pools)

Initialization is not appropriate for:

  • Complex business logic (hard to debug when it runs before main)
  • Anything that might fail in a way the user needs to see (there is no UI yet)
  • Lengthy operations (they delay program startup)

💡 Design Principle: Keep Initialization Minimal The initialization section should do the bare minimum to make the unit functional. Complex setup should be deferred to an explicit Initialize or Open procedure that the main program calls. This gives the main program control over when and how initialization happens, which makes errors easier to handle and testing easier to set up.

Initialization Without Finalization (and Vice Versa)

You can have an initialization section without finalization, or (less commonly) finalization without initialization. You can also use begin..end at the bottom of the unit as a shorthand for initialization-only:

unit QuickInit;

{$mode objfpc}{$H+}

interface

var
  AppStartTime: TDateTime;

implementation

uses
  SysUtils;

begin
  { This is equivalent to an initialization section }
  AppStartTime := Now;
end.

However, we recommend using the explicit initialization keyword for clarity. The begin..end syntax is a holdover from early Pascal and is less readable.


33.5 Packages and Libraries

Units are the building blocks of modular design within a single project. They are the internal architecture of a single application. But what about sharing code between projects? If you build a great StringUtils unit for PennyWise, and then start a new project (GradeBook Pro, Crypts of Pascalia), do you copy the file? What if you fix a bug in one copy and forget to fix it in the others?

Packages and libraries solve this problem. They are mechanisms for distributing and reusing compiled code across multiple projects. A library is compiled once and used by many programs — the opposite of copying source files around.

There are two fundamental kinds of libraries, and the distinction matters both technically and practically.

Static Libraries

A static library is a collection of pre-compiled units bundled into a single file. When you link against a static library, the linker copies the needed object code into your executable. The result is a standalone executable with no external dependencies.

In Free Pascal, you can create a static library by compiling units and using the ar archiver (on Unix) or the Free Pascal package system:

{ file: mathlib.pas — a library definition }
library MathLib;

{$mode objfpc}{$H+}

uses
  MathHelpers, StatUtils, MatrixOps;

exports
  Factorial,
  IsPrime,
  Mean,
  StdDev,
  MatrixMultiply;

begin
  { Library initialization code, if any }
end.

The library keyword (instead of program or unit) tells the compiler to produce a library file instead of an executable.

Pros of static libraries: No external dependencies at runtime. The executable is self-contained.

Cons of static libraries: Larger executables. If the library is updated, every program that uses it must be recompiled and relinked.

Dynamic Libraries (DLLs / Shared Objects)

A dynamic library is loaded at runtime rather than linked at compile time. On Windows, these are .dll files; on Linux, .so files; on macOS, .dylib files.

library MathDynLib;

{$mode objfpc}{$H+}

uses
  SysUtils, MathHelpers;

exports
  Factorial name 'Factorial',
  IsPrime name 'IsPrime';

begin
  { Initialization code }
end.

Compile with: fpc -Mdelphi MathDynLib.pas — this produces MathDynLib.dll (Windows) or libMathDynLib.so (Linux).

To use a dynamic library from Pascal:

program UseDynLib;

{$mode objfpc}{$H+}

uses
  DynLibs, SysUtils;

type
  TFactorialFunc = function(N: Integer): Int64; cdecl;

var
  LibHandle: TLibHandle;
  Factorial: TFactorialFunc;
begin
  LibHandle := LoadLibrary('MathDynLib.dll');
  if LibHandle = NilHandle then
  begin
    WriteLn('Failed to load library');
    Halt(1);
  end;
  try
    Factorial := TFactorialFunc(GetProcAddress(LibHandle, 'Factorial'));
    if Factorial = nil then
    begin
      WriteLn('Failed to find Factorial function');
      Halt(1);
    end;
    WriteLn('10! = ', Factorial(10));
  finally
    UnloadLibrary(LibHandle);
  end;
end.

Pros of dynamic libraries: Smaller executables. Libraries can be updated without recompiling the main program. Multiple programs can share a single copy of the library in memory.

Cons of dynamic libraries: Runtime dependency — if the DLL is missing, the program fails. Version mismatches ("DLL hell"). Slightly slower function calls due to indirection.

Creating a Lazarus Package: Step by Step

Free Pascal has its own package system, separate from dynamic libraries. A Free Pascal package (.lpk file in Lazarus) is a collection of units that can be installed into the IDE and used by multiple projects. Lazarus packages are particularly powerful: they can include components that appear in the component palette, making them available for visual form design.

Creating a Lazarus package involves these concrete steps:

  1. Create the package in Lazarus. Open the IDE, choose Package > New Package. Lazarus creates a .lpk file (an XML file describing the package metadata, dependencies, and included units).

  2. Add unit files. In the package editor, click "Add Files" and select your .pas unit files. For our example, we might add StringUtils.pas, MathUtils.pas, and DateHelpers.pas to create a "CommonUtils" package.

  3. Configure dependencies. If your package depends on other packages (for example, the LCL package for GUI components), add them in the "Required Packages" section.

  4. Set compiler options. The package can have its own compiler options, output directory, and search paths. These are inherited by any project that uses the package.

  5. Compile the package. Click "Compile." Lazarus compiles all units and produces the .ppu and .o files in the package output directory.

  6. Install (optional). If the package contains visual components (descendants of TComponent), click "Install" to add them to the component palette. This requires recompiling the IDE.

Once installed, any Lazarus project can add "CommonUtils" as a dependency, and all its units become available via uses.

📊 Ecosystem Note The Lazarus Online Package Manager (OPM) contains over 200 packages, from database drivers to image processing libraries to cryptographic toolkits. When you install a package through OPM, Lazarus automatically resolves its dependencies, compiles the units, and registers any components. It is similar in concept to pip (Python), npm (JavaScript), or cargo (Rust), though smaller in scale.

Dependency Management Best Practices

When your project depends on multiple packages and units, dependency management becomes a real concern. Here are practices that prevent the most common problems:

Pin versions. When depending on external packages, record which version you tested with. A package update might change behavior or interfaces, breaking your project unexpectedly.

Minimize transitive dependencies. If your unit uses package A, and package A uses packages B, C, and D, your project now transitively depends on B, C, and D. The more dependencies in the chain, the more likely something breaks when any one of them changes. Prefer packages with few dependencies of their own.

Vendor critical dependencies. For small, critical utility units, consider copying the source into your project rather than depending on an external package. This eliminates the risk of the package disappearing or changing. The trade-off is that you lose automatic updates.

Use a consistent build system. For projects with many units and dependencies, a build script (or Lazarus project group) ensures that everything compiles in the right order with the right options, every time. Manual compilation with fpc unit1.pas && fpc unit2.pas && fpc main.pas is fragile — the build script makes it reproducible.


33.6 Namespace Management

As projects grow and more units are used, name collisions become inevitable. What happens when two units both export a procedure called Initialize? Pascal has clear rules for this situation.

The Conflict Problem

uses
  DatabaseManager,  { exports procedure Initialize }
  NetworkManager;   { also exports procedure Initialize }

If you call Initialize without qualification, the compiler uses the one from the last unit in the uses clause — in this case, NetworkManager.Initialize. The DatabaseManager.Initialize is hidden.

Qualified Identifiers

The solution is to use the unit-qualified name:

begin
  DatabaseManager.Initialize;   { Calls DatabaseManager's version }
  NetworkManager.Initialize;    { Calls NetworkManager's version }
end.

The syntax is UnitName.Identifier — the unit name, a period, and the identifier. This is unambiguous and always works.

Real-World Namespace Collisions

Namespace collisions are not hypothetical — they happen regularly in real projects. Here are common examples:

The TList collision. The Classes unit defines TList (a generic list). If you create your own TList in a unit, any program that uses both will see a conflict. The compiler resolves it by the uses clause order, but the ambiguity can confuse developers.

The Sort collision. Multiple units may export a Sort procedure. The fgl generics unit exports sort methods. Your own utility units might have Sort. The math library might have sorting routines. Qualification is essential.

Third-party package conflicts. Two packages might export the same type name — for example, both a graphics library and a database library might define TColor. This is particularly insidious because you do not control the package source code.

The solution in all cases is the same: qualify the identifier with its unit name, or restructure your code to avoid the ambiguity.

💡 Best Practice: When to Qualify Some developers qualify every identifier from external units: SysUtils.IntToStr, Classes.TStringList. This is verbose but maximally clear. Others qualify only when there is an actual conflict. We recommend a middle ground: qualify when there is any potential for confusion, and always qualify in large projects where multiple units might export similar names.

Avoiding Conflicts by Design

The best solution to name conflicts is to avoid them in the first place. Strategies include:

1. Prefix your identifiers. If you are writing a logging library, prefix public identifiers: LogInit, LogWrite, LogClose rather than Init, Write, Close. This is the convention used by many C libraries and works well in Pascal too.

2. Use longer, descriptive names. Instead of Initialize, use InitializeDatabase or InitializeNetworkStack. Descriptive names are self-documenting and rarely collide.

3. Minimize the interface. Export only what external code genuinely needs. Every symbol in the interface is a potential collision. If a helper function is used only within the unit, keep it in the implementation.

4. Nest types inside classes. Object Pascal allows type declarations inside classes, which effectively namespaces them:

type
  TDatabase = class
  public
    type
      TConfig = record
        Host: string;
        Port: Integer;
      end;
  end;

Now the config type is TDatabase.TConfig, which will not collide with any other TConfig.

Unit Aliases

Free Pascal supports unit aliases using the in keyword, though this is rarely needed:

uses
  DB in 'DatabaseManager.pas';  { Use unit from specific file }

This is primarily useful when the unit filename does not match the unit name, or when you need to disambiguate between units with the same name in different directories.


33.7 Unit Testing in Pascal

One of the greatest benefits of modular design is testability. A well-designed unit with a clean interface can be tested in isolation — you do not need the entire application running to verify that IsNumeric('3.14') returns True.

Simple Unit Testing

The simplest approach to unit testing in Pascal is a dedicated test program for each unit:

program TestStringUtils;

{$mode objfpc}{$H+}

uses
  SysUtils, StringUtils;

var
  PassCount, FailCount: Integer;

procedure Check(const TestName: string; Condition: Boolean);
begin
  if Condition then
  begin
    WriteLn('  PASS: ', TestName);
    Inc(PassCount);
  end
  else
  begin
    WriteLn('  FAIL: ', TestName);
    Inc(FailCount);
  end;
end;

begin
  PassCount := 0;
  FailCount := 0;
  WriteLn('=== Testing StringUtils ===');

  { IsEmpty tests }
  Check('IsEmpty with empty string', IsEmpty(''));
  Check('IsEmpty with spaces only', IsEmpty('   '));
  Check('IsEmpty with content', not IsEmpty('hello'));

  { IsNumeric tests }
  Check('IsNumeric integer', IsNumeric('42'));
  Check('IsNumeric decimal', IsNumeric('3.14'));
  Check('IsNumeric negative', IsNumeric('-7'));
  Check('IsNumeric not a number', not IsNumeric('abc'));
  Check('IsNumeric empty', not IsNumeric(''));

  { Capitalize tests }
  Check('Capitalize word', Capitalize('hello') = 'Hello');
  Check('Capitalize empty', Capitalize('') = '');

  { Reverse tests }
  Check('Reverse word', Reverse('Pascal') = 'lacsaP');
  Check('Reverse palindrome', Reverse('madam') = 'madam');

  WriteLn;
  WriteLn(Format('Results: %d passed, %d failed', [PassCount, FailCount]));
  if FailCount > 0 then
    Halt(1);  { Non-zero exit code signals failure }
end.

FPCUnit: Free Pascal's Testing Framework

For larger projects, Free Pascal includes FPCUnit, a unit testing framework modeled after JUnit and DUnit:

unit StringUtilsTests;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, fpcunit, testregistry, StringUtils;

type
  TStringUtilsTest = class(TTestCase)
  published
    procedure TestIsEmpty;
    procedure TestIsNumeric;
    procedure TestCapitalize;
    procedure TestReverse;
    procedure TestBetween;
  end;

implementation

procedure TStringUtilsTest.TestIsEmpty;
begin
  AssertTrue('Empty string is empty', IsEmpty(''));
  AssertTrue('Whitespace is empty', IsEmpty('   '));
  AssertFalse('Content is not empty', IsEmpty('hello'));
end;

procedure TStringUtilsTest.TestIsNumeric;
begin
  AssertTrue(IsNumeric('42'));
  AssertTrue(IsNumeric('3.14'));
  AssertTrue(IsNumeric('-7'));
  AssertFalse(IsNumeric('abc'));
  AssertFalse(IsNumeric(''));
end;

procedure TStringUtilsTest.TestCapitalize;
begin
  AssertEquals('Hello', Capitalize('hello'));
  AssertEquals('', Capitalize(''));
  AssertEquals('A', Capitalize('a'));
end;

procedure TStringUtilsTest.TestReverse;
begin
  AssertEquals('lacsaP', Reverse('Pascal'));
  AssertEquals('madam', Reverse('madam'));
  AssertEquals('', Reverse(''));
end;

procedure TStringUtilsTest.TestBetween;
begin
  AssertEquals('content', Between('<tag>content</tag>', '<tag>', '</tag>'));
  AssertEquals('', Between('no delimiters here', '<', '>'));
end;

initialization
  RegisterTest(TStringUtilsTest);

end.

The framework provides AssertTrue, AssertFalse, AssertEquals, AssertNull, and other assertion methods. Tests are organized into classes, and the RegisterTest call in the initialization section makes the framework aware of the test class.

This testing discipline — write the unit, write the tests, run them after every change — is what separates hobby projects from professional software. The modular architecture makes it possible; the testing framework makes it practical.


33.8 Design Principles for Modular Code

Knowing the syntax of units is necessary but not sufficient. You can create units with perfect syntax and still end up with a disorganized mess — fifty-function "utility" units, circular dependency tangles, units that change every time any feature changes. The syntax tells you how to create modules; design principles tell you where to draw the lines.

These principles were developed over decades by software engineers who learned the hard way what works and what does not. They have names that sound academic — cohesion, coupling, dependency inversion — but they express deeply practical wisdom. Follow them and your codebase stays manageable as it grows. Ignore them and you eventually reach a point where every change breaks something unexpected, every new feature requires touching ten files, and every developer is afraid to modify the code.

The good news: Pascal's unit system enforces several of these principles structurally. The interface/implementation separation forces information hiding. The prohibition on circular interface dependencies forces acyclic design. The explicit uses clause makes dependencies visible. You are already ahead of languages that leave all of this to convention.

Cohesion: Things That Belong Together

Cohesion measures how closely related the elements within a module are. High cohesion means everything in the module is focused on a single, well-defined purpose. Low cohesion means the module is a grab-bag of unrelated functionality.

High cohesion example: A DateUtils unit that contains IsLeapYear, DaysBetween, FormatDate, ParseDate, and AddMonths. All functions relate to date manipulation.

Low cohesion example: A Utils unit that contains FormatDate, SortArray, ConnectToDatabase, ValidateEmail, and PrintReceipt. These have nothing in common except that someone needed them and did not know where else to put them.

The general rule: a unit should do one thing, and do it well. If you cannot describe the unit's purpose in a single sentence without using the word "and," it probably needs to be split.

Coupling: Dependencies Between Modules

Coupling measures how much modules depend on each other. Low coupling means modules interact through narrow, well-defined interfaces. High coupling means modules are entangled — changing one requires changing the other.

Types of coupling, from best to worst:

Type Description Example
No coupling Modules are independent MathUtils and StringUtils
Data coupling Modules pass simple data Calculate(Amount: Currency)
Stamp coupling Modules pass records/objects ProcessExpense(E: TExpense)
Control coupling One module controls another's flow Process(Data; IsVerbose: Boolean)
Common coupling Modules share global variables Both read/write GlobalConfig
Content coupling One module modifies another's internals Accessing private fields directly

Aim for data coupling and stamp coupling. Avoid common and content coupling.

The Dependency Inversion Principle

In a well-designed system, high-level modules should not depend on low-level modules. Both should depend on abstractions.

Consider PennyWise. The reporting module needs to read expenses from storage. A naive design has the report unit directly calling the database unit:

Reports --> Database  { Reports depends on Database }

If we later want to switch from SQLite to PostgreSQL, we must modify Reports. The dependency inversion principle says: define an interface that both modules depend on.

unit FinanceInterfaces;

interface

type
  IExpenseRepository = interface
    function GetAll: TExpenseList;
    function GetByCategory(const Cat: string): TExpenseList;
    function GetByDateRange(StartDate, EndDate: TDateTime): TExpenseList;
  end;

Now Reports depends on IExpenseRepository, and Database implements IExpenseRepository. Switching databases means changing only the Database unit. Reports does not change at all.

The Acyclic Dependencies Principle

The dependency graph between your units should be a directed acyclic graph (DAG) — no cycles. If unit A depends on B, B depends on C, and C depends on A, you have a cycle, and the system is fragile: any change anywhere ripples everywhere.

Techniques for breaking cycles:

  1. Extract shared types into a separate unit that both depend on
  2. Use interfaces — depend on the abstraction, not the implementation
  3. Move the dependency to the implementation uses — this does not eliminate the cycle logically, but it resolves the compilation issue
  4. Merge the units — if two units are so intertwined that they cannot exist without each other, they may actually be one module

Practical Guidelines

Here are concrete rules we follow when designing unit boundaries:

Rule 1: One unit per major abstraction. Each significant type or subsystem gets its own unit. Expense, Budget, Report, Database — each is a unit.

Rule 2: Keep interfaces small. Expose only what clients need. If a unit has 50 public functions, it is probably doing too much.

Rule 3: Hide implementation details aggressively. If a type, constant, or function is used only within the unit, keep it in the implementation section. Every public symbol is a promise that must be maintained.

Rule 4: Group by feature, not by type. Do not put all types in one unit and all functions in another. Put TExpense and all expense-related operations in the same unit.

Rule 5: Name units clearly. PennyWise.Models, PennyWise.Repository, PennyWise.Reports — the name should immediately tell you what the unit contains.

⚖️ Trade-off: Granularity Too few units and you have a monolith. Too many units and you have an explosion of tiny files that is hard to navigate. A good rule of thumb: each unit should be between 100 and 1,000 lines. Fewer than 100 and you are probably splitting too aggressively. More than 1,000 and you should consider whether the unit has multiple responsibilities.


33.9 Project Checkpoint: PennyWise Modular

It is time to apply everything we have learned. PennyWise has grown from a simple expense tracker into a full-featured application with database storage, GUI, and reporting. But it is still largely a monolith — a single program file (or a small number of files) where types, database code, display logic, and report generation are all intertwined. Changing one thing risks breaking another. Finding anything requires searching through thousands of lines. Adding a new feature means understanding everything.

This is the exact situation that modular design was invented to solve. In this checkpoint, we perform the most important refactoring in PennyWise's history: splitting it from a monolith into a clean multi-unit architecture where each unit has one responsibility, clear boundaries, and minimal dependencies on other units.

The process is instructive because it mirrors a refactoring you will perform many times in your career. Every successful project starts small and grows. At some point, the growth produces pain — the code becomes hard to navigate, hard to change, hard to test. The response is always the same: decompose, separate, modularize. The specific technique depends on the language and framework, but the principles are identical whether you are splitting a Pascal program into units, a Java application into packages, a Python project into modules, or a web application into microservices.

The Architecture

We will create four units:

Unit Responsibility Key Types/Functions
FinanceCore Financial domain model TExpense, TBudget, TCategory, TCurrency
FinanceDB Data persistence TExpenseRepository, SaveExpense, LoadExpenses
FinanceUI User interface logic TMainForm helpers, menu handlers, display formatting
FinanceReports Report generation TMonthlySummary, GenerateReport, ExportReport

And one main program file:

File Role
PennyWise.pas Main program — initializes units, starts the application

FinanceCore Unit

This is the heart of PennyWise — the types and operations that define the domain:

unit FinanceCore;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils;

type
  TExpenseCategory = (
    ecFood, ecTransport, ecHousing, ecUtilities,
    ecEntertainment, ecHealth, ecEducation, ecOther
  );

  TExpense = record
    ID: Integer;
    Description: string;
    Amount: Currency;
    Category: TExpenseCategory;
    ExpenseDate: TDateTime;
    IsRecurring: Boolean;
  end;

  TExpenseArray = array of TExpense;

  TBudget = record
    Category: TExpenseCategory;
    MonthlyLimit: Currency;
    CurrentSpent: Currency;
  end;

  TBudgetArray = array of TBudget;

function CategoryToStr(Cat: TExpenseCategory): string;
function StrToCategory(const S: string): TExpenseCategory;
function CreateExpense(const Desc: string; Amt: Currency;
  Cat: TExpenseCategory; ADate: TDateTime): TExpense;
function TotalExpenses(const Expenses: TExpenseArray): Currency;
function FilterByCategory(const Expenses: TExpenseArray;
  Cat: TExpenseCategory): TExpenseArray;
function FilterByDateRange(const Expenses: TExpenseArray;
  StartDate, EndDate: TDateTime): TExpenseArray;

implementation

var
  NextID: Integer = 1;

function CategoryToStr(Cat: TExpenseCategory): string;
const
  Names: array[TExpenseCategory] of string = (
    'Food', 'Transport', 'Housing', 'Utilities',
    'Entertainment', 'Health', 'Education', 'Other'
  );
begin
  Result := Names[Cat];
end;

function StrToCategory(const S: string): TExpenseCategory;
var
  Cat: TExpenseCategory;
begin
  for Cat := Low(TExpenseCategory) to High(TExpenseCategory) do
    if CompareText(CategoryToStr(Cat), S) = 0 then
      Exit(Cat);
  Result := ecOther;
end;

function CreateExpense(const Desc: string; Amt: Currency;
  Cat: TExpenseCategory; ADate: TDateTime): TExpense;
begin
  Result.ID := NextID;
  Inc(NextID);
  Result.Description := Desc;
  Result.Amount := Amt;
  Result.Category := Cat;
  Result.ExpenseDate := ADate;
  Result.IsRecurring := False;
end;

function TotalExpenses(const Expenses: TExpenseArray): Currency;
var
  I: Integer;
begin
  Result := 0;
  for I := 0 to High(Expenses) do
    Result := Result + Expenses[I].Amount;
end;

function FilterByCategory(const Expenses: TExpenseArray;
  Cat: TExpenseCategory): TExpenseArray;
var
  I, Count: Integer;
begin
  SetLength(Result, Length(Expenses));
  Count := 0;
  for I := 0 to High(Expenses) do
    if Expenses[I].Category = Cat then
    begin
      Result[Count] := Expenses[I];
      Inc(Count);
    end;
  SetLength(Result, Count);
end;

function FilterByDateRange(const Expenses: TExpenseArray;
  StartDate, EndDate: TDateTime): TExpenseArray;
var
  I, Count: Integer;
begin
  SetLength(Result, Length(Expenses));
  Count := 0;
  for I := 0 to High(Expenses) do
    if (Expenses[I].ExpenseDate >= StartDate) and
       (Expenses[I].ExpenseDate <= EndDate) then
    begin
      Result[Count] := Expenses[I];
      Inc(Count);
    end;
  SetLength(Result, Count);
end;

end.

Notice what FinanceCore does not depend on: it has no database dependency, no UI dependency, no file format dependency. It is pure domain logic. This is the ideal: the core of your application should depend on nothing except the standard library.

FinanceDB Unit

The database unit depends on FinanceCore (it needs to know what a TExpense is) but FinanceCore does not depend on it:

unit FinanceDB;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, FinanceCore;

type
  TExpenseRepository = class
  private
    FFilename: string;
    FExpenses: TExpenseArray;
    FModified: Boolean;
  public
    constructor Create(const AFilename: string);
    destructor Destroy; override;
    procedure AddExpense(const E: TExpense);
    procedure DeleteExpense(ID: Integer);
    function GetAll: TExpenseArray;
    function GetByCategory(Cat: TExpenseCategory): TExpenseArray;
    procedure Save;
    procedure Load;
    property Modified: Boolean read FModified;
  end;

implementation

constructor TExpenseRepository.Create(const AFilename: string);
begin
  inherited Create;
  FFilename := AFilename;
  SetLength(FExpenses, 0);
  FModified := False;
  if FileExists(FFilename) then
    Load;
end;

destructor TExpenseRepository.Destroy;
begin
  if FModified then
    Save;
  inherited;
end;

procedure TExpenseRepository.AddExpense(const E: TExpense);
begin
  SetLength(FExpenses, Length(FExpenses) + 1);
  FExpenses[High(FExpenses)] := E;
  FModified := True;
end;

procedure TExpenseRepository.DeleteExpense(ID: Integer);
var
  I, J: Integer;
begin
  for I := 0 to High(FExpenses) do
    if FExpenses[I].ID = ID then
    begin
      for J := I to High(FExpenses) - 1 do
        FExpenses[J] := FExpenses[J + 1];
      SetLength(FExpenses, Length(FExpenses) - 1);
      FModified := True;
      Exit;
    end;
end;

function TExpenseRepository.GetAll: TExpenseArray;
begin
  Result := Copy(FExpenses, 0, Length(FExpenses));
end;

function TExpenseRepository.GetByCategory(Cat: TExpenseCategory): TExpenseArray;
begin
  Result := FilterByCategory(FExpenses, Cat);
end;

procedure TExpenseRepository.Save;
var
  F: file of TExpense;
  I: Integer;
begin
  AssignFile(F, FFilename);
  Rewrite(F);
  try
    for I := 0 to High(FExpenses) do
      Write(F, FExpenses[I]);
  finally
    CloseFile(F);
  end;
  FModified := False;
end;

procedure TExpenseRepository.Load;
var
  F: file of TExpense;
  E: TExpense;
begin
  if not FileExists(FFilename) then Exit;
  AssignFile(F, FFilename);
  Reset(F);
  try
    SetLength(FExpenses, 0);
    while not Eof(F) do
    begin
      Read(F, E);
      SetLength(FExpenses, Length(FExpenses) + 1);
      FExpenses[High(FExpenses)] := E;
    end;
  finally
    CloseFile(F);
  end;
  FModified := False;
end;

end.

The Dependency Graph

PennyWise.pas (main program)
    |
    +-- FinanceUI
    |     |
    |     +-- FinanceCore
    |     +-- FinanceReports
    |
    +-- FinanceDB
    |     |
    |     +-- FinanceCore
    |
    +-- FinanceReports
          |
          +-- FinanceCore

The graph is acyclic. FinanceCore is at the bottom, depending on nothing. FinanceDB and FinanceReports depend on FinanceCore but not on each other. FinanceUI depends on FinanceCore and FinanceReports. The main program ties everything together.

This structure means: - Changing FinanceDB does not affect FinanceReports or FinanceUI (as long as the interface stays the same) - Changing FinanceCore's interface affects everything (it is the foundation) - Changing FinanceUI affects nothing else (it is a leaf node) - Each unit can be tested independently

The Detailed Refactoring Process

The refactoring from monolith to multi-unit architecture follows a systematic process. Here is how Rosa and Tomas approached it for PennyWise:

Step 1: Identify responsibilities. Read through the entire source and tag each section with its responsibility: "domain type," "database operation," "display logic," "calculation." Use comments or a spreadsheet.

Step 2: Extract the core types first. Move TExpense, TBudget, TExpenseCategory, and the pure functions that operate on them into FinanceCore. Compile. Fix any references. This is the safest step because it has no side effects.

Step 3: Extract the data layer. Move file I/O and database operations into FinanceDB. Add uses FinanceCore to its interface. Compile. Fix references.

Step 4: Extract reports. Move calculation and formatting logic into FinanceReports. This often reveals hidden dependencies — a report function might directly read a global variable from the database module. Refactor it to accept the data as a parameter instead.

Step 5: Extract UI. Move menu handlers, input/output formatting, and display logic into FinanceUI. This is usually the messiest step because UI code tends to reach into every part of the application.

Step 6: Wire everything together in the main program. The main program creates the repository, calls the UI, and coordinates the pieces. It should be short — perhaps 20-50 lines.

Step 7: Verify. Compile everything. Run the program. Compare the behavior to the original monolith. Run any tests. The behavior should be identical — this is a refactoring, not a feature change.

🔗 Looking Ahead In Chapter 34, we will replace FinanceDB's binary file format with JSON and CSV, making the data human-readable and interoperable. In Chapter 35, we will add a REST API. In Chapter 36, we will add background threads. In Chapter 37, we will add OS-specific path handling. Each of these changes will be localized to the relevant unit — the modular architecture we have built here will pay dividends throughout the rest of Part VI.

What Rosa Learned

Rosa Martinelli has been watching her single PennyWise file grow to over two thousand lines. Functions that format output sit next to functions that read the database. Constants for category names are scattered across three different places, slightly different in each. Last week she tried to change how expenses are stored and accidentally broke the monthly summary report.

"This is like my studio before I bought the filing cabinet," she tells Tomas. "I know everything is in here somewhere, but finding it is a nightmare."

The refactoring takes an afternoon. She moves the expense types into FinanceCore, the file operations into FinanceDB, the display code into FinanceUI, and the calculations into FinanceReports. She discovers two redundant copies of the category-to-string conversion, three different date-formatting functions that do almost the same thing, and a global variable that she thought was used everywhere but was actually referenced in only one place.

When she finishes, each unit is under 300 lines. She can open FinanceCore and see every type and operation that defines what an expense is, without any database or display code cluttering the view. She can modify how expenses are stored without touching how they are displayed. She can add a new report type without risking the data layer.

"Why didn't we do this sooner?" she asks.

"Because we didn't need to," Tomas says. "A fifty-line program doesn't need modules. A two-thousand-line program does. The skill is knowing when to make the switch."


33.10 Summary

This chapter introduced Pascal's module system and the principles of modular design. Here is what we covered:

Units are Pascal's fundamental module construct. Each unit has an interface section (the public contract) and an implementation section (the private details). The interface declares types, constants, variables, and procedure/function headers. The implementation provides the code.

The uses clause imports units. A unit can have two uses clauses — one in the interface (for dependencies needed by the public declarations) and one in the implementation (for dependencies needed only by the private code). Circular references are prohibited in interface uses clauses but can be resolved by moving one dependency to the implementation uses, extracting shared types into a third unit, or refactoring to eliminate the mutual dependency.

Initialization and finalization sections let units run setup and teardown code automatically when the program starts and stops. They run in dependency order (initialization) and reverse dependency order (finalization).

Libraries extend the module system across projects. Static libraries bundle compiled units into a single archive. Dynamic libraries (DLLs on Windows, shared objects on Linux) are loaded at runtime and can be shared between programs. Lazarus packages provide an IDE-integrated mechanism for distributing and reusing unit collections.

Namespace management prevents name collisions through qualified identifiers (UnitName.Identifier), prefix conventions, and minimal interfaces. Real-world collisions are common and must be handled deliberately.

Unit testing becomes practical with modular design — each unit can be tested in isolation through dedicated test programs or the FPCUnit framework. Test each unit's public interface without needing the full application.

Design principles — cohesion, coupling, dependency inversion, and acyclic dependencies — guide the decisions about what goes in which unit. High cohesion and low coupling produce systems that are easier to understand, test, and modify.

PennyWise was refactored from a monolith into four units: FinanceCore (domain model), FinanceDB (data persistence), FinanceUI (user interface), and FinanceReports (report generation). The dependency graph is acyclic, with FinanceCore at the foundation.

The modular architecture we built in this chapter is the foundation for everything that follows in Part VI. Clean module boundaries make it possible to add file format support (Chapter 34), networking (Chapter 35), threading (Chapter 36), and OS integration (Chapter 37) without turning PennyWise into an unmaintainable tangle.

Niklaus Wirth once said that the quality of a program is inversely proportional to the number of places you need to look to understand it. Modular design is how we achieve that quality. One unit, one purpose, one place to look.