A working C++ console CRM application with clean layered architecture, modern C++ idioms, threading (auto-save), and file I/O.
poliz manages three core entities for an insurance company: clients, policies, and interactions (appointments and contracts). The application runs entirely in the terminal. Data is persisted to CSV files in a user-specified directory. A background thread saves automatically at a configurable interval so that data is never lost between manual saves.
- Clients: add, list, search, view detail, edit all fields, delete (with cascade removal of all associated policies and interactions).
- Policies: add (type, duration, and start date are the only inputs; the system calculates the end date and amount from a fixed pricing table), list, search, view detail, edit status and notes, delete.
- Interactions: add appointments (date, time, duration, optional report) and contracts (date, value, product name, signed date, optional expiry, status), list, search, view detail, edit, delete.
- Application level: manual save at any time, configurable auto-save in the background, clean shutdown with a save prompt when there is unsaved data.
The codebase is organized into four layers. Dependencies flow strictly inward: no layer knows what is above it. main.cpp is the composition root; it constructs all repositories, services, and controllers and passes them into Application. Application belongs to insura::cli and calls into insura::service. The service layer calls into insura::domain through repository interfaces. insura::data implements those interfaces and also depends on insura::domain. Nothing in a lower layer knows that a higher layer exists.
- insura::domain: entities, repository interfaces, enums, DTOs, and pure utilities (string ops, date arithmetic, validation, UUID). No I/O.
- insura::data: CSV repositories implementing the domain interfaces. The only layer that knows CSV exists. Atomic write pattern, mutex-guarded access.
- insura::service: business logic (email uniqueness, cascade delete, policy pricing). Receives repository interfaces by reference, no knowledge of CSV or the CLI.
- insura::cli: controllers with dispatch tables, view classes, shared
resolution helpers.
Applicationis a thin orchestrator owning one controller per entity.
insura::service and insura::data are independent siblings: both depend on insura::domain, neither depends on the other. Service calls through repository interfaces defined in the domain; data implements them. main.cpp is the only place that instantiates all four layers together.
CMake enforces the dependency graph at the link level: each layer is a separate
static library and target_link_libraries mirrors the allowed dependencies.
A symbol from an unlisted layer causes a linker error.
- CMake 3.14 or later
- A C++17-compatible compiler (GCC 8+, Clang 7+, MSVC 2017+)
- An internet connection for the first build (Catch2 is fetched via
FetchContentfrom GitHub at configure time)
No other dependencies. The project has no third-party runtime libraries.
# Debug build (assertions active, no optimizations)
cmake -S . -B build-debug -DCMAKE_BUILD_TYPE=Debug
cmake --build build-debug
# Release build (assertions removed via NDEBUG, -O2)
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release
cmake --build build-release./build-debug/src/polizThe startup menu offers new (create an empty CRM in a directory) or load
(load existing CSV files from a directory). The default directory is read from
poliz.conf in the project root.
cd build-debug && ctest --output-on-failureOr run the test binary directly for verbose Catch2 output:
./build-debug/tests/crm_tests
./build-debug/tests/crm_tests --reporter compact # compact outputpoliz
src/
insura_domain (static library)
insura_data (static library, links insura_domain)
insura_service (static library, links insura_domain)
insura_cli (static library, links insura_domain, insura_service)
poliz (executable, links all four libraries)
tests/
crm_tests (executable, links Catch2, insura_data, insura_service, insura_cli)
Compiler flags active on all targets: -Wall -Wextra -Wpedantic.
C++17 was chosen as the learning target for several reasons. It introduced
several language features that were directly useful in this project and that are
now considered standard practice: std::optional for representing absent values
without null pointers, structured bindings for destructuring pairs and tuples,
if constexpr for compile-time branching, std::filesystem for portable path
manipulation, and std::string_view for non-owning string references.
C++17 is also widely supported across all major compilers without flags or experimental headers, which kept the build setup straightforward.
Several implementation choices in this project exist because C++17 does not provide a better tool. These are documented here not as deficiencies but as deliberate trade-offs with a known upgrade path.
- Date arithmetic: dates are stored as
std::stringinYYYY-MM-DDformat. Custom helpers (isValidDate,calculateEndDate,isDateAfter) handle validation and arithmetic manually. In C++20,std::chrono::year_month_dayprovides type-safe date arithmetic.calculateEndDateandisValidDatewould become one-liners using the calendar library, and date parsing errors would be type errors caught at compile time rather than runtime throws. - Thread-safe timestamp formatting:
utils.hppcontains an inlinesafe_localtimewrapper that dispatches tolocaltime_ron POSIX andlocaltime_son Windows via preprocessor macros. This is necessary becausestd::localtimerelies on a shared static buffer that is a data race under threading. In C++20,std::format("{:%Y-%m-%d %H:%M:%S}", std::chrono::...)is thread safe by design and eliminates the platform-specific wrapper entirely. - String formatting: output formatting uses
std::ostringstreamand manual concatenation throughout (for example, formatting a policy amount as a EUR string or building a timestamped log line). In C++20,std::formatprovides the same capability with a cleaner syntax, better performance, and compile-time format string validation. - Range-based filtering: search operations (filtering clients by name,
filtering policies by client UUID) use explicit for loops with
push_back. In C++20,std::ranges::views::filterandstd::ranges::views::transformcompose these into declarative pipelines without intermediate storage. - Module system: the codebase uses the traditional header and source file
pair for every translation unit. C++20 modules would eliminate
#pragma onceguards, reduce redundant parsing of headers across translation units, and make the dependency graph explicit in the language rather than inferred from include paths.
The patterns below appear in the production code. Each is noted with where it lives and why it was chosen over the alternatives.
- Repository pattern:
IClientRepository,IPolicyRepository, andIInteractionRepositoryare abstract interfaces in the domain layer. Concrete CSV implementations live in the data layer. Services receive interfaces by reference at construction. This means the persistence mechanism could be replaced (for example, swapped for a SQLite implementation) without touching the service layer. The interfaces also make the dependency graph explicit: nothing above the data layer knows that CSV exists. - Orchestrator and controller:
Applicationis a thin orchestrator that owns oneIEntityControllerper entity. It selects the active controller based on user input and delegates all CRUD operations to it. Controllers are registered in the constructor by name;Applicationnever mentions client, policy, or interaction types directly. This was the result of ADR-017, which replaced a monolithicApplicationclass of roughly 400 lines with an orchestrator of roughly 80 lines. - Command dispatch table: each controller maintains an
unordered_map<string, function<void()>>populated in its constructor.run()is a single lookup and call with no switch statements or if-else chains. Adding a command is one map entry; removing one is one erasure. - Prototype (clone): the
Interactionabstract class declares a pure virtualclone()returningstd::unique_ptr<Interaction>. Each derived class implements it by delegating to its own copy constructor. The interaction repository uses this to hand out owned, independent copies without exposing raw pointers into its internal vector, which would be unsafe under the background auto-save thread. - RAII resource management:
FileHandlerwrapsstd::fstream. The file opens in the constructor and closes in the destructor. No caller can forget to close. The auto-save thread lifetime is managed the same way: theAutoSaveServiceconstructor starts the thread;stop()sets the flag and joins. The destructor callsstop()as a safety net. - Dependency injection: every service receives its repository interface by reference in its constructor. No service instantiates a repository. This keeps the service layer testable in isolation: integration tests construct real repositories with temporary files and pass them to the service under test, with no mocking needed.
- Data Transfer Object:
ClientData,PolicyData, andInteractionDataare plain structs with optional fields that carry data from the CLI layer to the service layer. The controller collects user input into the DTO and passes it to the service. The service validates, enriches (calculates end date and amount for policies), and constructs the entity. No partially constructed entity crosses a layer boundary.
Tests are written with Catch2 v3.4.0, fetched automatically by CMake at configure time. There are two levels of tests.
Unit tests cover pure functions in the domain layer with no I/O.
| File | Coverage |
|---|---|
test_strops.cpp |
trim, lower, capitalize, contains |
test_utils.cpp |
isValidDate, isLeapYear, isDateAfter, calculateEndDate, isValidEmail, isDigitsOnly, generateUuid, stringToOptional |
test_status_converter.cpp |
all ClientStatus enum-to-string and string-to-enum round trips |
test_interaction_status_converter.cpp |
all InteractionType and ContractStatus converters |
test_calculate_amount.cpp |
all 16 type/duration combinations in the policy pricing table |
test_domain.cpp |
Client and Policy construction and invariant enforcement |
Integration tests (integration_test.cpp) use real temporary files and
real repository instances. No mocks. The repository interfaces exist for
dependency injection and a future storage swap, not for creating test doubles.
A mock would test the mock, not the serialization or file I/O. Integration tests
cover serialize and deserialize round trips for all three entities, save and
reload cycles, service-level rules (duplicate email rejection, cascade delete,
policy field calculation), and cross-entity integrity (interactions reference
valid clients, client delete cascades to interactions).
Catch2 conventions used throughout: test names describe behavior, not function
names. SECTION groups related assertions. GENERATE with tables for
parameterized data (all enum-to-string mappings). CHECK for independent
assertions in the same section, REQUIRE for preconditions and single
assertions. REQUIRE_THROWS_AS for exception paths. Catch::Approx for all
double comparisons.
The file poliz.conf in the project root is read at startup. If it is
missing, defaults apply silently. The user edits the file directly; there is no
config command in the menu.
# poliz Configuration
autosave_enabled=true
autosave_interval_seconds=60
default_directory=poliz_data
clients_filename=clients.csv
policies_filename=policies.csv
interactions_filename=interactions.csvInvalid values print a warning to stderr and keep the default. Unknown keys are
ignored with a warning. The parser splits on the first = only, so values may
contain = characters.
The design decision between a Git-style layered config system with typed callbacks and this single flat file is documented in ADR-023. The flat file was chosen because the CRM has five configuration keys. A section-based dispatcher with typed callbacks for five fields is disproportionate to the problem.
All architecture decision records live in docs/adr/. The index is docs/adr/00_backlog.md.
This project was built as a learning exercise. AI was used as a thinking tool
under a strict protocol defined in CLAUDE.md.
-
Socratic reasoning: when stuck on a design or implementation problem after at least 20 minutes of independent work, AI was consulted using the Socratic method only: it asked questions and challenged assumptions rather than providing solutions. No code was generated in this mode.
-
Architecture review: after forming an architectural position independently (usually after studying relevant documentation and implementing a first version), AI was consulted for feedback on that position. It never proposed an architecture first. ADR-017 (orchestrator and controller), ADR-020 (error handling strategy), and ADR-024 (auto-save threading design) each went through this review after the decision was already made.
-
Repetitive pattern code: once a concept had been studied and implemented manually at least once, AI generated mechanical repetitions of that pattern within strictly bounded scope (one function at a time). In every case, comment-specs describing the function's purpose, parameters, and logical steps were written first. Every line of the generated output was read, understood, and manually verified before being committed. Functions generated this way include:
- Status converter pairs (
statusToStringandstatusFromString) forPolicyStatus,PolicyType,InteractionType, andContractStatus, after theClientStatusconverters were written manually. - The
serializeanddeserializemethods inCsvPolicyRepositoryandCsvInteractionRepository, afterCsvClientRepositoryserialization was implemented manually. - The
safe_localtimeinline wrapper inutils.hpp, after the threading problem was identified, the two platform-specific APIs were read in their documentation, and the preprocessor macro structure was understood. - The
GENERATEtable intest_calculate_amount.cpp, after the Catch2 parameterized test pattern was learned and applied manually in the status converter tests.
- Status converter pairs (
What AI did not do: architecture decisions were never delegated. Debugging was always manual first. New language concepts were always studied and implemented by hand before AI was involved. The overall structure, layer boundaries, interface designs, and data model reflect independent design choices made before any AI consultation.
The study notes and documentation files in docs/ were written by AI after a specific workflow. Each note started with a discussion where I explained my understanding of the topic in my own words. AI asked questions, corrected misconceptions, and challenged assumptions until the understanding was verified. Only after that verification step did AI write the final note, using my reasoning and explanations as the content source but producing cleaner English prose. The ideas and reasoning in every note are mine. The writing quality is AI-assisted.
- Replace CSV persistence with a relational database for transactional writes and indexed queries.
- Interactive search with real-time filtering as the user types.
- Stricter input validation: time format enforcement (HH:MM 24-hour), phone number country-code normalization, policy deduplication on type + client + start date.
- Address autocomplete via geocoding API.
- Performance test suite with generated datasets to measure load, save, and search under realistic volume.
- Migration to C++20 and progressively C++23 to replace the manual workarounds documented in the "What C++20 would change" section with standard library facilities.
- HTTP API layer exposing all CRM operations (add, list, search, edit, delete for clients, policies, and interactions) over network requests, so the terminal UI becomes one client among many rather than the only interface to the application.