DEV Community

Cover image for Barcode lookup: a provider chain with an LLM as the normaliser
Ibukun Demehin
Ibukun Demehin

Posted on

Barcode lookup: a provider chain with an LLM as the normaliser

The app now has three input devices: your voice, your camera pointed at paper, and — new this post — your camera pointed at a barcode. The first two hand Claude an ambiguity problem: rambling speech, crumpled thermal print. A barcode is the opposite. 5000169005804 identifies exactly one product on Earth. No interpretation permitted.

That inversion drives every architectural decision in this feature — including the strictest rule in the codebase: Claude is never allowed to invent a product.

TL;DR — Exact inputs get lookups, not generation. The lookup is an ordered provider chain (own cache → four Open*Facts databases, which share one API so they're one adapter with four hosts), each provider behind a 3-second fuse, failures skipped never fatal. Claude's only job is normalising the messy winning payload (trimmed to 11 fields first) into a clean name, parsed size, and one of the app's own aisle keys. Nothing found → found: false and fast manual entry — whose answer is written into a shared, deliberately userId-less Product cache, so coverage accumulates exactly where the global databases are blank.

(Part 15 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)

The feasibility memo came first

Before any code, a plan-doc reckoning with two uncomfortable facts.

Product identification is solved for Europe and thin elsewhere. The Open*Facts family — Open Food Facts plus its Beauty, Products and Pet Food siblings — is free, open, keyless, and strong in the UK. But the app supports NGN, GHS, KES and ZAR currencies, and locally-produced goods in Lagos or Nairobi are usually in no global database at all. So manual entry is designed as a main path, not an apologetic fallback — and (spoiler) it feeds the cache.

There is no free, legitimate API for live UK supermarket prices. Tesco and Sainsbury's publish none. Scraping breaks their terms and breaks constantly in practice — off the table entirely. The honest alternative: best price from data the user already owns — their own scanned receipts. Free, legal, and accurate for their shops, which a web price never is.

Both findings are in the plan doc with a warning I'll repeat here: free-tier terms change — verify before integrating, and don't trust the figures in anyone's plan doc, including mine.

The provider chain

The four Open*Facts databases share one API shape — /api/v2/product/{barcode}.json — so all four are one adapter with four hosts:

const OPEN_FACTS_HOSTS = [
  { id: "openfoodfacts", host: "world.openfoodfacts.org" },
  { id: "openbeautyfacts", host: "world.openbeautyfacts.org" },
  { id: "openproductsfacts", host: "world.openproductsfacts.org" },
  { id: "openpetfoodfacts", host: "world.openpetfoodfacts.org" },
] as const;
Enter fullscreen mode Exit fullscreen mode

The chain runs: our own Product cache (checked on the client, before the Lambda is ever invoked — a repeat scan resolves instantly and costs nothing) → the four databases in order → not found. Three rules make it safe to stand in an aisle with:

// Per-provider fuse. The user is stood in an aisle — a slow answer is worse
// than a fast "not found", which still leads to a quick manual add.
const PROVIDER_TIMEOUT_MS = 3000;

async function fetchWithTimeout(url: string, ms: number): Promise<Response | null> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  try {
    return await fetch(url, {
      signal: controller.signal,
      headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
    });
  } catch {
    return null; // timeout or network error — skip this provider, never fatal
  } finally {
    clearTimeout(timer);
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Every provider has a fuse. A hung host degrades to "try the next one," never to a spinner in a supermarket.
  • First good answer wins. No merging of providers — two conflicting names are harder to reason about than one imperfect record.
  • Failures are skipped, never fatal. One database being down must not break lookup.

Paid catalogs (UPCitemdb, Go-UPC…) slot in at the end of the same list behind enabled flags that read env keys. Adding or dropping a provider is a change in the Lambda only — no client work, no app-store release. The chain is the API.

(Also: the User-Agent is a real, descriptive one. Open Food Facts is a volunteer project that asks callers to identify themselves; being a good citizen of a free database costs one string.)

Claude the normaliser, never the generator

The winning payload is a mess by design — community-entered data: categories_tags: ["en:snacks"], product names in mixed languages, "415 g" as one opaque string, brands as comma lists. This is exactly an LLM-shaped problem, with one twist: before Claude sees it, the payload is trimmed to 11 fields — the full Open Food Facts record is enormous (nutrition matrices, image revisions, edit history) and almost all of it is irrelevant to a shopping list. Don't pay tokens for data you're going to ignore.

The prompt's job description is deliberately narrow:

  • name: the product WITHOUT the brand (brand has its own field), title-cased …
  • quantity + unit: the size parsed ("415 g" → quantity 415, unit "g") …
  • category: choose EXACTLY ONE from this list, or null when genuinely unclear: produce, bakery, dairy, meat, … pet, other
  • Never invent details that are not supported by the record. Prefer null over a guess.

That category rule is Part 9's lesson applied again: the model maps into the app's owned vocabulary of aisle keys — it never mints its own labels.

And when no provider knows the barcode, the Lambda returns { found: false, barcode }. It does not ask Claude to conjure a product from thirteen digits. A confidently wrong product is worse than no product — it's unverifiable, and it would poison a shared cache. The client answers "not found" with a manual-entry form instead, name field focused, one tap from the scanner.

The cache with no userId

Every model in this app carries userId — the owner's Cognito sub. Product is the first deliberate exception:

  • A barcode means the same product for everyone. Barcode → product is a global fact, so the cache is a global model. One user's lookup benefits the next person to scan the same thing.
  • Manual entries are cached toosource: "manual". This is the quiet best decision in the feature: the not-found path writes back, so the cache grows precisely where the global databases are blank. A user in Accra scanning local products builds coverage no European database will ever have. A dead end becomes an asset.
  • The provider's raw payload is kept on the row, so products can be re-normalised later (better prompt, better model) without re-fetching.

A world-writable shared cache has an obvious failure mode — one user's typo becoming everyone's product name — so v1 ships guards: first write wins, existing products are never overwritten, and source makes manual rows auditable and supersedable by a later real provider hit. The plan doc also states plainly what's deferred: if this ever goes properly multi-user, it needs moderation or per-user overrides — a decision written down before the scale that forces it, not after.

The scanner is mostly restraint

The camera half of the feature is a list of things carefully not done:

  • A camera fires the same barcode many times a second — a scan lock ensures exactly one lookup per capture.
  • Scanning the same product again bumps its quantity instead of duplicating the row.
  • Lookups run in the background; the viewfinder never blocks on the network.
  • Symbologies are restricted to product codes (EAN-13/8, UPC-A/E) — a narrow set misreads far less than everything-on.
  • Scans accumulate in a staging tray reviewed once at the end. A confirmation modal between every scan is the wrong design for a human pushing a trolley; batch capture, single review — the same review-gate pattern as voice and receipts.
  • The sheet dismisses on an explicit ✕ only — no swipe-down, no backdrop tap. An over-scroll must never throw away a reviewed trolley.

The scanner: viewfinder locked on a barcode, torch and manual-entry controls, with already-scanned items staged below

The silence is the signal

The feedback design earns its own section because the absence of feedback is doing the work. A new capture chirps (a real shutter-adjacent beep) and taps a success haptic. A duplicate scan gets silence plus a warning haptic — and that contrast is what teaches users to stop re-scanning: no beep, nothing new. The audio is configured to mix with (iOS) or duck (Android) whatever's playing rather than interrupting it, and the iOS silent switch is respected — haptics still confirm when the phone is muted. Manual entry never beeps; it isn't a capture.

Designing what doesn't make a sound turned out to matter as much as what does.

Coral's debut

One more thing appears in the batch review sheet: "Last paid £2.40 · cheapest £1.95." It's computed from the user's own receipt history — matched by normalised name rather than barcode (receipt lines predate the barcode field, and name-matching also covers items added by voice or hand), home-currency receipts only, unit prices so quantity never distorts a comparison.

The batch review sheet: each scanned product editable, with what you last paid pulled from your own receipts

It's also the first place the coral accent appears in the app — the color the design system reserves exclusively for money-saving moments, unspent through fourteen posts. What your receipts know deserves more than a paragraph, though: it's the whole next post.

What I took away

  • Exact inputs get normalisers, not generators. The moment the input identifies one true answer, the LLM's job is cleanup — and "prefer null over a guess" belongs in the prompt.
  • Chains need fuses. Per-provider timeouts and skip-on-failure turn four flaky free databases into one dependable lookup.
  • Global facts get global caches. Dropping userId from one model — deliberately, with guards — turned every user's scans into shared infrastructure.
  • Feed the cache from the failure path. Manual entry after not-found is how the app builds coverage no provider sells.
  • Trim before you prompt. Eleven fields in, not four hundred.
  • Design the silence. A duplicate that doesn't beep teaches more than any toast.

Next up

Items now carry barcodes, receipts carry prices, and the app has months of both. Post 16 wires it together: per-item price history, spend trends, and the savings screen — what "deals" look like when your only price source is your own past.

Where do you put the line between lookup and generation in your LLM features — and have you ever let a model fill a gap it shouldn't have?

Top comments (0)