Case Study 1: Building a Configuration System
Overview
In this case study, we build a robust application configuration system that uses INI files as the primary format with JSON as a fallback for complex settings. The system supports defaults, validation, environment-specific overrides, and safe atomic writes. This mirrors the configuration architecture found in professional applications.
Problem Statement
PennyWise needs a configuration system with these requirements:
- Users should be able to edit the configuration file in any text editor
- Missing keys should have sensible defaults (the app must work without a config file)
- Complex settings (like a list of configured bank import profiles) should be stored in JSON
- Changes should survive crashes (atomic write)
- The application should support multiple environments (development, production)
Design
Two-Tier Configuration
Simple key-value settings (database path, display preferences) live in an INI file — easy for users to edit. Complex structured settings (import profiles with multiple fields each) live in a companion JSON file. The INI file points to the JSON file.
pennywise.ini <- Simple settings (human-editable)
pennywise-profiles.json <- Complex settings (program-managed)
Configuration Manager
A single TConfigManager class encapsulates all configuration access:
unit ConfigManager;
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, IniFiles, fpjson, jsonparser;
type
TImportProfile = record
Name: string;
Delimiter: Char;
DateColumn: Integer;
DescColumn: Integer;
AmountColumn: Integer;
DateFormat: string;
HasHeader: Boolean;
end;
TImportProfileArray = array of TImportProfile;
TConfigManager = class
private
FINIFile: TINIFile;
FINIPath: string;
FProfilesPath: string;
FProfiles: TImportProfileArray;
procedure LoadProfiles;
procedure SaveProfiles;
public
constructor Create(const ConfigDir: string);
destructor Destroy; override;
{ Simple settings via INI }
function GetDatabasePath: string;
procedure SetDatabasePath(const Value: string);
function GetAutoSave: Boolean;
procedure SetAutoSave(Value: Boolean);
function GetAutoSaveInterval: Integer;
procedure SetAutoSaveInterval(Value: Integer);
function GetCurrencySymbol: string;
function GetDateFormat: string;
function GetItemsPerPage: Integer;
{ Complex settings via JSON }
function GetProfiles: TImportProfileArray;
procedure AddProfile(const Profile: TImportProfile);
procedure DeleteProfile(const Name: string);
function FindProfile(const Name: string): Integer;
procedure Save;
end;
implementation
constructor TConfigManager.Create(const ConfigDir: string);
begin
inherited Create;
FINIPath := IncludeTrailingPathDelimiter(ConfigDir) + 'pennywise.ini';
FProfilesPath := IncludeTrailingPathDelimiter(ConfigDir) + 'pennywise-profiles.json';
FINIFile := TINIFile.Create(FINIPath);
LoadProfiles;
end;
destructor TConfigManager.Destroy;
begin
Save;
FINIFile.Free;
inherited;
end;
{ --- INI-based settings with sensible defaults --- }
function TConfigManager.GetDatabasePath: string;
begin
Result := FINIFile.ReadString('Database', 'Path', 'pennywise.db');
end;
procedure TConfigManager.SetDatabasePath(const Value: string);
begin
FINIFile.WriteString('Database', 'Path', Value);
end;
function TConfigManager.GetAutoSave: Boolean;
begin
Result := FINIFile.ReadBool('Database', 'AutoSave', True);
end;
procedure TConfigManager.SetAutoSave(Value: Boolean);
begin
FINIFile.WriteBool('Database', 'AutoSave', Value);
end;
function TConfigManager.GetAutoSaveInterval: Integer;
begin
Result := FINIFile.ReadInteger('Database', 'AutoSaveInterval', 30);
if Result < 5 then Result := 5; { Minimum 5 seconds }
if Result > 3600 then Result := 3600; { Maximum 1 hour }
end;
procedure TConfigManager.SetAutoSaveInterval(Value: Integer);
begin
if Value < 5 then Value := 5;
if Value > 3600 then Value := 3600;
FINIFile.WriteInteger('Database', 'AutoSaveInterval', Value);
end;
function TConfigManager.GetCurrencySymbol: string;
begin
Result := FINIFile.ReadString('Display', 'CurrencySymbol', '$');
end;
function TConfigManager.GetDateFormat: string;
begin
Result := FINIFile.ReadString('Display', 'DateFormat', 'yyyy-mm-dd');
end;
function TConfigManager.GetItemsPerPage: Integer;
begin
Result := FINIFile.ReadInteger('Display', 'ItemsPerPage', 25);
end;
{ --- JSON-based import profiles --- }
procedure TConfigManager.LoadProfiles;
var
F: TFileStream;
Parser: TJSONParser;
Data: TJSONData;
Arr: TJSONArray;
Obj: TJSONObject;
I: Integer;
begin
SetLength(FProfiles, 0);
if not FileExists(FProfilesPath) then Exit;
F := TFileStream.Create(FProfilesPath, fmOpenRead);
try
Parser := TJSONParser.Create(F);
try
Data := Parser.Parse;
try
if Data is TJSONArray then
begin
Arr := Data as TJSONArray;
SetLength(FProfiles, Arr.Count);
for I := 0 to Arr.Count - 1 do
begin
Obj := Arr.Objects[I];
FProfiles[I].Name := Obj.Get('name', '');
FProfiles[I].Delimiter := Obj.Get('delimiter', ',')[1];
FProfiles[I].DateColumn := Obj.Get('dateColumn', 0);
FProfiles[I].DescColumn := Obj.Get('descColumn', 1);
FProfiles[I].AmountColumn := Obj.Get('amountColumn', 2);
FProfiles[I].DateFormat := Obj.Get('dateFormat', 'yyyy-mm-dd');
FProfiles[I].HasHeader := Obj.Get('hasHeader', True);
end;
end;
finally
Data.Free;
end;
finally
Parser.Free;
end;
finally
F.Free;
end;
end;
procedure TConfigManager.SaveProfiles;
var
Arr: TJSONArray;
Obj: TJSONObject;
I: Integer;
SL: TStringList;
begin
Arr := TJSONArray.Create;
try
for I := 0 to High(FProfiles) do
begin
Obj := TJSONObject.Create;
Obj.Add('name', FProfiles[I].Name);
Obj.Add('delimiter', FProfiles[I].Delimiter);
Obj.Add('dateColumn', FProfiles[I].DateColumn);
Obj.Add('descColumn', FProfiles[I].DescColumn);
Obj.Add('amountColumn', FProfiles[I].AmountColumn);
Obj.Add('dateFormat', FProfiles[I].DateFormat);
Obj.Add('hasHeader', FProfiles[I].HasHeader);
Arr.Add(Obj);
end;
{ Atomic write: write to temp file, then rename }
SL := TStringList.Create;
try
SL.Text := Arr.FormatJSON;
SL.SaveToFile(FProfilesPath + '.tmp');
if FileExists(FProfilesPath) then
DeleteFile(FProfilesPath);
RenameFile(FProfilesPath + '.tmp', FProfilesPath);
finally
SL.Free;
end;
finally
Arr.Free;
end;
end;
function TConfigManager.GetProfiles: TImportProfileArray;
begin
Result := Copy(FProfiles, 0, Length(FProfiles));
end;
procedure TConfigManager.AddProfile(const Profile: TImportProfile);
begin
SetLength(FProfiles, Length(FProfiles) + 1);
FProfiles[High(FProfiles)] := Profile;
end;
procedure TConfigManager.DeleteProfile(const Name: string);
var
Idx, J: Integer;
begin
Idx := FindProfile(Name);
if Idx < 0 then Exit;
for J := Idx to High(FProfiles) - 1 do
FProfiles[J] := FProfiles[J + 1];
SetLength(FProfiles, Length(FProfiles) - 1);
end;
function TConfigManager.FindProfile(const Name: string): Integer;
var
I: Integer;
begin
for I := 0 to High(FProfiles) do
if CompareText(FProfiles[I].Name, Name) = 0 then
Exit(I);
Result := -1;
end;
procedure TConfigManager.Save;
begin
FINIFile.UpdateFile;
SaveProfiles;
end;
end.
Atomic Writes
Notice the atomic write pattern in SaveProfiles:
- Write to a temporary file (
.tmp) - Delete the original file
- Rename the temporary file to the original name
If the program crashes during step 1, the original file is intact. If it crashes during step 2 or 3, the .tmp file exists and can be recovered. This ensures that a crash never leaves you with a half-written, corrupt configuration file.
Validation
The GetAutoSaveInterval method demonstrates validation: it clamps the value to a valid range (5–3600 seconds) regardless of what the INI file contains. This defensive approach means a user who accidentally types AutoSaveInterval=0 gets a safe default rather than a program that tries to auto-save continuously.
Lessons Learned
- Use the simplest format that works. INI for flat settings, JSON for structured data. Do not use XML when INI suffices.
- Always provide defaults. Every
Readcall has a default. The application works even if the config file is deleted. - Validate on read, not on write. The user might edit the INI file by hand and introduce invalid values. Validate when you read, not when you write.
- Atomic writes prevent corruption. Write-then-rename is a simple pattern that prevents data loss.
- Separate human-editable from program-managed. Users edit the INI file; the program manages the JSON file. Mixing human and machine editing of the same file is a recipe for conflicts.