Skip to content
Kien

Decisions

Architecture decisions from the systems on this site — what was on the table, what was chosen, and what it cost. Including the one that was rolled back.

  1. Cross-call state rides in the identifier, not a server session

    2024holding
    Context
    Search, prebook and book are separate HTTP calls handled by whichever instance picks them up — yet prebook must know which supplier, contract and credentials the offer came from.
    On the table
    • A server-side session keyed by a token and expiring in Redis — the default everyone reaches for.
    • Re-deriving the context at prebook from the cached search results — a session by another name, with the same expiry and divergence problems.
    Decision
    A composite offer ID encoding `source · contract · hotel · uuid`, decoded at prebook to pick the right credentials.
    Consequence
    Nothing to expire, nothing to diverge across instances. The flip side: the ID's layout is now part of the public contract — clients hold it between calls, so what rides inside it can never change quietly.
  2. Exactly two offer patterns, not one shape per supplier

    2024holding
    Context
    Every wholesaler models rooms, rates and cancellation differently, yet every module must answer one API contract.
    On the table
    • Let each module return whatever shape its supplier speaks and normalise in the callers — the default that turns every consumer into a translator.
    • One universal pattern — which cannot hold, because some suppliers return a single token booking N rooms while others price each room independently.
    Decision
    Two documented patterns and no third — pattern A where the supplier returns one token booking N rooms, pattern B where the offer list is the materialised matrix of valid room-rate combinations.
    Consequence
    Every new integration starts by classifying the supplier as A or B instead of designing a shape. The cost is volume — pattern B spells out every valid combination as its own offer, and offers are what a search must carry: one search once held roughly 10 million of them and wrote 1.2 GB into Redis before the storage layout was fixed.
  3. A graph for hotel identity, not a pairwise join table

    2022holding
    Context
    The same building carries a different ID at every supplier, and identity is transitive — if A matches B and B matches C, A matches C. Resolve one wrong and an entire search region returns zero hotels.
    On the table
    • Pairwise join tables — the default, where every transitive lookup becomes a self-join of unknown depth and every new supplier deepens it.
    Decision
    Keep the row store for the data, but run identity lookups over a Neo4j `SAME_AS` graph with variable-depth traversal.
    Consequence
    Still holding since 2022 — the ~1.9M-row identity table and the 2026 bridging campaigns both sit on it. The price is a second store that must be kept in sync with the rows it mirrors.
  4. White-label differences pushed into data, not forks

    2024holding
    Context
    The same supplier integration is resold under many brands, each with its own credentials and its own hotel-ID column in the catalogue.
    On the table
    • A module copy per brand — the default, which multiplies every bug fix by the number of brands.
    Decision
    One module per supplier; 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. Brands become aliases registered by code.
    Consequence
    75 supplier codes run on 31 modules. The flip side of pushing behaviour into data is that a wrong settings row is a production bug no code diff will ever show — it has to be caught by reading the data back.
  5. Snapshot the whole workflow tree at submit

    2026holding
    Context
    A multi-level approval flow configured differently per tenant — and the configuration keeps changing while requests are in flight.
    On the table
    • Read the live configuration at every level — the default, until an edit mid-flight rewrites who was supposed to approve a request that already exists.
    Decision
    On submit, snapshot the whole workflow tree including per-level deadlines; the request answers to its snapshot for the rest of its life.
    Consequence
    Later configuration edits cannot rewrite history — and cannot repair it either: an in-flight request keeps its snapshot even when the edit was the correction. That trade is deliberate.
  6. Delayed jobs as pure clocks, never the source of truth

    2026holding
    Context
    Per-level SLA reminders run on a delayed-job queue while the levels they watch get approved, reassigned and escalated underneath them.
    On the table
    • Cancel and reschedule the job on every state change — the default, which makes the queue a second source of truth that must never miss an update.
    Decision
    The job is only a clock: when it fires, the worker re-reads the database and does nothing if the level was already handled. Job IDs are deterministic, so rescheduling cannot duplicate mail.
    Consequence
    No job ever needs cancelling, and Redis being down loses reminders but never blocks approving. The price is that every firing pays a database read, even the ones that turn out to be no-ops.
  7. Name-and-geo similarity as sufficient evidence to write identity

    2026superseded
    Context
    Hotel-identity campaigns needed a rule for when a candidate match may be written. The bridging technique counts independent suppliers agreeing as the confidence score — but one campaign wrote on name similarity and coordinates alone.
    On the table
    • Write above a name-similarity and distance threshold, without a supplier vouching — more coverage, weaker evidence.
    • Write only mappings at least one independent supplier confirms — less coverage, every row carries a witness.
    Decision
    The threshold-only rule was used for one 11,953-row campaign.
    Consequence
    The team later decided to keep only supplier-evidenced mappings — all 11,953 rows were deliberately rolled back, row by row, with the post-rollback state verified at 286,105 non-null rows. Superseded by the supplier-evidenced-only policy, which is what every campaign since has run under.
  8. A zero-dependency alerting client

    2026holding
    Context
    One alerting package is installed in a dozen services; anything it pulls in, all twelve carry forever, including into their audit reports.
    On the table
    • Pull in an HTTP client and a per-channel SDK — the default that saves an afternoon and costs a permanent transitive tree.
    Decision
    An empty `dependencies` block: roughly 180 lines over `node:http`/`node:https`, exposing an axios-shaped response so call sites read as they would have.
    Consequence
    Nothing transitive to audit — but the client is now code the package must test itself, hostile input included. And one default from the same package is on record as the wrong call: `strictMode` ships off to avoid breaking upgrades, when the safe mode should be the one you get by not thinking about it.

How I run a design review

The questions are fingerprinted to this domain — each one is on the list because its absence has already cost something written up elsewhere on this site.

Questions that must have an answer:

  • What happens when a supplier returns HTTP 200 with an error body? A swallowed failure here is how three features once died silently with zero errors anywhere.
  • Is book idempotent under client retry? A timeout plus a retry must not become two reservations.
  • Which module owns this state, and who is allowed to read it? If the answer is "everyone", the modules are welded together within six months.
  • What is the blast-radius label of the rollout command — read-only, writes to production data, or deploys? The label goes where the eye lands first, not three paragraphs in.
  • How does this go live across N repos — what is the merge order, and which MR pairs must land in the same beat? A repo missed is an undercharge that throws no error.
  • Which figures in the document are measured, and which are estimates? Every number carries its label; an estimate mistaken for a measurement is how a document loses trust.
  • What does the client see while this is failing — an error, or an empty result that looks like an answer?

Entry criteria — the review does not start until:

  • the problem is written down, with the numbers we actually have, each labelled measured or estimated;
  • the failure mode is named — what the user sees when it breaks, not just what throws;
  • the alternatives are real ones stated with their constraints, not strawmen set up to lose.

Exit criteria — the review is not done until:

  • every write path runs dry-run → printed plan → confirmation → apply → read-back verification, and the tool enforces it rather than the human remembering to;
  • everything that leaves the process — logs, alerts, analytics — has an allow-list, because egress is a boundary;
  • a "what I am not certain of" section exists; it costs a paragraph and is the difference between a document a colleague can build on and one they must re-verify from scratch;
  • if the change spans repositories, the full list of repos is written and the merge order settled before anything ships.

There is no count of reviews run here, because none was measured — the checklist is the deliverable.

The integration landscape

Everything above sits on one aggregation layer: ~150 supplier codes on ~90 integrations, across 5 product lines. The map below is structural on purpose — axes, not a count matrix.

Axis Values
Product lines Hotel · Flight · Tour · Transfer · Car hire
Protocol families REST-JSON · SOAP-XML · push-cache · polled availability
Offer patterns A — one token books N rooms · B — the materialised matrix of valid room-rate combinations
Runtimes Node.js/TypeScript (the daily stack) · Python (FastAPI adapters) · Go (adapters)

Deliberately no per-line, per-protocol counts: those live in a supplier registry, and a number appears on this site only after it has actually been pulled and measured.

09/2026