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:

  1. Users should be able to edit the configuration file in any text editor
  2. Missing keys should have sensible defaults (the app must work without a config file)
  3. Complex settings (like a list of configured bank import profiles) should be stored in JSON
  4. Changes should survive crashes (atomic write)
  5. 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:

  1. Write to a temporary file (.tmp)
  2. Delete the original file
  3. 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

  1. Use the simplest format that works. INI for flat settings, JSON for structured data. Do not use XML when INI suffices.
  2. Always provide defaults. Every Read call has a default. The application works even if the config file is deleted.
  3. 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.
  4. Atomic writes prevent corruption. Write-then-rename is a simple pattern that prevents data loss.
  5. 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.