Case Study 1: PennyWise 2.0 — The Complete Architecture
A full system diagram, class hierarchy, database schema, and data flow analysis of the PennyWise 2.0 personal finance manager.
Background
PennyWise 2.0 is the capstone application of this textbook — a complete, cross-platform personal finance manager built with Free Pascal and Lazarus. This case study provides the definitive architectural reference for the project, suitable for printing and pinning above your desk as you build.
The architecture was not arrived at by accident. Rosa and Tomas — and you — built PennyWise incrementally over thirty-seven chapters, adding features one at a time. But PennyWise 2.0 is a clean-room redesign that takes everything learned from those incremental additions and reorganizes it into a professional architecture. This is a common pattern in software development: the first version teaches you what the system should look like; the second version implements that knowledge.
The Module Dependency Diagram
PennyWise 2.0 consists of five units plus the main program. The dependencies between them form a layered architecture:
┌─────────────────────────────────────────────────┐
│ PennyWise2.lpr │
│ (main program file) │
├───────────┬───────────┬───────────┬─────────────┤
│ FinanceUI │FinanceSync│FinanceExp │ (top) │
│ (forms, │ (threads, │ (CSV, │ layer: │
│ dialogs, │ REST, │ JSON │ presenta- │
│ charts) │ tray) │ import/ │ tion & │
│ │ │ export) │ I/O │
├───────────┴───────────┴───────────┤─────────────┤
│ FinanceDB │ middle │
│ (SQLite, queries, │ layer: │
│ migrations, CRUD) │ persist. │
├───────────────────────────────────┤─────────────┤
│ FinanceCore │ bottom │
│ (TTransaction, TExpense, │ layer: │
│ TIncome, TBudget, │ domain │
│ TTransactionManager) │ model │
└───────────────────────────────────┴─────────────┘
The dependency rule: Dependencies flow downward only. FinanceUI depends on FinanceDB and FinanceCore. FinanceDB depends on FinanceCore. FinanceCore depends on nothing (except standard Free Pascal units like Classes, SysUtils, and Generics.Collections).
This rule is not arbitrary. It ensures that:
-
The domain model is independent.
FinanceCorecan be compiled, tested, and reused without any database, GUI, or network code. You could takeFinanceCore.pasand use it in a command-line tool, a web server, or a mobile app. -
The database can change. If you replace SQLite with PostgreSQL, MySQL, or a flat file, only
FinanceDBchanges. Nothing above it notices. -
The UI can change. If you replace the Lazarus GUI with a web interface or a TUI (text user interface), only
FinanceUIchanges. The business logic and database continue working. -
Testing is straightforward. Each layer can be tested in isolation:
FinanceCorewith unit tests,FinanceDBwith integration tests against a test database,FinanceUIwith manual or automated UI tests.
The Class Hierarchy
TObject (Free Pascal root)
│
├── TTransaction
│ ├── TExpense
│ │ └── (IsRecurring, RecurrenceDays)
│ └── TIncome
│ └── (Source)
│
├── TBudget
│ └── (Category, MonthlyLimit, Month, Year)
│
├── TTransactionManager
│ ├── FTransactions: TTransactionList
│ ├── FBudgets: TBudgetList
│ └── Methods: Add, Remove, Find, Sort, Query, Report
│
├── TFinanceDatabase
│ ├── FConnection: TSQLite3Connection
│ └── Methods: Save, Load, Update, Delete, Backup
│
├── TMainForm (TForm)
│ ├── FManager: TTransactionManager
│ ├── FDatabase: TFinanceDatabase
│ └── UI Components: Grid, Charts, Entry Panel, Budget Sidebar
│
├── TCSVImporter
│ └── Methods: ImportFromFile, ParseCSVLine, ParseDate
│
├── TCSVExporter / TJSONExporter
│ └── Class methods: ExportTransactions, ExportMonthlyReport
│
└── TAutoSaveThread (TThread)
└── Methods: Execute (periodic save check)
Key Design Decisions
Why TTransaction is a class, not a record. Records are value types in Pascal — they are copied on assignment and cannot participate in inheritance. Transactions need inheritance (TExpense and TIncome are specialized transactions) and identity (each transaction has a unique ID that persists across saves). Classes provide both.
Why TTransactionManager owns the transaction list. The manager creates its TTransactionList with OwnsObjects = True, meaning the list automatically frees transaction objects when they are removed or the list is destroyed. This prevents memory leaks without requiring the caller to manage object lifetimes — a clean ownership pattern.
Why TCSVExporter uses class methods. Export operations are stateless — they take data in and write a file out, with no need to maintain state between calls. Class methods (which can be called without creating an instance) are the natural Pascal idiom for stateless operations.
The Database Schema
Entity-Relationship Diagram
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ transactions │ │ budgets │ │ categories │
├──────────────┤ ├──────────────┤ ├──────────────┤
│ id (PK) │ │ id (PK) │ │ id (PK) │
│ trans_type │ │ category │──┐ │ name (UQ) │
│ date │ │ monthly_limit│ │ │ parent_cat │──┐
│ amount │ │ month │ │ │ icon │ │
│ description │ │ year │ │ └──────────────┘ │
│ category │──┐ │ UQ(cat,m,y) │ │ │ │
│ source │ │ └──────────────┘ │ │ self-ref │
│ is_recurring │ │ │ └────────────┘
│ recurrence_ │ │ ┌──────────────┐ │
│ days │ │ │ app_settings │ │
│ created_at │ │ ├──────────────┤ │
│ updated_at │ │ │ key (PK) │ │
└──────────────┘ │ │ value │ │
│ └──────────────┘ │
│ │
└── category name ───┘
Table Details
transactions (primary data table):
- Single-table inheritance: both expenses and income in one table, distinguished by trans_type.
- source is NULL for expenses, populated for income.
- is_recurring and recurrence_days are meaningful only for expenses.
- Indexes on date, category, and trans_type for query performance.
budgets (spending limits):
- One row per category per month.
- UNIQUE(category, month, year) prevents duplicate budgets.
- Referenced by category name (not by foreign key to categories), allowing budgets for ad-hoc categories.
categories (optional hierarchy):
- Self-referential parent_category column enables hierarchical categories (e.g., "Food > Groceries > Organic").
- Currently optional — PennyWise 2.0 works with flat categories. Hierarchy is a future extension point.
app_settings (key-value configuration): - Stores application preferences, schema version, and user configuration. - Simple key-value pattern avoids schema changes for new settings.
Data Flow Analysis
Adding a Transaction
User clicks "Add" button
│
▼
BtnAddClick handler fires
│
├── 1. VALIDATE
│ ├── ValidateAmount(SpinAmount.Value) → must be > 0
│ ├── ValidateCategory(ComboCategory.Text) → must be non-empty
│ └── ValidateDate(DatePicker.Date) → must be reasonable
│ If any fail → ShowMessage → EXIT
│
├── 2. CREATE OBJECT
│ ├── If RadioExpense.Checked:
│ │ └── TExpense.Create(date, amount, desc, category)
│ └── Else:
│ └── TIncome.Create(date, amount, desc, category, source)
│
├── 3. UPDATE MODEL
│ └── TTransactionManager.AddTransaction(T)
│ └── FTransactions.Add(T), FModified := True
│
├── 4. PERSIST
│ └── TFinanceDatabase.SaveTransaction(T)
│ └── INSERT INTO transactions ... ; COMMIT
│ └── T.ID := GetInsertID (database assigns the ID)
│
└── 5. DISPLAY
├── RefreshGrid → re-reads FTransactions, updates cells
├── RefreshBudgetPanel → recalculates category summaries
├── RefreshCharts → updates pie and bar charts
├── ClearEntryFields → resets form for next entry
└── CheckBudgetAlerts → warns if over 80% of any budget
Application Startup
PennyWise2.lpr → Application.Initialize → Application.CreateForm(TMainForm)
│
▼
TMainForm.FormCreate
│
├── Create TTransactionManager (empty)
│
├── Create TFinanceDatabase(path)
│ ├── Open SQLite connection
│ ├── InitializeSchema (CREATE TABLE IF NOT EXISTS)
│ └── RunMigrations (check schema_version)
│
├── FDatabase.LoadAllTransactions(FManager)
│ └── SELECT * FROM transactions → create TExpense/TIncome objects → add to manager
│
├── FDatabase.LoadAllBudgets(FManager)
│ └── SELECT * FROM budgets → call SetBudget for each row
│
├── Setup grid columns
│
├── RefreshGrid + RefreshBudgetPanel + RefreshCharts
│
├── Start TAutoSaveThread (checks every 60 seconds)
│
└── CheckBudgetAlerts (show warnings for over-budget categories)
Performance Characteristics
| Operation | Time Complexity | Notes |
|---|---|---|
| Add transaction | O(1) amortized | List append + single INSERT |
| Delete transaction | O(n) | Linear scan to find ID in list |
| Find transaction by ID | O(n) | Linear scan (could be optimized with a dictionary) |
| Get total expenses for month | O(n) | Scans all transactions, filters by date |
| Sort by date | O(n log n) | Uses TObjectList.Sort with comparator |
| Category summaries | O(n) | Single pass with dictionary lookup |
| Monthly report | O(n) | Calls GetTotalExpenses, GetTotalIncome, GetCategorySummaries |
| Database query by date range | O(log n + k) | Index scan + k matching rows |
For PennyWise's typical use case (a few thousand transactions per year), all operations are effectively instantaneous. The O(n) operations become noticeable only at tens of thousands of transactions, and the database indexes handle query-side performance. If you needed to support millions of transactions, you would:
- Replace the in-memory list with database-only queries (lazy loading).
- Add pagination to the grid (show 100 rows at a time).
- Use database aggregation (
SUM,GROUP BY) instead of in-memory computation.
Lessons from the Architecture
-
The domain model is the foundation. Every other component — database, UI, export, network — is a view of or gateway to the domain model. If the domain model is correct, the rest follows. If it is wrong, no amount of UI polish can fix it.
-
Interfaces should be narrow.
TFinanceDatabaseexposes a small number of methods (Save, Load, Update, Delete, Query). It does not expose the SQL connection, the query objects, or any database-specific types. Callers cannot accidentally bypass the abstraction. -
Ownership must be explicit. Every object has exactly one owner that is responsible for freeing it.
TTransactionManagerowns its transaction list.TMainFormowns the manager and the database. The auto-save thread does not own anything — it borrows references. -
The compiler enforces the architecture. Because
FinanceCoredoes not haveFinanceDBin itsusesclause, it cannot accidentally access the database. This is not a convention — it is a compile-time guarantee. In dynamically typed languages, architectural boundaries are suggestions. In Pascal, they are enforced.
This architecture is not perfect. It is not the only valid architecture for a personal finance manager. But it is clean, it is understandable, and it works. And because it follows the principles of separation, explicit ownership, and narrow interfaces, it can evolve without being rewritten.
That is the goal. Not perfection — evolution.