One Product, Many Suppliers, Many Channels
No spreadsheet was harmed. Several were retired.
Almost every ecommerce system assumes a product has a price. It’s such a reasonable assumption that you don’t notice you’ve made it until you meet a business where it’s false.
Distribution is one of those businesses. You buy the same item from several distributors, each quoting a different cost with different stock. You sell it through other people’s storefronts, each wanting a different price and taking a different cut. The product in the middle is one product. Almost nothing attached to it is singular.
We spent this year building a system for that shape, for Inovio doo. Here’s what we’d carry into the next one.
Both Directions Are Adapters
Importing from a supplier and exporting to a channel look like different problems. Inbound is XML from one distributor, a CSV drop from another, a REST API from a third. Outbound is marketplaces that each want their own feed format.
Structurally they’re identical: something external and changeable, sitting on one side of a boundary you control. So every supplier is an adapter producing offers in one internal shape, and every channel is an adapter consuming the catalog in one internal shape. What varies lives in the adapter. What matters lives in the middle.
The payoff isn’t elegance, it’s the cost of the next one. Adding a supplier is writing an adapter. It isn’t touching pricing, or stock, or any other channel.
One rule earned its place early: nothing gets silently dropped at a boundary. A row we can’t match doesn’t disappear into a log line. If it’s active and it has a name it becomes a draft product waiting for a person, tagged with the reason it couldn’t be placed, and matched by its code on every later run instead of being created again as a duplicate. Inbound feeds sit behind a circuit breaker too, so a supplier having a bad morning gets backed off and reported rather than retried into the ground.
Compute The Price Once
The rule we’d most want to keep: nothing recomputes a price at export time.
That isn’t what we inherited. Some channels applied their own margin formula on the way out, so the price in the system and the price on the marketplace were different numbers, and the real one existed only inside the code that generated that particular feed. Ask why an item is cheaper on one marketplace and nobody can answer without reading two feed generators.
Now pricing is one engine that knows about per-channel terms and produces the number that actually gets published. Export formats. It does not calculate. Stock works the same way, derived in exactly one function from internal and supplier availability. A second implementation is treated as a bug, because the second one is always the one that drifts.
Your Identifiers Belong To Other People
This is the one that cost the most thought, and the one we’d flag to anyone replacing a catalog.
When you migrate, you naturally want clean new internal identifiers. The problem is that every marketplace you sell through already knows your products by the identifier the old system generated. That code lives in their database, attached to your listing’s history and its ranking. Change it and you aren’t migrating, you’re relisting from scratch and discarding everything those listings earned.
So a migrated product keeps answering to its old code on channels that already knew it, while carrying a new internal identifier for everything else. Unglamorous modelling, and the difference between a migration nobody outside the company notices and one that costs a quarter of marketplace performance.
The general version: when you replace a system, its identifiers may be the part other people depend on, and they didn’t agree to your migration.
The Back Office Is Not A Website
Fairly early on we stopped treating the admin interface like a website.
Operators here do one thing for most of the day: scan long tables of products, offers and prices, hunting for the row that’s wrong. Every instinct you’ve picked up from building marketing sites works against you. Generous whitespace means fewer rows on screen. A comfortable 16px body means fewer rows on screen. Every bit of breathing room you add costs the user the exact thing they came for.
So body text is 12px, spacing is deliberately tight, and rows per screen is the number we optimise. We went as far as turning on the font’s alternate glyph set for l, 1 and I, because in a narrow column of SKUs at 12px those three are genuinely hard to tell apart, and misreading a code costs somebody ten minutes and a wrong edit.
In a design review it reads as cramped, and we’ve had that conversation more than once, mostly with each other. Nobody who uses it all day has asked us to loosen it.
AI Proposes, A Person Decides
A catalog built from many distributors is uneven by nature. One supplier sends a full description and clean attributes. The next sends a name, a price, and a photo. Both have to be good enough for a marketplace that rejects a listing over a missing dimension.
Closing that gap used to be a people problem, which capped catalog quality at however many hours you could spend on it. So we treat AI as an enrichment service over the dataset, in the same family as any other data provider the platform calls: it takes the product we have, fills in the fields the feeds didn’t supply, and hands them back. It proposes a category with a confidence score, completes missing attributes, derives the logistics fields, and reads the product’s images when the text alone isn’t enough to tell.
Anyone can call an AI endpoint. The interesting part is where its output is allowed to land.
Nothing the service returns is written onto the product. It’s stored beside it as a pending suggestion, and an operator accepts or rejects it against the current value. Acceptance is recorded, so a year later you can still tell which fields a person entered and which a person approved.
That property matters more than it sounds. The dangerous failure isn’t the obvious howler, which someone catches. It’s the plausible but wrong value written silently into a record, propagating to every channel, indistinguishable from data a human entered. Once you can’t tell which is which, you can’t audit it, and you can’t clean it up without rechecking everything.
We allowed one exception: physical dimensions fill empty logistics fields directly, because there’s no editorial judgement in the width of a box, and the provenance is still recorded.
The honest cost of all this is a review queue. Somebody has to sit with it, and on a bad week it’s a real chunk of an operator’s day. You could argue we’re being too careful, and plenty of teams would just write the values in and fix complaints later. We’d rather have the queue than a catalog we can’t vouch for, but it is a trade and we made it deliberately.
What Generalizes
Treat every external party as an adapter, inbound and outbound both. Derive each meaningful number in exactly one place. Before migrating anything, ask which of your identifiers other people have already written down.
And where AI enriches your data: keep what the service proposes separate from what your system believes, with the transition between them recorded, or you end up with a dataset you can never audit.
None of this is clever, but it’s what decides whether adding the next supplier costs you an afternoon or a quarter, and whether AI enrichment leaves you with a better dataset or a slow motion data quality incident.
Many channels. One price engine. And an enrichment service that never gets the last word.


