Marketplace Sync — LLD Recap (Outbound: ERP → Connector Fan-out)¶
Low-level design walkthrough: syncing ERP entity changes out to connected marketplaces (Amazon, Shopify), given a fixed generic event shape we don't control.
The constraint that shapes everything¶
Incoming event (fixed, cannot be split upstream):
{
"merchant_id": 15037,
"entity": "Product",
"entity_id": "PROD123",
"operation": "Update"
}
One generic queue, one entry point: Sync(SyncEvent evt). The caller (our own ERP) actually knows what changed — a per-entity-per-operation queue split (product.updated, product.deleted, stock.updated, ...) would be the more reasonable design and would collapse handlers to near-nothing. But that's explicitly off the table here — the event shape is fixed. Everything below is the answer to: given one generic event, how do you get the same "trivial handler, closed for modification" property that queue-splitting would give you for free, purely in code?
Two independent growth axes: - Entities grow (Product, Stock, Customer, ...) - Connectors grow (Amazon, Shopify, Etsy, ...) - Operations do not grow — small closed set (Upsert, Delete). This is the axis safe to bake into an interface.
Rejected shape: per-connector, per-entity-method interface¶
interface IMarketplaceConnector
{
Task UpsertProduct(Product p);
Task UpsertStock(Stock s);
Task UpsertCustomer(Customer c);
// ...
}
Bakes the growing axis (entities) into the interface contract. Every new entity type forces editing every existing connector class (implement the new method or throw NotSupportedException). Violates open-closed on the axis that actually grows.
Chosen shape: generic syncer, closed over a purpose-built payload per entity¶
interface IMarketplaceSyncer<TPayload>
{
string ConnectorType { get; }
Task<ConnectorSyncResult> Upsert(TPayload payload);
Task<ConnectorSyncResult> Delete(string entityId);
}
record ProductSyncPayload(string Sku, string Title, decimal Price, string Description);
record StockSyncPayload(string Sku, int Quantity, string WarehouseId);
class AmazonProductSyncer : IMarketplaceSyncer<ProductSyncPayload> { public string ConnectorType => "Amazon"; ... }
class ShopifyProductSyncer : IMarketplaceSyncer<ProductSyncPayload> { public string ConnectorType => "Shopify"; ... }
class AmazonStockSyncer : IMarketplaceSyncer<StockSyncPayload> { public string ConnectorType => "Amazon"; ... }
Only the bounded axis (Upsert/Delete) is baked into the interface. TPayload is not a shared domain model — it's a purpose-built shape per entity (Stock sync needs quantity/warehouse, not a full Product). New entity type → new payload record + new syncer implementations, purely additive. New connector → new syncer classes per entity it supports, purely additive. Genericity earns its keep on two fronts: DI can group IEnumerable<IMarketplaceSyncer<ProductSyncPayload>> and filter by ConnectorType, and each entity gets its own tailored payload shape without needing a separate interface definition per entity.
The dispatch problem: bridging a runtime string to a compile-time generic type¶
evt.Entity is a string ("Product") at runtime. IMarketplaceSyncer<ProductSyncPayload> needs a type at compile time. Generics cannot be parameterized by a runtime string without reflection (MakeGenericType, loses compile-time safety). Something has to own that translation — the question is where, and how much logic sits behind that boundary.
IEntitySyncDispatcher — the smallest possible class that hardcodes one entity type, so everything inside it is fully typed again:
interface IEntitySyncDispatcher
{
string EntityType { get; }
Task<EntitySyncResult> Dispatch(SyncEvent evt, IEnumerable<ActiveConnector> connectors);
}
class ProductSyncDispatcher : IEntitySyncDispatcher
{
private readonly IReadOnlyDictionary<string, IMarketplaceSyncer<ProductSyncPayload>> _syncers; // keyed by ConnectorType
private readonly IErpClient _erp;
public string EntityType => "Product";
public async Task<EntitySyncResult> Dispatch(SyncEvent evt, IEnumerable<ActiveConnector> connectors)
{
var results = new List<ConnectorSyncResult>();
ProductSyncPayload? payload = evt.Operation == OperationType.Delete
? null
: ProductSyncPayload.From(await _erp.GetProduct(evt.EntityId));
foreach (var connector in connectors)
{
if (!_syncers.TryGetValue(connector.Type, out var syncer))
continue; // this connector doesn't support Product sync — skip, don't throw
var result = evt.Operation == OperationType.Delete
? await syncer.Delete(evt.EntityId)
: await syncer.Upsert(payload!);
results.Add(result);
}
return new EntitySyncResult(evt.Entity, evt.EntityId, results);
}
}
Without the dispatcher, this string→type translation has to happen inside Sync() itself via a switch — exactly the switch that needs editing every time a new entity type appears. The dispatcher is that switch's case body, extracted into its own class, registered in DI instead of hardcoded as a branch.
Why evt.Operation should be an enum, not a string: a stringly-typed operation ("Delete" compared as text) risks a typo in the publisher becoming a silent no-op/wrong-branch in the consumer with no compile-time catch. OperationType.Delete closes that gap for free.
Why _syncers should be a dictionary, not a list scanned with .Single(...) per connector: a .Single(s => s.ConnectorType == connector.Type) inside the per-connector loop is an O(N×M) scan (N connectors × M syncers checked each time). Fine at 2 connectors, real cost at scale. Build the lookup once in the constructor.
Why the dispatcher must filter/skip, not crash, on connectors it can't serve: Dispatch receives all active connectors for the merchant, including ones with no registered syncer for this entity type (e.g., a connector that supports Product sync but not Customer sync). TryGetValue + skip, not Single + throw.
The orchestrator — reduced to two lookups, nothing else¶
class SyncOrchestrator
{
private readonly IEnumerable<IEntitySyncDispatcher> _dispatchers;
private readonly IConnectorRepository _connectorRepo;
public async Task<EntitySyncResult> Sync(SyncEvent evt)
{
var dispatcher = _dispatchers.SingleOrDefault(d => d.EntityType == evt.Entity)
?? throw new UnsupportedEntityTypeException(evt.Entity);
var connectors = await _connectorRepo.GetActiveConnectors(evt.MerchantId);
if (!connectors.Any())
return EntitySyncResult.Empty(evt.Entity, evt.EntityId);
return await dispatcher.Dispatch(evt, connectors);
}
}
No switch, no knowledge of Product/Stock/Amazon/Shopify. Closed against growth in both axes: new entity type → new IEntitySyncDispatcher registered in DI, no change here. New connector → new row in merchant_connectors + new IMarketplaceSyncer<T> classes, no change here.
Results flow through every layer — the fix for a real early mistake¶
First pass had Task Upsert(...) / Task Dispatch(...) — no return value. That's information-destroying for a fan-out: N independent connector calls collapse to one bit ("didn't throw"). No way to know which connector succeeded, no way to capture a marketplace-assigned entity ID to write back to connector_merchant_mapper, no way to report partial failure (Amazon succeeded, Shopify failed) to whatever called Sync().
Fix: make the result a first-class value at every layer, not bolted on at the end.
record ConnectorSyncResult(string ConnectorType, bool Success, string? MarketplaceEntityId, string? ErrorMessage);
record EntitySyncResult(string EntityType, string EntityId, IReadOnlyList<ConnectorSyncResult> ConnectorResults);
This is the canonical shape for any scatter-gather with independent, partially-failable branches — same idea as Promise.allSettled, or a saga's per-step outcome log: gather a result per branch, aggregate into a result per fan-out, let the caller decide policy (retry, alert, "partial success is still success") from real data instead of the absence of an exception.
Queue-topology alternative — and why it's a different answer to a different question¶
If you did control the publisher (e.g., syncing your own domain events, not a fixed generic payload), the better topology is per-entity-per-operation queues:
product.updated → ProductUpdatedHandler (8 lines, no dispatch logic)
product.deleted → ProductDeletedHandler
stock.updated → StockUpdatedHandler
Each handler gets exactly the payload shape it needs and no routing logic at all — the queue name is the dispatch decision, made at the infrastructure layer instead of in code. This is strictly simpler when available.
Same logic applies on the inbound (marketplace → ERP) side for a normalization layer you own: order.created, order.shipped, order.cancelled as separate queues/handlers, each with its own tailored payload (OrderShippedPayload doesn't need a full order fetch — just orderId + tracking info — where a single generic handler would have buried that distinction inside a switch).
The key interview point: these are not competing designs for the same problem — they're the same underlying principle (isolate per-entity/per-event logic into its own small unit; keep the dispatch point itself dumb) applied at two different layers, because the constraint decides which layer is available. Own the publisher → push the routing decision into infrastructure (queue topology). Don't own the publisher / stuck with one generic event → push the routing decision into a thin, additive dispatcher layer in code. If asked "why not just split the queue," the answer is naming the constraint: the event shape was fixed upstream, so the dispatcher does in code what queue-splitting would have done for free in infrastructure.
Known remaining gaps (not yet designed, worth naming if asked)¶
- Idempotency: does
Sync()need dedup if the same event fires twice (at-least-once delivery)? - Where exactly
connector_merchant_mappergets written after a first-time successful Upsert (presumably inside eachIMarketplaceSyncerimplementation). - Retry policy: what does the caller of
Sync()do with a partial-failureEntitySyncResult— immediate retry, dead-letter, alert?
---¶
Marketplace Sync — LLD Recap (Inbound: Connector → ERP)¶
The mirror problem: a marketplace event (order confirmed, shipped, cancelled) has to land in the ERP. Same fan-out family of problem as outbound, but the two growing axes swap roles.
The event shape¶
Raw connector webhooks (Amazon's shape, Shopify's shape) are already normalized upstream — SyncOrder never sees connector-specific webhook payloads. It receives:
record OrderPayload(int MerchantId, string RemoteOrderId, ConnectorType ConnectorType, OrderEventType EventType);
// EventType: Confirmed | Shipped | Cancelled | ...
Two independent growth axes, both open-ended this time — no bounded third axis like Upsert/Delete was on the outbound side:
- ConnectorType grows (Amazon, Shopify, ...)
- EventType grows (Confirmed, Shipped, Cancelled, PartiallyFulfilled, Refunded, ...)
Where each axis actually shows up — the key asymmetry¶
This is the fact that decides the whole shape: EventType decides what happens on the ERP side, and that ERP-side operation is uniform across connectors. A Shipped event is always POST /orders/{localOrderId}/shipped with tracking info, regardless of whether it came from Amazon or Shopify. A Confirmed event is always pull-transform-push: fetch the full order, map to canonical shape, POST /orders. So EventType is the axis safe to bake into a dispatcher class — same role Entity played outbound.
ConnectorType only matters for the fetch step — but critically, fetching itself also varies by EventType, not just by connector: Confirmed needs to hit the connector's "get full order" endpoint; Shipped needs the connector's "get shipment/tracking" endpoint; these are different API surfaces on the same connector, not the same call with different post-processing. So the fetch operation is genuinely keyed by (EventType, ConnectorType) together — structurally identical to how outbound's Upsert was keyed by (Entity, Connector).
The ERP-side call itself (POST /orders, POST /orders/{id}/shipped) never needs to know or care which connector the data came from — it's connector-agnostic once the fetch has produced canonical data.
Chosen shape: generic fetcher, closed over a purpose-built result per event type¶
interface IOrderEventFetcher<TFetchResult>
{
ConnectorType ConnectorType { get; }
Task<TFetchResult> Fetch(string remoteOrderId);
}
record ConfirmedOrderData(string RemoteOrderId, /* line items, totals, shipping address, etc */);
record ShipmentData(string RemoteOrderId, string TrackingNumber, string Carrier);
class AmazonOrderFetcher : IOrderEventFetcher<ConfirmedOrderData> { public ConnectorType ConnectorType => ConnectorType.Amazon; ... }
class ShopifyOrderFetcher : IOrderEventFetcher<ConfirmedOrderData> { public ConnectorType ConnectorType => ConnectorType.Shopify; ... }
class AmazonShipmentFetcher : IOrderEventFetcher<ShipmentData> { public ConnectorType ConnectorType => ConnectorType.Amazon; ... }
class ShopifyShipmentFetcher: IOrderEventFetcher<ShipmentData> { public ConnectorType ConnectorType => ConnectorType.Shopify; ... }
Exact mirror of IMarketplaceSyncer<TPayload> outbound — only the fetch-result shape is generic, one per event type, implemented once per connector. New event type → new result record + new fetchers per connector, purely additive. New connector → new fetcher classes per event type it supports, purely additive.
The dispatcher — EventType is the string-to-type boundary this time¶
interface IOrderEventDispatcher
{
OrderEventType EventType { get; }
Task<OrderSyncResult> Dispatch(OrderPayload evt);
}
class OrderConfirmedDispatcher : IOrderEventDispatcher
{
private readonly IReadOnlyDictionary<ConnectorType, IOrderEventFetcher<ConfirmedOrderData>> _fetchers;
private readonly IErpOrderService _erp;
private readonly IOrderMappingRepository _mappingRepo;
public OrderEventType EventType => OrderEventType.Confirmed;
public async Task<OrderSyncResult> Dispatch(OrderPayload evt)
{
var fetcher = _fetchers[evt.ConnectorType]; // TryGetValue + explicit failure if connector unsupported
var order = await fetcher.Fetch(evt.RemoteOrderId);
var localOrderId = await _erp.CreateOrder(evt.MerchantId, order); // POST /orders
await _mappingRepo.SaveOrderMapping(evt.MerchantId, evt.ConnectorType, evt.RemoteOrderId, localOrderId);
return OrderSyncResult.Success(localOrderId);
}
}
class OrderShippedDispatcher : IOrderEventDispatcher
{
private readonly IReadOnlyDictionary<ConnectorType, IOrderEventFetcher<ShipmentData>> _fetchers;
private readonly IErpOrderService _erp;
private readonly IOrderMappingRepository _mappingRepo;
public OrderEventType EventType => OrderEventType.Shipped;
public async Task<OrderSyncResult> Dispatch(OrderPayload evt)
{
var fetcher = _fetchers[evt.ConnectorType];
var shipment = await fetcher.Fetch(evt.RemoteOrderId);
var localOrderId = await _mappingRepo.GetLocalOrderId(evt.MerchantId, evt.ConnectorType, evt.RemoteOrderId);
await _erp.MarkShipped(localOrderId, shipment.TrackingNumber, shipment.Carrier); // POST /orders/{id}/shipped
return OrderSyncResult.Success(localOrderId);
}
}
Mapping table (RemoteOrderId ↔ localOrderId) is written on Confirmed (order doesn't exist locally yet) and read on Shipped/Cancelled (order must already exist). Read/write split follows directly from event semantics, not an arbitrary choice.
The orchestrator — identical shape to outbound, axis swapped¶
class OrderSyncOrchestrator
{
private readonly IEnumerable<IOrderEventDispatcher> _dispatchers;
public async Task<OrderSyncResult> SyncOrder(OrderPayload evt)
{
var dispatcher = _dispatchers.SingleOrDefault(d => d.EventType == evt.EventType)
?? throw new UnsupportedOrderEventException(evt.EventType);
return await dispatcher.Dispatch(evt);
}
}
No switch, no knowledge of Confirmed/Shipped/Amazon/Shopify at this layer. New event type → new IOrderEventDispatcher, no change here. New connector → new fetcher classes per event type it supports, no change here.
Outbound vs inbound — the mapping, stated explicitly¶
| Outbound (ERP → connector) | Inbound (connector → ERP) |
|---|---|
Entity decides the ERP-side data shape (fixed axis → dispatcher class) |
EventType decides the ERP-side operation (fixed-per-class axis → dispatcher class) |
ConnectorType varies the outbound push (Upsert/Delete per connector) |
ConnectorType varies the inbound fetch (different API surface per connector) |
IMarketplaceSyncer<TPayload> — generic over payload, one per (Entity, Connector) |
IOrderEventFetcher<TFetchResult> — generic over fetch result, one per (EventType, Connector) |
IEntitySyncDispatcher — bridges runtime entity string → generic syncer set |
IOrderEventDispatcher — bridges runtime event type → generic fetcher set |
| Operation (Upsert/Delete) is the one truly bounded axis, baked into the syncer interface | ERP call (POST /orders, POST /orders/{id}/shipped) is fixed per dispatcher, not a shared interface method — it's connector-agnostic and lives directly in the dispatcher body |
Same underlying principle both directions: identify the axis where the destination-side operation is decided (bake that into a dispatcher, one class per value), and leave the other-system-specific mechanics (talking to Amazon vs Shopify) as a small generic interface implemented once per connector per operation. The direction of data flow reverses; the shape of the solution doesn't.
Known remaining gaps (inbound)¶
- Idempotency: same
RemoteOrderId+EventTypefiring twice (at-least-once delivery) — doesSaveOrderMappingneed an upsert-or-noop guard, doesMarkShippedneed to be safe to call twice? - What happens if
Shippedarrives beforeConfirmed(out-of-order delivery) — mapping table lookup inOrderShippedDispatcherwould fail to find alocalOrderId. Needs an explicit strategy (retry/dead-letter/buffer), not left implicit. CancelledandRefundeddispatchers weren't fully sketched — same shape, but worth confirming what data each needs from the connector before assuming it fitsIOrderEventFetcher<TFetchResult>cleanly.