I got called in on this one during audit season, which is the worst possible time. The client β€” a Nepali accounting practice managing books for several small companies β€” had an outsourced Oracle Forms 6i system that had quietly worked for years. The ledgers balanced, nobody had lost data, and the accountant who ran it day to day genuinely liked it. But it was built for a world where the Inland Revenue Department wasn't pushing everyone onto e-invoicing, and where the Finance Act hadn't just changed VAT and TDS rates again. It had, and the developer who'd built the original system wasn't answering calls anymore.

The plan I drew, then threw out

My first pass at the architecture was the one you'd expect if you'd been reading the same conference talks I had. Split billing into its own service, tax into another, payroll into a third, wire it all together with events. I actually sketched it out on a whiteboard before I traced through what a single sale actually touches: the ledger (debit the debtor, credit sales, credit output VAT), the stock ledger (cost of goods at FIFO or weighted average), a TDS withholding record if the buyer has to withhold tax, and a queue entry for the government sync. All four have to land together, or none of them should. Split that across services and you're now building sagas and compensating transactions to fake atomicity you used to get for free. And in an audited accounting system, "compensating entry" isn't a technical footnote β€” it's a real journal entry an auditor is going to point at and ask you to justify. I erased the whiteboard and went back to one process, one database, one transaction, with module boundaries enforced in code instead of over the network.

Keeping the ledger honest

The one thing worth salvaging from the old system was its schema. `comp_code`, `sldg_code`, `voucher_no`, `dr_cr_flag` β€” ugly names, but the double-entry structure underneath had survived years of real use without falling over. What it never had was any protection against a future developer (me, six months from now, in a hurry) writing to it carelessly. So every module now has to go through one door:

// Billing, Payroll, Treasury β€” everything posts through here.
ledgerPostingService.post(new PostingRequest(entries, fiscalPeriod));

// This will not compile:
ledgerRepository.save(myOwnJournalRow);

That second line isn't a style guideline someone forgot to enforce β€” there's an ArchUnit rule, `no_module_writes_ledger_directly`, that fails the build if any module reaches the ledger table through its own repository. Balanced-entry checks, fiscal-period locking, the audit trail β€” all of it lives behind that one door, which means there's exactly one place to get those right instead of hoping every module remembers.

The bug that taught me rates aren't constants

I found a hardcoded `0.13` VAT rate buried in a calculation, and about two weeks later the Finance Act changed the VAT rate. That's the kind of coincidence that reorganizes how you think. Every statutory number now lives as an effective-dated row instead of a constant anywhere in the code:

tax_rate_version(rate_type, code, effective_from_bs, effective_to_bs, rate, statutory_ref)

Backdate an invoice into last fiscal year and it resolves last year's rate automatically. A rate change in Shrawan becomes a row insert, not a deploy β€” which matters more than it sounds like it should, because the people who'd need to request that deploy are not developers, and the two-week wait was exactly what broke things before. The same logic ended up covering the BS fiscal calendar too: the system figures out the current Nepali fiscal year from today's Gregorian date instead of making someone pick it from a dropdown, reusing month-length tables the old system had already been carrying around for years.

The one place async actually belonged

Not everything wanted to live inside the transaction, and I stopped fighting the one thing that genuinely didn't: CBMS/VCTS, the government e-invoice gateway, is a network call into a system I have no control over. That got a durable database queue and a worker that retries, instead of being crammed into the same transaction as the sale. No message broker, just a table β€” which turned out to matter for a reason I hadn't planned for: a branch with a dead internet connection can keep selling, and the queue catches up once the connection comes back.

What I'd say to myself before starting over

None of this is exciting architecture. I didn't need five independently scalable services; I needed a ledger a tired developer couldn't accidentally corrupt at 11pm, and a tax engine that survives a government rate change without anyone touching code. The decisions that actually mattered weren't the ones that would've looked good on a system design diagram β€” they were the ones I only found by asking what an auditor would actually ask about.