Consolidating Report Calculation Logic with a Builder Pattern
Report generation was spread across controllers and helpers. We consolidated it into a single canonical service and simplified the main flow without rewriting the product.
The Problem
On the Curia platform, generating a contract review report involved calculation logic that had grown over time: duty calculations, fee lookups, conditional sections, and merge-tag resolution. That logic lived in several places: a main controller that orchestrated steps, a few helper modules, and some inline conditionals. Adding a new report type or changing a rule meant touching multiple files and risking regressions. The main controller had become hard to follow and difficult to test in isolation.
Investigation
I traced the report generation flow from the API entry point through to the final PDF/content. I mapped which functions read contract data, which applied business rules, and which assembled the output. It became clear there was no single “source of truth” for “how a report is calculated.” Different code paths sometimes duplicated logic or made slightly different assumptions (e.g. rounding, null handling). Unit tests were sparse because the behaviour was tied to the controller and the database.
Root Cause
The root cause was incremental feature growth: each new report type or rule had been added where it was convenient (controller, helper, or new file) without a shared abstraction. There was no dedicated service that owned “given this contract and this template, produce the calculation context.” As a result, the controller did too much, and the logic was scattered.
The Fix
We introduced a report calculation service that owns all calculation steps. It uses a builder pattern: the service builds up a calculation context (duty amounts, fees, flags, etc.) step by step, and the final step produces the structure that the template engine and PDF generator consume. The main controller now only calls this service and passes the result to the renderer. We moved existing logic into the builder’s steps, deduplicated where possible, and added unit tests against the service with mocked data. The controller shrank and became easier to read; new rules are added as new steps or options on the builder.
Lessons Learned
- A builder pattern fits “build up a complex object in stages” well. For report calculation, each stage (duties, fees, conditions) could be a step; the final stage produced the canonical structure. That made the flow testable and predictable.
- Consolidating first, then refactoring, beats rewriting. We didn’t replace the product; we introduced the service and migrated logic into it incrementally, then simplified the controller once the service was in place.
- One canonical path for calculations reduces drift. Having a single service that every report type goes through ensured that rule changes and null handling were applied consistently everywhere.
Have thoughts on this story or questions? Get in touch.