Three copies of the same exchange-rate client, on purpose
Why FocalHQ's backend services duplicate code instead of sharing it, the one test that decides what may live in a shared package, and the three places sharing tries to sneak back in.
Open services/invoice, services/expense and services/inbound-invoice in
FocalHQ’s backend (case study) and you will find the same
seventy lines three times: fetch a rate from api.frankfurter.app, fall back from the historical
endpoint to latest, cache by currency and date, multiply, round to two decimals. A reviewer’s first instinct is to extract it.
Every instinct trained on DRY says one implementation, one package, one place to fix the next bug.
We keep all three, and the rule that keeps them is deliberate: implementation details stay inside the service that owns them. Two services needing the same behaviour is not a reason to share code — it is a reason for each to have its own copy. The coupling a shared package creates costs more than the duplication it removes. One service’s need to change shared code becomes every other service’s redeploy, every other service’s regression surface, and every other service’s release schedule. Seventy lines are cheap. A dependency edge between two independently deployed services is not.
Two refinements make the rule survive contact with an actual codebase.
The copies are allowed to diverge, and they do. This is the part that looks like sloppiness and
isn’t. The invoice copy throws when a rate cannot be resolved — a 502, with the currency and
date in the error details — because its callers issue documents, and a document with a silently
wrong total is worse than no document. The inbound-invoice copy returns null for exactly the
same failure, because its caller is the Suppliers dashboard: one unrecognised currency code on one
bill must not take the page down, so the use case reports which currencies it could not convert and
renders the rest. The expense copy isn’t a class at all — it is an inline function that builds a
rate map for a whole batch before converting, because that’s the shape its route needs. Same API,
three failure modes, each chosen by the service whose users live with it. A shared package could
serve all three only by growing a mode flag, and the flag is precisely where the coupling comes
back: now every service’s behaviour is a parameter that another team can change.
Every copy names the others. Duplication’s real failure mode isn’t the duplication — it’s the next reader, six months on, deleting two copies in the name of cleanup. So each one carries a header saying what it is, that there are others, and why this one differs:
One of three Frankfurter implementations in this repo (
services/expenseandservices/inbound-invoicehave their own). Deliberate — services stay independent rather than sharing an FX package. Do not extract this; a fourth service copies rather than imports. This one THROWS on an unresolvable rate, because its callers issue documents where a missing rate must stop the operation.
That comment is the load-bearing part of the design. It is also honest about the cost: a bug in the fallback logic has to be fixed three times, and the comment is what makes the other two findable.
That’s the easy 90%. The rest is defending the boundary, because sharing re-enters through three doors.
1. Rules that must agree — the one genuine exception
There is a category where duplication is not acceptable, and the test that identifies it is short: what happens when the copies drift?
For the FX client, drift is loud and local — each service keeps behaving the way its own callers
need. For supplier identity, drift is silent and wrong. services/company owns suppliers and
precomputes nameKeys and vatKey onto each record at write time; services/inbound-invoice
normalises an incoming bill’s supplier block and matches against those keys. Both must strip
Romanian legal-form noise identically, so that S.C. OMV Petrom S.A. and OMV PETROM SA collapse
to the same string, and both must strip a RO prefix from a VAT code identically. If the two
implementations diverge by one character class, bills stop matching suppliers that are plainly the
same company — with no error anywhere. The symptom reads as the matcher is bad, not as two
functions diverged, and it can run for months.
So those functions live in a small domain package: pure, no I/O, no framework, nothing to mock.
That package and the auth package are the only shared ones, and the list is closed. Adding to it
is a decision someone makes explicitly, not a refactoring conclusion — the qualification is silent
cross-service failure, not similarity.
2. Inside a service, where the rule does not apply
“Services stay independent” is not a licence to copy-paste within one. Inside a single boundary there is one owner, one deploy and one release — there is no independence to protect, so a rule gets resolved once.
services/inbound-invoice learned this the expensive way. Five places had grown their own answer to
“what does this bill actually say”, and the answers had drifted — which matters because on an
inbound bill the authoritative dates are the "YYYY-MM-DD" strings inside the extracted payload,
not the root epoch columns the extraction model computed for the same fields. One copy parsed
strictly and anchored at UTC midnight; another ran the string through Number() first, which
quietly reads "20260306" as a 1970 epoch. That is one normalization module and three bug tickets.
Even there, divergence stays legal when it’s a decision. The v2 dashboard use cases keep their own
resolvers that return display defaults — 'Unknown supplier', 0, 'RON' — because a hole in a
total is worse than a guess; the normalization module returns null, because a manually uploaded
bill has no payload until extraction finishes and the web must distinguish “not known yet” from
“genuinely zero”. Same rules, different answer to the missing case, and a comment at each end saying
so.
3. The database and the wire
The third door is the one that doesn’t look like code sharing. Eight services share one PostgreSQL
instance with one schema per service, and cross-schema access is SELECT-only. That restriction
is the same rule wearing SQL syntax: a cross-schema write would let one service’s migration break
another’s invariants, which is a shared package with worse ergonomics and no type checking. Reads
are a snapshot you can survive; writes are ownership.
The API contract is the same story at the top of the stack. Existing v1 routes and their DTOs are frozen — no changes, not even additive optional fields — and any new capability ships as a v2 endpoint beside them, because iOS and the web consume v1 and freezing it is what guarantees nothing breaks. The temptation, when the web and a service both need to know an invoice’s shape, is a shared types package. That is coupling by another name: it makes a client redeploy a consequence of a server refactor. The gateway route is the contract, and it is versioned precisely so the two sides can move apart.
What it adds up to
Three copies of an exchange-rate client, each with the failure mode its callers need and a comment naming the other two. Two shared packages, on a closed list, holding only rules whose disagreement would fail silently. No copy-paste inside a service boundary. Schema-per-service with read-only crossings, and a frozen v1 with new work landing in v2.
There is nothing clever in any of it, and — as with most rules worth keeping — that is exactly why it holds. The cost is measurable and small: seventy lines times three, one bug fixed in three places. The cost avoided is a graph of services that must be released together, which is not measurable at all until the day you need to ship one of them alone.