Key Takeaways
- A commerce platform tends to age better when it is built from a small number of first-class entities that reference each other rather than duplicate data between them. The pull to introduce a new entity for every new use case is constant, and it usually signals that the existing model needs to flex rather than grow.
- When a feature has to satisfy several independent constraints at once — which locations can serve a customer, which offerings are enabled where, which categories carry extra eligibility rules — treating the problem as an intersection of independently-evaluated sets scales better than threading conditional logic through a single workflow.
- Grouping related items under a shared identity, with its own completeness guarantees, keeps a bundled offering from silently degrading into something that no longer matches what was promised to the customer.
- Regulated or constrained product categories are easier to support safely when their constraints are modeled as a distinct type carrying its own rules, rather than as flags scattered through a shared code path. New categories then become additive, rather than compounding the complexity of existing ones.
- When a fully safe editing workflow is not realistic within a launch deadline, a layered set of lighter-weight guards can capture most of the risk reduction at a fraction of the engineering cost — often enough of a trade-off to ship safely under real time pressure.
The launch
Kits at Shipt are built for moments — a game day, a holiday dinner, a hosting weekend. The window between a merchandiser deciding to launch a themed bundle and a customer needing it can be days, not weeks. Every architectural decision described below is downstream of that constraint.
In June 2026, Shipt launched Shipt Kits, a one-click cart-building feature designed to help customers plan their hosting events. Timed around a global soccer event, the company’s national brand campaign introduced Shipt Kits through a celebrity partnership, highlighting summer use cases such as watch parties, backyard hosting, and cookouts. Customers landing on a "Match of the Week" kit could add a full evening’s worth of groceries — sides, drinks, snacks, dessert — to their cart in one click. Each item was store-aware: the kit you saw at your store reflected what was actually on shelves near you, not a generic catalog.
The campaign was covered in Progressive Grocer, Supermarket News, ¡HOLA!, and Better Homes and Gardens. From the outside, it looked like a polished new feature.
From the inside, the team had three weeks to build it. The API was done in ten days. The remaining eleven went to the admin experience, the customer surfaces, and the operational hardening that turns a working prototype into something a merchandising team can use without supervision.
This is an architecture tour of what was built in those three weeks: how the platform replaced a multi-day clerical workflow with a single admin UI, how it was modeled as three first-class entities that compose well, how the catalog was made store-aware end-to-end, and how it was made safe to edit live without a full draft-publish lifecycle.

Figure 1. Platform architecture overview (Image source: created by author)
The Kit API sits between the merchandiser-facing admin UI and the entity layer, with store-aware resolution and kit-type compliance rules determining what a customer ultimately sees, and edit-safety guards protecting in-flight changes.
Before Kits: a manual assembly workflow
Before Kits, building a themed shopping experience at Shipt looked like this. A merchandiser who wanted to assemble, say, a Tailgate Bundle would:
- Query the data warehouse for products that fit the theme.
- Pick the relevant UPCs by hand from the query results.
- Translate each UPC to the retailer-specific product ID — different IDs for the same physical product at Target, Meijer, Publix, and so on.
- Build a category containing those retailer-specific IDs.
- Build a product list from that category.
- Plug the product list into wherever the bundle needs to show up.
After all that work, the bundle still wasn’t guaranteed to display correctly at every store. The workflow was pegged on product ID, not on per-store inventory, so a kit that looked complete in the admin tool could be missing half its items at the customer’s actual store. The merchandiser had no way to know in advance.

Figure 2. The pre-Kits workflow: six manual steps and no per-store guarantee (Image source: created by author)
For a one-off promotion that lived for a week, this was tolerable. For a national campaign that needed dozens of fresh kits launching on cadence — across multiple retailers, multiple regions, multiple weeks — it was impossible. When the campaign got the green light with a fixed launch date, the team had three weeks to build a system where merchandisers could describe a bundle once and have the platform handle the per-store, per-retailer materialization.
Three first-class entities: items, shopping lists, kits
The first architectural decision was the most important: model the platform as three composable entities, not one monolithic "kit" type.
The approach draws on the design vocabulary of Domain-Driven Design (DDD). Items, shopping lists, and kits form the ubiquitous language shared across code, admin UI, and merchandiser conversations, and each is modeled as an aggregate that owns its own invariants. Kits act as aggregate roots for editorial and structural metadata; items live in their own bounded context and are shared by reference rather than duplicated inside every kit that uses them. Naming these boundaries early kept downstream feature work — new kit types, ad monetization, per-store resolution — additive rather than disruptive.
Items are the atomic catalog units — generic, typed, and tagged. An item knows what kind of thing it is (a product, an ingredient, a non-product element of an editorial bundle) and carries arbitrary metadata in a JSON tag bag. Items are not retailer-specific; the resolution to a particular store’s product ID happens at view time, not at modeling time.
Shopping lists are curated sequences of items. A shopping list can be merchandiser-curated and attached to a kit (the "here’s what to grab for your party" list inside a Game Day Bundle) or customer-built (a personal grocery list that the customer maintains across sessions). The same shopping-list abstraction supports both, which means the platform maintains one mental model and one set of APIs rather than two parallel ones.
Kits are the composable bundles — the things customers see, click, and add to cart. A kit carries editorial metadata (title, description, hero image, theme), structural metadata (slot layout, type, anchor items), and references to items and optionally to shopping lists.

Figure 3. Items as a single source of truth, referenced by both shopping lists and kits (Image source: created by author)
The platform’s most consequential property is that items are shared by reference across shopping lists and kits. The same Heinz ketchup item powers the Tailgate Bundle, the Backyard BBQ Bundle, the Family Picnic Bundle, the customer’s personal grocery list, and twenty other surfaces. Updating the item’s metadata propagates everywhere it’s used. Adding a new retailer mapping for that item lights it up across every kit and list that references it, automatically.
An item, roughly, looks like this:
{
"id": "itm_01H2N7X...",
"item_type": "product",
"canonical_name": "Heinz Tomato Ketchup, 20 oz",
"tags": {
"category": "condiments",
"dietary": ["vegetarian", "gluten-free"],
"seasonal": ["summer", "grilling"],
"brand_id": "brand_heinz"
}
}
The tags field is intentionally a schemaless JSON bag — new dimensions (dietary, seasonal, promotional, anything a merchandiser or downstream service wants to filter on) can be added without a migration. Retailer-specific product IDs deliberately don’t live on the item; they’re resolved at view time, per store, which is the subject of the next section.
The migration that unified items into this single source of truth — kit products and shopping-list items both pointing at the same item rows — was mechanically boring but paid off immediately. Maintenance dropped. New kit types became inexpensive to introduce because a new type just needed to know which items it references, not how to manage its own private item table.
Anchor items and kit identity
A kit is more than a bag of items. Some items are essential to the kit’s identity. A summer grilling kit without meat is not a summer grilling kit. A pasta-night kit without pasta is not a pasta-night kit. An alcohol kit without alcohol is, legally and conceptually, something else.
The team modeled this with the concept of anchor items. An anchor item is one whose absence — at a specific store, on a specific day — disqualifies the entire kit from being shown at that store. The merchandiser marks anchor items during kit creation. The resolution layer checks anchor availability before deciding whether to surface the kit.
Anchor items also drive a per-kit minimum product threshold. A kit can declare that it needs at least, say, eight non-anchor items available at the customer’s store to be worth showing, in addition to all anchor items being present. The thresholds and anchors compose: a kit is shown only if (a) every anchor item resolves at the store and (b) the total available item count clears the threshold.
As a result, the platform never shows customers a half-empty kit. The kit either makes sense at this store today, or it doesn’t appear in the customer’s catalog at all. For a curated, editorial product, this is the contract the team wants.
Resolving a kit per store, per customer

Figure 4. The customer’s effective retailer set is the intersection of three independently configured layers (Image source: created by author)
Behind every customer view of a kit is a small set-theoretic computation that decides which retailers’ inventories to query. A customer at a given zip code may be served by a set of grocery retailers—the customer’s local Hy-Vee, Target, Meijer, and Giant Eagle. Each kit has its own allow-list of retailers it’s enabled for. Some kits—alcohol kits, most notably— also have additional eligibility constraints that depend on store-level licensing.
The resolution is the intersection of all three:
effective_retailers = customer_zip_retailers ∩ kit_allowed_retailers ∩ kit_eligibility(store)
For an alcohol kit, the eligibility check filters to stores with active alcohol licenses in the customer’s jurisdiction. For a kit with no special eligibility, the third set is unconstrained. For a general national-campaign kit, the allow-list covers Shipt’s operational areas. Each layer is independently configurable; the intersection is computed very efficiently at request time.
Once the effective retailer set is known, the platform fans out concurrent searches against each retailer’s catalog, collects the results, and assembles the customer-facing kit. Items are scored against the retailer’s actual inventory, anchor items are checked, the minimum threshold is verified, and the kit is either rendered or quietly dropped from the customer’s view.
The fan-out matters operationally. A customer at a metro zip might be eligible for four retailers; the kit needs to materialize quickly enough to feel instantaneous. Sequential per-retailer queries would have been too slow at the latency budgets the team was targeting. Concurrent fan-out, with per-retailer timeouts and graceful degradation, keeps the customer-perceived latency flat regardless of how many retailers the customer is eligible for.
Each retailer call runs independently and in parallel, with its own fixed timeout rather than a single timeout for the whole fan-out. If a retailer doesn’t respond within that window, its result is simply omitted from that customer’s kit view rather than blocking or failing the request. This keeps customer-perceived latency bounded by a fixed ceiling regardless of how many retailers the customer is eligible for, rather than growing with the number of retailers or stalling on a single slow one.

Figure 5. Concurrent fan-out for retailer calls (Image source: created by author)
Alcohol kits: when compliance becomes a kit type
Alcohol is the clearest example of why the team settled on kit types as an abstraction.
A generic kit’s lifecycle is editorial: a merchandiser creates it, sets its theme and items, publishes it. An alcohol kit’s lifecycle is editorial plus compliance: the same creation flow plus a separate store-eligibility check (does this store hold an active alcohol license?), plus product-level filtering (only products classified as alcoholic, no mixers-as-alcohol false positives), plus jurisdictional constraints (an alcohol kit is hidden at the customer level if the customer’s zip falls outside the retailer’s licensed delivery area).
We made all of that part of the alcohol kit type rather than special-casing the generic kit. The type carries its own eligibility predicates; the merchandiser doesn’t need to remember to apply alcohol rules because the type applies them automatically. New compliance constraints can be added by extending the type’s predicate without touching the generic kit code path.
The team expects more of these — pharmacy, age-restricted content, regulated brands. Each one is a good candidate for its own kit type rather than a new flag on the generic one.
Onsite ad monetization without breaking the bundle
The first revenue surface on top of the platform is onsite ad monetization. A consumer-packaged-goods brand pays to be featured prominently inside a kit — their product appears in a designated slot, branded, sometimes with promotional pricing.
The mechanics require a brand registry, sponsor associations on a per-kit basis, and an answer to a question the team hit almost immediately: what happens when the sponsored brand’s product isn’t available at the customer’s store?
Neither of the obvious answers works cleanly. Showing the kit without the sponsored item breaks the sponsorship agreement — the brand paid for placement, and silently dropping it leaves the customer with an awkward gap. Hiding the entire kit is worse in the other direction — one out-of-stock sponsored beverage shouldn’t sink a whole Game Day Bundle for the customer.
The behavior is contractual, so we made it configurable per kit. A brand_fallback field takes one of two values: hide item (drop the sponsored item, render the rest of the kit) or hide kit (don’t show the kit at this store). The merchandiser picks the value that matches the brand’s contract at kit-setup time, and the resolution layer enforces it at view time.
The brand registry itself is synced from Shipt’s upstream catalog system on a cadence. The sync is streaming and idempotent: it pages through the upstream catalog, upserts into the local brand table, and writes only where values would actually change. Re-running the sync after a partial failure is safe — already-correct rows are no-ops, and inconsistent ones converge on the next run.
Edit safety: layered guards over a live-edit model
Merchandisers edit kits in a live system. Customer carts and placed orders are snapshots taken at add-to-cart and order time, so live edits don’t rewrite history — but the kit itself is mutable, and every save is visible to the next customer. The textbook fix is a full draft-publish lifecycle (draft rows, atomic publish, diff UI, snapshots), at two to four extra weeks of build.
That time wasn’t available in the launch window, so the team shipped four guards instead:
- Layer 1 — persistent warning. An always-visible amber banner whenever the kit is published. Deliberately louder than admin-UI convention, because it’s the only always-on signal that this is production data.
- Layer 2 — first-edit confirmation. Touching any control on a live kit triggers a modal, remembered per tab and per kit (a fresh tab reprompts; a single click can’t authorize blanket edits across the catalog). Fourteen mutation call sites route through the same hook. "Live" is derived from the persisted kit status, not the local Status field — flipping the dropdown to Draft doesn’t silence the warning while the kit is still live for customers.
- Layer 3 — explicit Save and Discard per section. A mixed model (some fields autosaved, some POSTed on toggle) was replaced with a uniform one: Save, Discard, an "Unsaved changes" pill, and a native beforeunload prompt. Two primitives — a generic dirty-form hook and a page-level dirty registry — compose the cross-section guarantees. Recipe and shopping-list slot edits stay instant, because they’re actions, not form drafts.
- Layer 4 — cross-section locks. Those instant actions are short-circuited whenever section metadata is dirty, so they always operate on persisted truth. A one-line check against Layer 3’s dirty registry — cheap, but only watching real edit flows made it clear the platform needed it.
Together, the four layers cover roughly 70% of what a true draft-publish system would give the platform, at maybe 10% of the cost — enough to ship the campaign safely, with the full lifecycle now visible on the roadmap.
What we’d build next
The next three investments are clear, and they’re investments rather than rework — the platform is structured to accept them.
A true draft-publish lifecycle
Layered guards are a good 70%. Draft-publish is 100%. The remaining 30% shows up in workflows we don’t currently support well: a merchandiser editing a kit over multiple days, an editorial reviewer approving changes before they go live, an audit trail of who changed what when, automatic rollback when a published change causes a regression.
Since the campaign, we’ve taken the first concrete step — a Testing status that puts a kit live in production but only visible to a global tester allowlist, so internal reviewers see the real customer surface before real customers do. The lifecycle is now a genuine pipeline (Draft → Testing → Published → Archived) rather than a binary switch; per-field drafts, atomic publish, diff UI, and audit trail are the next slice.
Brand-sponsored kits with full ROAS attribution
The ad monetization layer ships placements today. The natural next step is closed-loop attribution: a brand pays to be featured, and we report back attributed revenue at the kit, item-within-kit, and impression levels. This is plumbing more than research — the events exist, the joins are tractable, the reporting surface is the work.
Per-customer filtering on kit items
Today’s resolution layer is per-store. The next axis is per-customer: a customer with a "no pork" or "gluten-free" preference should see a Tailgate Bundle that respects it. The mechanics are an additional intersection in the resolution layer, alongside the per-store ones already in place. Anchor items and minimum thresholds will need to be re-evaluated per customer rather than per store, which is a real but contained change.
What I’d say to anyone building something similar
Three things would have saved me time if I’d known to look for them earlier:
- Pick a small set of composable entities and resist the urge to add more. Items, shopping lists, and kits are three. The pressure to add a fourth (a "collection," a "feature," a "carousel") is constant and almost always wrong. Each new entity multiplies the platform's surface area; sharing the existing three by reference does almost everything the new entity would, with one less concept to maintain.
- Model real-world constraints as types, not flags. Alcohol is the obvious case. The next case will be something else — pharmacy items, regulated brands, age-restricted content. Each one should be a kit type that carries its own eligibility predicate, not another boolean column on the generic kit.
- Ship enough to learn what the rest should be. Layered edit-safety guards weren’t the architecturally clean answer — they were what fit in the launch window. But they’ve been good enough to let the merchandising team use the platform in production, which is now teaching us what a real draft-publish system would need to support. If we’d tried to build the full lifecycle in week one, we’d have been designing against guesses instead of real edit patterns.
Shipt Kits has been live for a few weeks now, handling real merchandiser workflows and real customer traffic across the guardrails, resolution, and compliance layers described above. The team is now planning its next investments — draft-publish, ROAS attribution, per-customer filtering — from a base of real usage rather than assumptions.
Summary
The specifics above are about grocery kits, but the underlying modeling questions are not unique to grocery, or even to commerce. Any platform that lets customers combine multiple things into one purchasable or consumable unit — travel packages, subscription boxes, meal kits, ticket bundles, curated content collections — runs into the same handful of decisions: how many first-class entities to introduce before the system becomes unmanageable; how to resolve eligibility when several independent constraints apply at once, such as location, inventory, regulation, and pricing; how to give a bundled offering an identity that customers can trust rather than one that silently falls apart; and how to support regulated or exception-heavy categories without littering the core model with special cases.
None of these questions has a universal right answer. The right level of abstraction depends on how often new constraints or categories will show up, and how much operational cost a team can absorb up front versus later. What held true in this case, and what tends to hold true elsewhere, is that the answer rarely comes from more process or more documentation. It comes from naming the entities and constraints explicitly in the model, early enough that the system can grow by composition instead of by exception.
Thanks to Marc Kapke (Senior Principal Engineer, Shipt) for review and technical feedback, and to the Shipt Marketplace team for the collaboration that made this platform possible.
About the Author
Khurshid Hameed
Show moreShow less
