The problem
Every wholesaler speaks a different protocol with its own hotel codes, room and rate model and cancellation grammar — yet all of them must answer one API contract.
Two further constraints: the same integration has to be resold under many brands, each with its own credentials and hotel-ID column; and two generations of the hotel stack run in production simultaneously, split across two repos, with most suppliers present in both.
The approach
Four decisions carry the design. Each one below is written the same way: what was on the table, what was chosen, what it costs.
Decision — one module per supplier, on a fixed contract.
On the table: a shared code path that branches per supplier feature — the
default everyone reaches for when the second wholesaler arrives, and the thing
that turns every protocol difference from the problem statement into another
if. Chosen: each supplier is one module with a fixed contract, registered
by code in a single registry. What it costs: behaviour is written once per
module, so a supplier fix lands module by module — and a change to the contract
itself touches all 31 modules at once.
Decision — two documented offer patterns, not one flow per supplier. On the table: letting each wholesaler keep its native room-and-rate model, with a bespoke flow wrapped around each one. Chosen: every supplier is classified into one of two documented offer patterns — pattern A where the supplier returns a single token booking N rooms, pattern B where each room's rate is independent and the offer list is the materialised matrix of valid combinations. What it costs: pattern B pays for its regularity up front — the matrix is materialised into the offer list rather than resolved later; and a supplier that fits neither pattern is a design conversation, not a quick patch.
Decision — cross-call state inside the identifier, not a server session.
On the table: server-side sessions, the default everyone reaches for — and the
thing that expires under a slow client and diverges across instances. Chosen: a
composite, versioned offer ID encoding source · contract · hotel · uuid,
decoded at prebook to pick the right credentials — nothing to expire, nothing to
diverge across instances. What it costs: the ID becomes a contract of its own —
it must stay decodable across versions, and anything a later call needs has to
fit inside it.
Decision — an absolute 30-second deadline over progress-fraction waiting.
Search fans out over a queue: one job per supplier, each guarded by a Redis lock,
workers writing into a shared cache hash plus a meta hash marking completion;
the HTTP layer aggregates whatever has landed and reports a progress fraction.
On the table: reporting that fraction until every supplier answers — which
hands the slowest supplier control over when every search ends, and leaves
clients polling forever. Chosen: an absolute 30-second deadline above the
fraction — past it, progress is reported as complete so clients stop polling.
What it costs: a supplier that answers late is simply absent from that result
set; its offers are dropped for that search rather than waited for.
That deadline is what makes each module its own failure domain:
Around the four decisions, two supporting moves. Brand differences are pushed into data: a sparse per-supplier settings table holding only the keys that differ from defaults, plus a map from supplier code to the hotel-ID column its catalogue lives in.
And the migration was explicitly not a big bang: the new line is the only one new code lands in, the old line is read-only, suppliers move one at a time — and it is written down that a supplier fix must be applied in both repos, because fixing one leaves the other broken.
The result
I run the release train as well as writing the code — promoting the shared integration branch to production across all 8 services, which means owning what goes out and when.
75 supplier codes now run on 31 modules through aliasing, mirrored between the search and booking services. Cross-cutting features ship in all five gateways on the same day.