Skip to main content

Data Model

Every persisted and on-the-wire identifier in Nexa is a URN. URNs are structural — the partner and live status are part of the identifier itself, not side fields. That single rule eliminates a category of bugs that plagues platforms with opaque numeric IDs: you never need an if/else chain to figure out which adapter to call, and you never lose track of whether r-8842 is the Nexa-side ID or a partner-side locator.

URN format

urn:<entity>:<id>[:vendor:<vendor>][:status:<status>]
  • <entity> — the entity type (case, sub-case, pnr, reservation, passenger, …)
  • <id> — the entity-side identifier (Nexa-minted or partner-supplied)
  • vendor:<vendor> (optional) — the issuer when the ID comes from outside Nexa (amadeus, hotelbeds, pomelo, aeroapi)
  • status:<status> (optional) — live status when it's load-bearing for routing or display

URNs parse structurally: extracting the partner from a URN is a one-call helper, and the booking engine dispatches to the matching adapter without an if/else chain.

URN registry

URN typeExampleIssued byNotes
urn:caseurn:case:c-7f8e1Case orchestratorThe macro disruption.
urn:sub-caseurn:sub-case:sc-91a4Case orchestratorOne PNR's saga unit.
urn:flighturn:flight:LA500@20260505:vendor:aeroapiFlight predictorJoins to the predictor's snapshot.
urn:airlineurn:airline:LANplatform registryTenant identity.
urn:airporturn:airport:SCLplatform registryIATA-coded.
urn:passengerurn:passenger:p-1124:vendor:latamAirline adapterReference only — toxic PII stays at the airline.
urn:pnrurn:pnr:XYZ123:vendor:amadeusAirline PSSPartner-tagged — the same locator string can exist across partners.
urn:offerurn:offer:8842Booking engineEphemeral search-result handle; the soft-hold key.
urn:hotelurn:hotel:SCL1234:vendor:amadeusBooking adapterPartner-tagged for adapter routing.
urn:reservationurn:reservation:r-8842Booking engineStable Nexa-side; minted before partner call so it survives retries.
urn:reservation (with status)urn:reservation:AMAD-1A-99XX:vendor:amadeus:status:confirmedPartnerPartner confirmation echoed back into a URN.
urn:hold-attempturn:hold-attempt:h-a91Booking enginePer-acquisition; one per soft-hold attempt.
urn:issued-cardurn:issued-card:ic-44b1WalletTied to (caseUrn, groupId).
urn:transferurn:transfer:t-3301:vendor:uberTransportGround transport.
urn:userurn:user:u-7Platform identityOperator / admin / finance.
urn:correlationurn:correlation:<w3c-trace-id>ObservabilityW3C traceparent header rewritten as a URN; flows through every event envelope.
urn:compensation-dead-letterurn:compensation-dead-letter:cdl-118Case orchestratorPermanent saga-rollback failures.

Cross-collection references are URN-keyed

Every cross-aggregate reference is on urn, never on a storage primary key. The internal _id on any record is a storage-only primitive; no other aggregate ever references it.

// sub-cases reference cases by URN
const subCases = await store.subCases.find({ caseUrn: 'urn:case:c-7f8e1' });

// reservations reference sub-cases by URN
const reservation = await store.reservations.findOne({ subCaseUrn: 'urn:sub-case:sc-91a4' });

This convention has two practical benefits:

  1. Events carry URNs, not storage IDs. Every event payload references an aggregate by URN. Consumers in any domain can resolve it without knowing the producer's storage layout.
  2. URNs survive denormalization. When the case orchestrator publishes to the read-side snapshot, the URN goes through unchanged. The passenger API can correlate a snapshot record to the live operational case without an extra lookup.

Tenant identity

Every tenant-scoped record carries an airlineUrn field, denormalized from the parent case if necessary. This is what the application-level tenant guard filters on, what the workflow topic prefix derives from, and what cost-attribution labels stamp onto the deployable. A single missing airlineUrn field is a tenant-isolation bug.

Aggregates and ownership

AggregateOwner domainCarries airlineUrn directlyNotes
CaseCase orchestratoryesDenormalized from manifest.
Sub-caseCase orchestratorinherited via caseUrnPer-PNR.
PNR referenceAirline adapter (cache only)yesZero-persistence; refreshed from PSS on demand.
PolicyPoliciesyesScoped to (airlineUrn, airportUrn?, countryCode?).
ReservationBookingyesLinked to subCaseUrn.
Issued cardWalletyesLinked to (caseUrn, groupId).
Workflow recordproducer domainyesOne per producer; durable.
Audit rowAudityesAppend-only; never mutated.
Compensation dead-letterCase orchestratoryesPermanent forensic record.

Toxic PII boundary

Nexa stores reference URNs to passengers, not the passenger documents themselves. Passport / DNI / national ID documents stay in the airline's system of record. The passenger sub-document on a sub-case carries:

  • passengerUrns: string[] — references back to the airline.
  • contact: { email, phone?, language } — minimum required to deliver the voucher.
  • cabinClass, loyaltyTier, optional specialNeeds flags — minimum required to match policy.

Nothing else. If a regulator audits Nexa for a deletion request, the cascade re-targets the airline; if Nexa's read-side snapshot is breached, no usable passport data is in the blast radius.

Card data is similarly bounded — Nexa stores Pomelo card URNs and webhook-delivered transaction events, never PAN or CVV. The PAN/CVV reveal in the passenger PWA is an iframe served directly by Pomelo's PCI-compliant infrastructure; Nexa never sees it.

Schema versioning and concurrent writes

Every aggregate root carries a version number that is checked on every update. If a concurrent writer already moved the aggregate forward, the second write fails fast, the caller reloads, and the operator UI surfaces "this case was just updated." There is no "last writer wins" anywhere in the platform.

For schema changes, additive migrations land alongside the code change. Rolling deploys mean both versions of the schema run simultaneously for several minutes; every read path tolerates absent fields, and every write path lands the new field whether the read path needs it or not. Destructive migrations (column drops, type changes) are never co-deployed with code that references the change — they ship in a separate release after every replica has been bumped.

The durable workflow record

The single most important data-plane invariant in Nexa: state changes and outbound events are written together in a single durable step. Domain state changes do not "fire and forget" their next event — the next event is committed atomically with the state change.

If the platform crashes between writing the state and dispatching the next step, the durable record means the next step is dispatched on recovery. The "we updated the database but lost the next step" failure mode is structurally impossible.

The durable record is insert-only and tracked by a resume token; there is no application-managed dispatched flag, and there is no polling cron. Records age out after a configured retention window; the long-term audit trail lives in the audit log.

Observability identifiers

Every request and event carries:

  • traceparent — W3C trace context, unmodified across HTTP and event boundaries.
  • tracestate — vendor-specific trace context (optional).
  • correlationUrnurn:correlation:<trace-id> for human-readable joins across logs, metrics, traces, and audit rows.

Log lines and audit rows always carry the correlation URN; an operator can paste it into the platform dashboard and see every step of a saga end-to-end.

Where to next

Was this helpful?