Unifying Feature Entitlements Across a Commerce Platform Without Breaking It Mid-Flight
Entitlements are the load-bearing glue between a purchase and what a customer can actually do. Here is how we consolidated two generations of that glue into one authoritative store, migrated hundreds of thousands of historical records idempotently, and never once risked a paid order without its grant.
By LegalZoom Engineering · · 8 min read
Start with one rule and let everything else fall out of it: a paid order and its entitlement either both exist, or neither does. There is no third state. A customer who has paid is owed their grant, and a grant that exists must trace back to a real order. Hold that line and most of the design decisions in this migration stop being decisions. They become consequences.
We needed that clarity because feature entitlements at LegalZoom are the glue that ties a purchase to what a customer can actually do: which subscriptions are active, what the dashboard reveals, which post-purchase features unlock. Entitlements are invisible until they’re wrong. A customer pays and the dashboard stays locked. A feature gets granted twice. A subscription reads active in one place and cancelled in another. Nobody notices entitlements when they work, and they break trust at the exact moment a customer has just handed you their money. So the invariant is not a nicety. It is the whole product surface, restated as a constraint.
Why the invariant was at risk
For a long time, entitlements lived in two different worlds, and that split was where the invariant could quietly fail.
The catalog of what each product grants, the rules and the shape of what a purchase entitles you to, lived in the relational PostgreSQL database of our newer commerce platform. The actual instances, “this specific customer has this specific grant,” lived in a separate, older entitlements service backed by DynamoDB. Checkout and fulfillment had to hop a service boundary to reconcile what a customer bought against what they were owed. No single view said “here is the order, and here are the grants it produced.” The legacy store also had no native concept of things newer products increasingly needed: usage tracking, periodic quotas, expiration. A product that wanted “10 uses per month” or “expires 30 days after purchase” had no way to say so.
Two stores, no shared transaction, and a service boundary between an order and its grant is exactly the shape that lets a paid order exist without its entitlement. Making one database authoritative was the goal. The hard part was never the schema. It was performing the cutover on a fully live system, with hundreds of thousands of historical records whose legacy shape didn’t cleanly map back to the new catalog, without ever landing a customer in the forbidden state.
Making “both or neither” structural
If the order and the entitlement must commit together, then they should be written together. We made the commerce platform’s relational database the source of truth and created entitlements synchronously, inside the same database transaction that saves the order. There is no window where payment has succeeded but the grant hasn’t landed. They commit together or roll back together. The invariant stops being something we remember to enforce and becomes a property of the schema.
That choice dictated how we modeled the data. We kept three things deliberately separate:
Lightweight instances. An entitlement instance row stores only instance-specific state: a reference to the grant it came from, a valid-until timestamp, and a cancelled-at timestamp. Nothing more. We resisted denormalizing usage into the instance row.
An append-only usage log. Every consumption event gets its own immutable row in a separate table. Instances stay small and usage stays auditable: you can always reconstruct how a quota was spent.
Per-grant quotas. We extended the product-grant catalog with limit metadata: unlimited versus counted grants, a grant count, a resource type, and an expire-in-days value.
Eligibility then becomes a small, explicit algorithm rather than scattered conditionals:
isEligible(entitlement):
if entitlement.cancelledAt is set -> deny (cancelled)
if now > entitlement.validUntil -> deny (expired)
if lifetimeUsage >= grant.totalLimit -> deny (over lifetime limit)
if periodUsage >= grant.periodQuota -> deny (over period quota)
else -> allow
One decision worth calling out: quotas are calendar-bucketed, not rolling. A monthly quota resets on the 1st, not 30 days after first use. Rolling windows are more “fair” in theory and far harder to reason about, explain to a customer, and reconcile in a support ticket. Predictability won.
Keeping the legacy world in sync without holding a purchase hostage
The invariant protects the new store. But a live legacy system was still reading from DynamoDB, and we couldn’t break it. The naive fix, calling the legacy service inside the order transaction, would have violated the invariant from the other direction: a momentary blip in the legacy service would roll back a real customer purchase. Unacceptable.
So the legacy dual-write happens only after the local transaction commits, through a transactional outbox. We write an outbox row in the same transaction as the order, and an asynchronous action runner picks it up afterward, calls the legacy store, and retries on failure. If the legacy service is briefly unavailable, the purchase is already safe and committed. The compatibility write catches up later. Correctness lives in the transaction; compatibility lives outside it.
That dual-write carries the new system’s entitlement UUID, so the legacy store stamps its own records with our ID. That stamped ID turned out to do double duty. It became the deduplication marker for the historical backfill and for ongoing sync. Hold onto that detail. It ends up dictating the order of our cutover steps.
Backfilling history without breaking the rule
New purchases were covered. The harder problem was the hundreds of thousands of historical entitlements still living only in the legacy store. Every one of them had to land in the new store exactly once: not zero times (a paid order with no grant), not twice (a double grant). The invariant, applied to a batch.
Our first instinct was a Kafka-driven design: export, publish partition keys, have the legacy service read full records and call into the new system. We talked ourselves out of it. It coupled the migration to the very service we were retiring and added moving parts we didn’t need for a one-time-plus-tail backfill. We chose something deliberately boring:
- Use DynamoDB’s built-in export to S3 to get a point-in-time snapshot as sharded, gzipped JSON.
- A loader streams those files into a staging table holding the raw legacy JSON plus processing state, timestamps, and errors, with an index on unprocessed rows.
- A nightly batch job reads the export manifest, seeds per-file state, processes files one at a time, streams the gzip line-by-line, batches records, and inserts into the authoritative store.
Three properties made “exactly once” hold under reruns and crashes. Deterministic identity: each target entitlement ID is a UUID v5 derived from the legacy record’s primary key, so re-running migration for the same legacy row always produces the same target ID. Primary-key conflicts are treated as skips, not failures, and concurrent duplicate inserts get caught as integrity violations and collapsed into idempotent no-ops. Reruns cannot duplicate. Crash-safe cursors: the job tracks a per-file line offset and updates it transactionally, so a mid-file crash resumes where it left off rather than re-importing or skipping a file. Per-record failure isolation: one malformed record fails itself, not its batch, and every batch emits inserted, skipped, and failed metrics.
The join that matched almost nothing
The plan assumed we could link each legacy entitlement back to the new catalog through order-item lineage: follow the record to its order, the order to its items, the items to the grant. Clean and obvious. It matched a vanishingly small fraction of records, on the order of a dozen out of hundreds of thousands. The legacy data simply hadn’t been written with that lineage intact.
Guessing a grant would violate the invariant as surely as missing one, so we rebuilt grant resolution as a priority chain: try the subscription id first; if that fails, fall back to workspace-id plus feature; and if neither resolves, mark the record failed and skip it rather than guess. We added semantic mapping for the legacy store’s quirks. The legacy system had no explicit cancellation timestamp, for instance, so a usage count of zero was interpreted as a cancellation and migrated to a cancelled-at value. We filtered out request-tracking artifacts and records already carrying the stamped ID, since those had been dual-written and didn’t need backfilling. The lesson we’d carry to the next migration: validate your join before you build the pipeline around it. The obvious key is sometimes a mirage.
One interface over two worlds
During the transition both generations coexist, and the invariant has to read true regardless of which store answers. An aggregation layer routes reads by the shape of the order id: numeric ids resolve to first-generation entitlements computed from legacy data, while UUID ids resolve to the new relational store. Callers see one unified interface. Within that layer, feature aggregation has clear precedence rules. Subscription entitlements beat transactional ones, and unlimited beats limited within a class, so a customer always gets the most generous applicable grant.
The ordering that protected everything
The detail that could have silently corrupted the migration was timing, and it traces straight back to the invariant. We had to enable the legacy dual-write before taking the DynamoDB export snapshot. Otherwise any purchase written in the gap between snapshot and cutover would either be missed by the backfill or duplicated by it: both forbidden states. Enabling dual-write first guaranteed every post-snapshot write was already stamped with an entitlement id, and therefore already deduplicated. That is the payoff of the stamped ID we set aside earlier.
The remaining safety rails were unglamorous and essential. The migration processor is gated behind a runtime feature flag and a config property, disabled by default: a true kill switch. A profile guard ensures it runs as a single pod, so two runners never race over the same files. And a pre-production dry run reconciled counts against the export manifest, confirmed the skip categories and the cancelled-state mapping, sampled API correctness, and required that re-running the job produce zero new inserts before we’d consider it ready to promote.
That zero-new-inserts bar is the one we cared about most. It is the difference between a migration you hope is idempotent and one you have proven is, and it is the invariant verified at the batch level: run it again, change nothing, because exactly-once already held. We don’t promote the job until a rerun against a fully migrated dataset inserts nothing.
Everything above is one rule, pushed through every layer. Co-locate the entitlement with the order and “both or neither” becomes structural. Move compatibility outside the transaction and the legacy store can never hold a purchase hostage. Derive ids deterministically and “did this already run?” stops being a worry. Distrust the obvious join and a fallback chain saves the backfill. Reconcile counts and rerun to zero and idempotency stops being a hope. Entitlements are still invisible to our customers, which is exactly the point. The difference now is that there’s one place to look when we need to know what a purchase granted, one transaction that guarantees it landed, and one migration we can re-run a hundred times without flinching.
We're building this — want in?
If shipping pragmatic, AI-native systems at the scale of millions of small businesses sounds like your kind of problem, we'd love to talk.
See open rolesMore in AI for Platform Migrations
How We Used a Grounded Coding Agent to Accelerate a Commerce-Platform Migration
A large commerce migration is a coordination problem before it is a coding problem. Here is how we replaced cross-team handoffs with a spec-driven, pattern-classifying coding agent — and the validation scaffolding that kept it from hallucinating its way into production.
LegalZoom Engineering · · 7 min read
Rewriting Document Generation Without the Big-Bang Risk
How we replaced a legacy, vendor-backed document engine that touches every product line — turning a high-stakes rewrite into a per-template routing decision that was always one flag flip away from rollback.
LegalZoom Engineering · · 7 min read