Most working software has a system at the centre of it that everyone needs and nobody wants to touch. It predates the current team, its schema was designed for its own admin interface rather than for anyone reading it, and it is load-bearing enough that replacing it is a project nobody will fund.
The failure pattern around that system is predictable. Each new thing that needs its data — a sync job, a webhook consumer, a reporting service, someone's spreadsheet script — reaches directly into it. Each one independently works out authentication. Each one independently discovers that the "status" column holds a number whose meaning is documented in a constants file. Each one hardcodes that mapping. Then the platform changes, and you fix the same bug in six places, assuming you can find all six.
The fix is an old idea with a good name — Eric Evans called it an anti-corruption layer — and in practice it's one service in front of the old system that everything else talks to instead. I built one of these over a CS-Cart commerce platform, in Fastify and TypeScript, and this is what I'd tell someone starting the same job.
Model what consumers need, not what the platform stores
The strongest temptation is to make your service a thin proxy: same field names, same shapes, just over HTTP with a nicer auth story. It's fast to build and it is the whole mistake, because it exports the legacy schema through your clean boundary and now everyone downstream depends on the thing you were insulating them from.
Design the contract from the consumer's side. Ask what a consuming service actually needs to know about an order, and produce that. If internally an order's state lives across three tables and a serialized blob, that's your problem to solve once, inside the boundary, rather than a shape to pass along.
The practical test: if a field name in your API response only makes sense to someone who has read the legacy database schema, it doesn't belong in the response.
Do the unpleasant translation once, on purpose
Every old system has accumulated representational oddities. Booleans stored as 'Y' and 'N'. Money as a float, or as a string, or in a different currency depending on a column three tables away. Timestamps as strings in local time with no zone. Status as an integer with a legend somewhere.
All of that is now your job, and doing it in one place is the entire value of the exercise. Booleans become booleans. Money becomes integer minor units with an explicit currency — the same discipline I'd apply anywhere money is involved. Timestamps become ISO 8601 with an offset. Statuses become a documented string enum.
The rule that keeps this honest: the translation is not allowed to leak. The moment one endpoint passes through a raw platform value "just for now", consumers start depending on it, and you have built a second legacy system with better syntax.
Validate at the boundary, and derive the types from that
Your service reads from something that makes no guarantees. A TypeScript interface describing what you expect back is a comment the compiler happens to check against nothing at runtime — I've written about that gap in more detail, and it applies with more force here, because your consumers are trusting you specifically to have checked.
Parse and validate what comes out of the platform, then let the static type be inferred from the validator rather than declared alongside it. One schema, one source of truth, no way for the runtime check and the compile-time type to drift apart. Fastify makes this pleasant on the way out too — schemas on responses mean the contract you publish is enforced rather than described.
When validation fails, fail loudly and specifically. A malformed record from the platform is a real event that someone needs to see, and swallowing it produces the worst outcome available: a consumer that silently receives less data than it asked for and cannot tell.
Authentication and errors belong here, once
Two things every consumer would otherwise reimplement, both of which are easy to get subtly wrong.
Handle authentication to the legacy platform inside the service, and give consumers a credential that is yours — one you can scope, rotate and revoke without anyone touching the old system's user table. If a consuming service is compromised, you want to be able to cut it off in one place.
Then error semantics. Legacy platforms are famously creative here: HTTP 200 with an error message in the body, empty responses for missing records, an exception page when a parameter is wrong. Normalise it. A missing record is a 404. A bad request is a 400 with a machine-readable reason. A platform failure is a 502 — because it genuinely is a bad gateway, and saying so tells the consumer whether retrying is sensible.
Write it down, or you become the API
This is the part that determines whether the project actually pays off, and it's the part most likely to be cut.
If integrating requires a conversation with you, you haven't removed the bottleneck — you've moved it from the platform's internals to your own head, which is worse, because at least the platform's internals were readable at 2am. The goal is that another team can go from nothing to a working integration without asking anyone how it works.
That means request and response examples for every endpoint, the error cases and what each one means, and the enum values written out. Tests are part of this too: a test suite is documentation that cannot go stale, and it is how you find out that a platform update changed a payload shape before a consumer finds out for you.
Resist scope creep into business logic
Once the boundary exists it becomes the obvious place to put things. Someone needs an endpoint that also applies a discount rule. Someone needs one that sends an email. It's right there and it already has authentication.
Be strict early. This service translates and validates; it does not decide. Once business rules live in it, it stops being a boundary you can reason about and becomes a second application with its own opinions about the domain — and now there are two places where an order's rules are implemented, which is exactly the condition you set out to eliminate.
What you get
The visible win is that new integrations get built by whoever needs them, without a person who knows the platform internals sitting in the room.
The bigger one is quieter: platform-side changes stop being a scavenger hunt. When the old system alters a payload, one service breaks, its tests say so, and you fix it in one place. Every consumer keeps working against a contract that didn't move. That property is worth more than the time saved on any individual integration, and it's the thing you are actually buying.
It's also the only version of "we should replace that legacy system" that has ever worked in my experience — not a rewrite, but a boundary drawn around it, so that the replacement, if it ever comes, is a change behind an interface rather than a change to everything at once.