Building the Financial Calculation Engine Before the UI
The short answer
We built the pension calculation engine as a pure TypeScript domain module before creating any interface that could post money. It cannot import the web framework, database or file handling. That boundary lets calculations run deterministically in isolation, while a narrow application layer owns authorization, persistence and atomic ledger posting.
By Timothy Indarsingh, Founder & CEO, Firelinkx
This is part of the engineering series behind our group pension administration case study. Financial applications are often built from the screen inward: create a form, save its fields, then add calculations behind the submit button. We reversed that order. The rules and arithmetic had to become a proven domain engine before any interface was allowed to turn their result into money.
The reason is practical. A benefit calculation does not become more trustworthy because it sits behind a polished workflow. Sessions, forms, database queries and file imports make the code harder to inspect while adding nothing to the financial meaning. We wanted the core question—given these facts and the applicable rules, what entries should exist—to be answerable without a server running.
The domain module has a hard import boundary
The domain directory contains money types, plan rules, calculation functions and ledger instructions. It imports no framework, database client, storage SDK, mailer or file parser. An enforced lint rule blocks those dependencies. The boundary is not a convention described in an architecture document; violating it fails the build.
Inputs arrive as domain values rather than request objects or database rows. Outputs describe calculated amounts, selected rules, explanations and balanced posting instructions rather than calling persistence directly. The application layer can translate storage records into inputs and commit accepted instructions, but the calculation has no path back into infrastructure.
This keeps tests honest and fast. A rule test does not need to mock an authentication session or construct an HTTP request. It supplies dates, contribution sources, balances and a rule version, then inspects the result. If a calculation changes, the diff is about the business rule rather than the scaffolding needed to reach it.
The module boundary also makes illegal dependencies visible in review. A calculation that suddenly needs a database lookup is a sign that an input or rule-selection responsibility has not been modelled, not an invitation to import the ORM. Passing the value explicitly keeps determinism and forces the application layer to own when and under whose authority the value was obtained.
Determinism includes time. Domain functions do not ask the system clock which rule is current; the caller supplies the value date and resolved version. A historical calculation run tomorrow therefore receives the same relevant facts it received today. Operational timestamps are added around the calculation, not smuggled into its result.
The boundary in one sentence
The domain decides what the financial event means. The application decides whether this actor may request it, how evidence is stored and whether the resulting entries can commit.
Money is a type with rules, not a number with formatting
The engine never uses a native floating-point number for money. Amounts enter as decimal strings, become arbitrary-precision decimal values and return as strings for storage or transport. The database uses fixed precision. Formatting to two places is presentation; it is not the arithmetic model.
Rounding is explicit and occurs at posting. When a fund-level total must be distributed across members, the engine rounds the control amount first and allocates the remaining cents deterministically so every part sums back to it. This prevents individually rounded rows from drifting away from the amount the ledger is supposed to post.
The same primitives are reused across contribution splits, interest, fees, forfeiture and benefits. A configured percentage had once been represented on different scales in different parts of the system, creating the possibility of a hundredfold error. Normalising the representation and funnelling percentage application through one domain path removed that footgun instead of relying on every caller to remember which scale it held.
Rules are selected before they are applied
The engine does not read the current plan record and assume it governs the calculation. The application supplies the approved rule version covering the event's value date, and the calculation result records that selection. Separating resolution from application lets tests examine both questions: did we choose the right version, and did that version produce the right result?
This separation is especially useful around scheme-year boundaries and historical benefits. A fixture can assert the selected version identifier even when two versions happen to produce the same amount. Otherwise a wrong temporal selection can hide until a later configuration change makes the figures diverge.
The temporal model is explored fully in Effective-Dated Pension Rules Without Rewriting History. At the engine boundary, the essential rule is that calculations receive an immutable, already-authorised version rather than reaching into mutable application state.
Return ledger instructions, not side effects
A calculation can produce a proposed set of balanced entries without posting them. That makes preview, review and maker-checker possible. The maker creates the instruction from domain facts. A checker can inspect the same amounts and explanation. Only the authorised application transition converts it into an atomic ledger transaction.
This also keeps failure boundaries clear. A calculation error cannot leave half a journal in the database because calculation performs no writes. A persistence failure cannot change the arithmetic because the instruction is already complete. The application validates that entries sum to zero and commits them together or not at all.
For benefits, the instruction includes the employee source, vested employer amount, any forfeiture and payment destination. For annual processing, it includes the control total and member allocations. The output shape carries enough explanation for review without exposing infrastructure concerns to the domain.
Build order: prove the centre, then one complete path
The project sequence was foundations, domain engine with historical fixtures, one complete vertical slice, then breadth. The first slice crossed the real boundaries: authenticated request, rule resolution, domain calculation, maker-checker, database posting and an as-at result. It was intentionally narrow in product scope and complete in architecture.
A horizontal build would have produced many screens backed by unfinished rules. A pure engine with no vertical slice would have proved arithmetic while leaving authorization and persistence integration uncertain. The chosen order made the difficult centre independently testable, then forced it through the actual application path before multiplying features.
Once that path held, contribution allocation, interest, fees, close and benefits could reuse the boundaries. New rule shapes changed the domain representation and tests. New workflows changed application states and authority. Neither required financial logic to migrate into components.
One explanation shape across calculations
Results carry more than an amount. They identify the rule version, relevant inputs, intermediate control totals and the proposed entries. That common explanation shape lets the approval interface, audit record and regression output describe the same calculation without each rebuilding the reasoning from raw fields.
It also makes a changed result reviewable. A diff can show whether the selected rule moved, a source balance changed, a rounding remainder went to a different member or only presentation changed. A financial regression is much easier to assess when the engine returns its working as structured data instead of a final decimal and a log message.
Historical fixtures cross the database boundary
Unit tests are necessary but not the acceptance proof. Four fixtures encode audited historical scheme-years. They are loaded through the live database path, processed by the engine and posting layer, and compared with the audited workbook to the cent. This catches correct functions wired to the wrong column, wrong account or wrong date.
No money-posting feature could merge before the relevant fixture stayed green. The fixtures act as a ratchet: adding a fee shape cannot move the already-proved interest result, and benefit changes cannot alter a closed historical year. Their expected values existed independently of the implementation, which is why they can disagree with it meaningfully.
The acceptance methodology has its own article, The Spreadsheet Was the Acceptance Test. The architectural point here is that a pure engine makes small rules explainable, while database-level fixtures prove the composition and wiring.
Continuous integration runs the unit, integration and end-to-end layers against a fresh database. The scratch environment matters because a passing fixture against a developer's long-lived schema can conceal an undeclared migration or seed dependency. Rebuilding from zero proves that the route from schema to historical result is actually recorded in the repository.
Infrastructure remains behind adapters
The primary deployment uses infrastructure controlled by the organisation for the application, database, document storage, mail and background work. Those capabilities are reached through adapters. An approved replacement can be introduced without putting a vendor SDK into the calculation or ledger model.
This was a data-residence decision made before deployment, not an environment variable added at the end. Live personal data does not enter an unapproved environment. The domain tests need no external services, and integration tests can exercise adapter contracts with controlled implementations.
The outbox illustrates why an interface alone is insufficient. The first system could enqueue statement-delivery work while no consumer actually sent it. The adapter contract existed, but the operational path ended early. We corrected the delivery worker and added end-to-end verification of the effect. Architecture makes a path replaceable; verification proves the path is complete.
Prepare for another product without implementing it
The first release administers defined-contribution plans. Benefit calculation sits behind an engine interface whose only implementation is the product we actually support. The ledger itself is product-neutral, and the schema identifies plan type. We did not write speculative defined-benefit formulas.
We did capture historical facts a later engine could require: salary history, employment and plan-join dates, marital facts and beneficiaries. Those inputs occur in the real world and cannot be reconstructed reliably years later. The interface boundary preserves a place for another strategy; the data model preserves facts; the current product remains honest about what it calculates.
What belongs outside the domain core
- Authentication, roles and maker-checker identity checks.
- Database queries, transactions and durable job scheduling.
- Spreadsheet parsing, member matching and import exception queues.
- PDF generation, secure-link delivery and notification transport.
- HTTP validation, UI state and framework-specific caching.
Keeping these concerns outside does not make them less important. It makes their contracts visible. The application layer owns the complete regulated workflow, while the domain core remains a small surface whose arithmetic and rule semantics can be read without tracing a web request.
The takeaway
Build the part that decides money before the part that displays it. Give that core decimal types, immutable inputs and side-effect-free outputs. Enforce the boundary with tooling. Then prove one narrow path through authorization, persistence and the ledger before expanding the interface. The result is not architecture for its own sake; it is a calculation you can test, explain and replace without bringing the entire application into the room.
Ready to replace your manual workaround?
Firelinkx helps Guyanese businesses get this right. Get a clear scope, timeline, and price, or just ask a question. We respond within 24 hours on business days.