Where It Started
I was working through a tax calculation problem and hit the classic JavaScript trap almost immediately:
0.1 + 0.2 === 0.30000000000000004
In most applications you can shrug that off. But when you're calculating tax on a $142,857.14 transaction and stacking a 7.25% California state rate with a 2.5% Los Angeles county surcharge, that tiny error compounds into real cents that don't reconcile. I decided to build JurisLogic as a proper solution - a headless tax and commission microservice where every monetary operation goes through an immutable Money value object backed by decimal.js with ROUND_HALF_UP (banker's rounding).
The result is a NestJS service that handles four jurisdiction families - US, EU, UK, and Canada - and demonstrates a clean hexagonal architecture where domain logic has zero framework dependencies.
The Domain Layer: Pure Business Logic
The heart of JurisLogic lives in src/domain/, and I'm genuinely proud of how clean it turned out. There are no @Injectable() decorators, no imports from NestJS, no infrastructure concerns whatsoever. Just pure TypeScript classes that model tax rules.
Value Objects
Three immutable value objects carry all the domain primitives:
Moneywrapsdecimal.jsand exposes.add(),.subtract(),.multiply(),.round(), and comparison methods. Every operation returns a newMoneyinstance - immutability means I never have to worry about one calculation corrupting another.TaxRatehas both afromPercentage()and afromDecimal()factory, so the calling code reads naturally:TaxRate.fromPercentage(7.25, 'California State Sales Tax').JurisdictionCodeencodes the multi-level key: country → state → county → city. A US transaction in Houston, Texas becomesJurisdictionCode.us('TX', 'HARRIS', 'HOUSTON'). Static factories likeJurisdictionCode.eu('DE')andJurisdictionCode.uk()enforce region rules at construction time.
The Strategy Pattern (and Why It Clicked)
Each jurisdiction family has wildly different tax rules. US sales tax stacks (state + county + city surcharges). EU VAT is per-country with standard and reduced rates for food, books, etc. UK VAT has three tiers: standard 20%, reduced 5%, and zero-rated for children's clothing. Canada splits between HST provinces and GST+PST provinces.
I modeled this with the Strategy pattern: a single ITaxStrategy interface with a calculate(context) method, and four concrete implementations:
USSalesTaxStrategy- iterates through state, county, and city rates, stacking them into a breakdown arrayEUVATStrategy- looks up the country's standard/reduced rate, applies reduced rates for eligible product categories like foodUKVATStrategy- checks the product category againstUK_REDUCED_CATEGORIESandUK_ZERO_RATED_CATEGORIESsetsCAGSTStrategy- determines whether the province uses harmonized HST or separate GST+PST
A TaxStrategyFactory resolves the correct strategy from a JurisdictionCode. Adding a new jurisdiction means writing one new strategy class and registering it in the factory constructor - zero changes to existing code. That's the Open/Closed Principle in practice, not just in theory.
The Pipeline: Chain of Responsibility
Tax calculation isn't just "multiply by rate." You need exemption checks, surcharge handling, minimum tax floors, and audit logging - all cross-cutting concerns that shouldn't live inside the strategy itself.
I built a TaxRulePipeline that chains handlers in order:
ExemptionHandler- checkscontext.isExempt. If true, short-circuits with a zero-tax result.SurchargeHandler- adds flat or percentage surcharges to the running total (processing fees, environmental levies).MinimumTaxHandler- enforces a tax floor if business rules require it.
The pipeline has a fluent API: new TaxRulePipeline().addHandler(exemption).addHandler(surcharge), and its .execute() method reduces through all handlers, letting each one transform or short-circuit the result. This separates cross-cutting concerns from core tax logic beautifully.
The Application Layer: Use Cases as Orchestrators
The application layer contains three use cases - CalculateTaxUseCase, CalculateCommissionUseCase, and ProcessBatchTransactionUseCase - each annotated with NestJS @Injectable(). They don't contain business rules; they orchestrate:
- Build domain value objects from input DTOs
- Check cache (via
ICachePort) for identical recent requests - Resolve the strategy, build the pipeline, run the calculation
- Create the
Transactionaggregate, apply the result - Cache the output and write an audit log (fire-and-forget)
The cache key is deterministic: tax:{country}:{state}:{county}:{city}:{subtotal}:{currency}:{category}:{exempt}. Two identical requests within the TTL skip the entire calculation.
Commission: Three Models in One
The commission system supports three models - flat, percentage, and tiered - all handled by a single use case with a switch statement:
- Flat: fixed amount per transaction
- Percentage: rate × amount
- Tiered: progressive brackets, similar to income tax. The tiers sort by
minAmount, and I walk through each bracket subtracting the applicable amount until nothing remains.
The tiered calculation was the trickiest to get right. I had to handle edge cases like a transaction that spans three tiers, ensure Decimal.min(remaining, tierRange) picks the correct applicableAmount, and round the result per-tier rather than at the end.
What I'd Change
Looking back, I'd add event sourcing to the Transaction entity. Right now the aggregate snaps to its final state after .applyTax() and .markCalculated(). With event sourcing I could replay any calculation - invaluable for auditing.
I'd also move the batch processor to a dedicated worker service rather than running it in-process with BullMQ. For the learning exercise it works, but at scale you want job processing isolated from your API.
The project is open-source: github.com/ionutn0301/juris-logic