Skip to content

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_mapper gets written after a first-time successful Upsert (presumably inside each IMarketplaceSyncer implementation).
  • Retry policy: what does the caller of Sync() do with a partial-failure EntitySyncResult — 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 + EventType firing twice (at-least-once delivery) — does SaveOrderMapping need an upsert-or-noop guard, does MarkShipped need to be safe to call twice?
  • What happens if Shipped arrives before Confirmed (out-of-order delivery) — mapping table lookup in OrderShippedDispatcher would fail to find a localOrderId. Needs an explicit strategy (retry/dead-letter/buffer), not left implicit.
  • Cancelled and Refunded dispatchers weren't fully sketched — same shape, but worth confirming what data each needs from the connector before assuming it fits IOrderEventFetcher<TFetchResult> cleanly.