Architecture of a ticket-search engine: three GDS, rail, and an itinerary changed mid-journey
An employee needs to fly to one city, take a train to another, and come back the same way. One trip, tickets from different booking systems. And then, already on the road, they message you on Telegram: "plans changed, we're flying somewhere else, rebook it."
The three air systems (Amadeus, Sabre, Travelport) are historically incompatible: different XML dialects of the very same notion of a flight, grown out of 1960s–80s mainframes, each with its own quirks and fields the others don't have. Plus a separate rail booking system with no connection to any of them: its own format, its own seat-and-class logic, nothing structurally in common with the airline GDS. Querying all of that in parallel, reconciling differently-shaped responses into one, and assembling a valid itinerary by connections is already a task no pile of ifs and hand-written mappers is going to survive.
And then the person, on the road, changes one leg of the trip. One flight has already happened, off limits. One is still ahead, up for replacement. And the trip isn't the "there-and-back" template but any length and composition: two connections today, four tomorrow, an extra flight spliced in mid-journey the day after.
This isn't "call an API and show a list." It's an architecture with three load-bearing axes, and usually only one gets built. Let's take each apart by mechanism, with code, and honestly draw the line where the GDS's job ends and the engine's begins.
The three axes of the problem
- The shape of the data. An itinerary isn't an
outbound/returnpair, it's a tree of heterogeneous legs (plane ≠ train) of variable length that changes in the middle of its own life. Where do you put that so a new transport type doesn't mean a migration, and "trips with an untraveled rail leg in cancellation" stays a query in the database rather than a load-everything-into-memory scan. - The flow. Fetching from four systems at once, normalizing four formats into one, building connections, the "changeable / frozen" branch. Either it's a readable DSL in the vocabulary of integration patterns (Scatter-Gather, Normalizer, Content-Based Router: the Apache Camel dictionary), or it's code even its author can't reconstruct six months on.
- Reconciliation. Reissuing on the road, when the itinerary is assembled from independent tickets from different sources, none of which sees the others. That isn't a reissue inside one PNR, it's a distributed transaction over systems that don't know about each other.
The flow axis lives in redb.Route (Apache Camel for .NET), the shape axis in redb's typed storage, and the reconciliation axis at the seam between them. In order.
The shape axis: a canonical model and an itinerary tree
The first architectural decision is made before a single line of integration: adopt a Canonical Data Model and never let source dialects past the boundary. Amadeus, Sabre, Travelport and rail all speak differently, but inside the system a single Segment and a single Offer circulate. Everything past the adapter (connection building, pricing, storage, the customer portal) works only with the canonical form.
public sealed class Segment // one leg, regardless of source
{
public TransportKind Kind { get; set; } // Flight | Rail
public string From { get; set; } = ""; // IATA / station of departure
public string To { get; set; } = "";
public DateTimeOffset DepartAt { get; set; }
public DateTimeOffset ArriveAt { get; set; }
public string Source { get; set; } = ""; // amadeus | sabre | travelport | rail
public string SourceRef { get; set; } = ""; // PNR / locator in the source system
public FareInfo Fare { get; set; } = new();
public bool Departed { get; set; } // leg already traveled: frozen
}
An itinerary isn't a flat list, it's a tree: a trip branches into "there" and "back," multi-city gives several branches, and within a branch the legs run in order. redb stores trees natively, so Itinerary lands exactly the way it looks in real life:
public sealed class Itinerary
{
public string PassengerId { get; set; } = "";
public ItineraryStatus Status { get; set; } // Searching → Offered → Booked → InTransit → Reissuing
public List<Leg> Legs { get; set; } = new(); // ordered, count not fixed
}
public abstract class Leg { public bool Departed { get; set; } public string Source { get; set; } = ""; }
public sealed class FlightLeg : Leg { public string Pnr = ""; public string FareBasis = ""; public string Cabin = ""; }
public sealed class RailLeg : Leg { public string Carriage = ""; public string SeatClass = ""; }
In a relational model this is the sore spot. A variable-length list of heterogeneous legs gets laid out either as a wide table with nullable columns for every transport type at once (half of them always empty), or as a formless key/value mush, or with a migration for every new kind of transport. redb stores Itinerary as a typed object: legs are props, FlightLeg and RailLeg are persisted with their own types, RTTI is preserved. Add a BusLeg tomorrow, no migration, the schema extended itself.
Separately, on how this speeds up development itself. In a domain like this the model doesn't settle in one pass: today FlightLeg gets a Cabin, tomorrow a BusLeg appears, the day after a leg grows a sub-structure for baggage. In a classic schema every such step is a migration: write it, review it, run it across every environment, don't forget the rollback, catch the drifted staging database. Here there are no migrations at all: the class is the schema. You change the domain model in C#, SaveAsync lays out the new shape itself, old objects keep reading. While the structure of a trip is still in motion, that removes the main brake on iteration: you edit a type and move on, instead of servicing a migration conveyor for every guess.
The operational key: queries stay server-side. A dispatcher needs "every trip with an untraveled rail leg in a carrier-cancellation state":
var stuck = await redb.Query<Itinerary>()
.Where(i => i.Status == ItineraryStatus.InTransit
&& i.Legs.Any(l => l is RailLeg && !l.Departed && l.Source == "rail"))
.ToListAsync();
This goes to your PostgreSQL or MS SQL as SQL, rather than pulling all trips into memory for a C# filter. The storage lives in the database you already run in prod, next to the rest of your tables, with real foreign keys and shared transactions. The object that assembled the trip and the object that shows it in the portal are one type, from backend to browser. Saving is one line; an update goes to the database as a diff (changed fields only):
await redb.SaveAsync(itinerary); // insert or update, auto-diff by _hash
The flow axis: Scatter-Gather, Normalizer, partial results
Now the fetch. A request goes to every source in parallel, and the responses are reconciled into one. In the canon that's Scatter-Gather, and in redb.Route it's a first-class DSL element rather than a hand-rolled Task.WhenAll with reconciliation and failure handling bolted on:
From("direct://search")
.ScatterGather()
.To("direct://src-amadeus")
.To("direct://src-sabre")
.To("direct://src-travelport")
.To("direct://src-rail")
.Timeout(TimeSpan.FromSeconds(8)) // a slow GDS doesn't sink the whole search
.AggregationStrategy(new OfferMerge()) // differently-shaped responses → one set of Offers
.To("direct://build-itineraries");
Each src-* is a Normalizer: a separate route that knows its system's dialect and is obliged to return the canon. It calls the source over SOAP/HTTP through the redb.Route.Http connector, parses the response with its own Unmarshal, and lays out the fields via the string expression engine (XPath/JSONPath compiled once when the route is built):
From("direct://src-amadeus")
.OnException<SourceUnavailable>().To("direct://dead-offers").MarkHandled().End() // source down → DLQ, don't sink the Gather
.To("https://amadeus/air?wsdl-style=soap") // call the source
.Unmarshal(typeof(AmadeusSoap)) // Amadeus XML dialect → intermediate object
.Process(new AmadeusToCanonical()) // → List<Segment> in the canon
.WireTap("direct://audit-raw"); // raw response to audit (dispute analysis later)
Two things where a naive implementation usually falls apart:
- Partial results. Amadeus answered, Travelport hung. The
Timeouton the Gather closes the window,OnExceptionsent the failed source to the DLQ, and the search completes with what arrived instead of failing whole or waiting on the slowest. The user sees offers, not a spinner. - Normalization at the boundary.
AmadeusToCanonical,SabreToCanonical,RailToCanonicalare the only place in the system where a source dialect lives. A fifth source is one more Normalizer route and one more.To(...)in the Gather, not a rewrite of the aggregator and not a newifin twenty places.
OfferMerge (the AggregationStrategy) merges the canonical segments from every source into one pool. After it, the system no longer knows or cares who sent a segment: from here on only Segment and Offer are in play.
Connection building: where the GDS ends and the engine begins
Assembling a valid itinerary from a pool of segments isn't sorting. Segments connect if one's arrival airport matches the next one's departure and there's a legal connection time between them (MCT, minimum connection time), and legs of different transport types connect by station/airport with slack for the transfer. This is where the line runs that's worth naming honestly, or the task looks made up.
Amadeus has long had Amadeus Ticket Changer (ATC): it automatically reprices on voluntary and involuntary reissue under Category 31/33 ATPCO rules and accounts for carrier policy. That's a solved problem, and in practice it's where you start: first you look at what each GDS offers on its own, because those are already optimal, fare-validated combinations (through-fares), and you evaluate them first.
The problem starts at the second step. When self-connect is cheaper (sometimes a single GDS's through-fare is cheaper, sometimes a combination across sources is), the itinerary is assembled from independent tickets, and no GDS sees the other two. ATC itself warns of its boundary: tickets issued through other GDSs are not guaranteed. The engine takes exactly what no single source sees: it builds the self-connect across systems, compares it with the through-fare, and ranks by what matters to the customer (a guaranteed connection from one source, or savings at the price of transfer risk). Through-fare combinations arrive pre-stitched from the source; self-connect the engine stitches itself:
From("direct://build-itineraries")
.Process(new ConnectionBuilder(minConnectionRules)) // Segment pool → candidate itineraries
.Split(e => ((IReadOnlyList<Itinerary>)e.In.Body!)) // each candidate separately
.Process(new FeasibilityCheck()) // MCT, terminal change, overnight between legs
.Process(new RankByPolicy()) // through-fare vs self-connect per client policy
.End()
.To("direct://present");
The reconciliation axis: reissue on the road as a saga
The hardest part is a change in transit. "Rebook it" arrives, and in an itinerary of independent tickets some legs are frozen (already traveled) and some are up for replacement. A reissue inside one PNR doesn't work here: the tickets are in different systems, and replacing one leg is a distributed transaction over sources that don't know about each other. So you need a saga with compensation: each swappable leg is reissued in its own source, and if one step fails, you roll back the ones that went through.
And the entry point is Telegram, which means at-least-once: the same "rebook" can arrive twice (a client retry, a duplicate webhook). So an Idempotent Consumer at the entrance, or one request reissues the trip twice.
From("telegram://reissue")
.IdempotentConsumer(e => e.In.GetHeader<string>("update_id")) // a duplicate webhook won't reissue twice
.Enrich("direct://load-itinerary") // pull the current tree from redb
.Saga()
.Split(e => ((Itinerary)e.In.Body!).Legs.Where(l => !l.Departed)) // future legs only
.Choice()
.When(e => Reprice((Leg)e.In.Body!) is { Cheaper: true })
.To("direct://swap-leg") // reissue in that leg's source
.Otherwise()
.To("direct://keep-leg")
.EndChoice()
.End()
.Compensation("direct://rollback-swaps") // one leg failed → roll back the rest
.End()
.To("direct://save-and-notify");
Freezing the past isn't a wish, it's an invariant: a Departed leg is immutable, and the saga doesn't even take it into the Split. After the reissue the itinerary tree is saved with one SaveAsync, and the auto-diff writes only the changed nodes to the database, not the whole tree again. Optimistic locking by _hash catches the race if a background price refresh collides with a manual reissue.
The fourth axis people remember later: pricing and 1C
Search isn't the whole job. Computing the price against the fare grid, reconciling public and internal (private) fares, applying the discount system and tying it all to 1C is a separate task, and just as differently-shaped as fetching from the GDS. The source fare, the agency markup, the client's corporate discount converge in one place, and that's again a Content-Based Router over the same expression engine, not spaghetti of nested ifs:
From("direct://price")
.Choice()
.When(e => Client(e).HasPrivateFares).Process(new ApplyPrivateFare())
.Otherwise().Process(new ApplyPublicFare())
.EndChoice()
.Process(new ApplyAgencyMarkup())
.Process(new ApplyCorporateDiscount())
.To("direct://sync-1c"); // push to 1C over the same connector (HTTP or a queue)
The pricing result lands on the same canonical Offer next to the itinerary, and the push to 1C is a redb.Route connector (HTTP or a queue, depending on how your exchange is set up), not a hand-written client. One offer model travels from the GDS through pricing to a document in 1C without being reassembled from a foreign format into your own at every seam.
What the coherence buys: observability and audit for free
When all four axes sit on one toolkit, the cross-cutting things appear on their own. One distributed trace covers the search from direct://search through all four sources to the save: you can see which GDS was slow and why an offer came out the way it did. A WireTap in each Normalizer files the raw source response to an audit store, so analyzing a "why did we show the customer this fare" dispute is reading a record, not archaeology through logs. Endpoint statistics (how many calls to each source, the timeout rate, errors) show up in the redb.Tsak dashboard next to the rest of the routes. None of this had to be built in: these are properties of the DSL, not separate scaffolding per source.
Putting it together
The two main axes usually get solved separately and hand-sewn together in the middle, and that seam of manual mapping is the thing nobody wants to touch six months on. The reference architecture here is that the axes are covered by one coherent set, and the canonical model separates the source dialects from everything else:
- Shape is a typed tree in redb: a trip of any length made of heterogeneous legs lands as an object, RTTI is preserved, a new transport type needs no migration, and queries stay server-side SQL in your own database.
- Flow is redb.Route: Scatter-Gather with partial results, a Normalizer at each source's boundary, a Content-Based Router on connection building, retries and DLQ as DSL elements.
- Reconciliation is a saga with compensation and an Idempotent Consumer at the entrance: reissue over independent tickets with the traveled legs frozen.
- The outward seams (GDS, 1C) are connectors, not hand-written clients per dialect.
The architecture's boundary is honest: through-fare reissue inside one GDS is the GDS's own job (ATC, Category 31/33), and the engine doesn't duplicate it. The engine takes what no source sees on its own: the self-connect across systems and its rebuild on the road. That's the part where a coherent toolkit pays off over a seam in the middle.
The point of this whole layout: on the redb ecosystem an engine like this comes together simpler, more structured and more readable than out of a scatter of hand-written clients, ORM migrations and manual mapping. Each axis lands on a ready tool with its own vocabulary (EIP for the flow, a typed tree for the shape, a saga for reconciliation) rather than on yet another layer of glue. The architecture ends up in a language even someone who didn't write it can read, and development runs without a migration conveyor and without reassembling the model at every seam. The task stays hard in essence, the GDS haven't gone anywhere, but it stops being hard for no reason: the difficulty moves to where it's real (connections, fares, reconciling sources) rather than spreading through integration glue.
If you've done a GDS or rail integration, I'd be curious which axis broke for you. Usually it's one of the three, and almost always only one gets fixed.
More of my writing: redbase.app/articles, and on dev.to.