← Back to articles

June 3, 2026

9 min read

Ecommerce Logistics Architecture: Multiple Carriers Without Coupling the System

How to design the shipping layer of an ecommerce system to support multiple carriers, switch providers without breaking anything, and select the best option at runtime.

Leer en espanol

The cost of coupling to a single carrier

Many ecommerce systems start by integrating directly with one carrier API. That works at first, but the coupling surfaces quickly: when the carrier has no coverage in a zone, when its rates stop being competitive, or when SLAs are not met. Switching requires rewriting the integration, not just updating configuration.

The problem is not using one carrier — it is that business code knows the details of that specific carrier. When quoting, label generation, and tracking logic is spread across the system with direct calls to a specific provider SDK, the cost of switching is high and the cost of adding an alternative is even higher.

The port-and-adapter pattern applied to logistics

The solution is to define a shipping provider interface that the order system knows, then implement one adapter per real carrier. The interface defines what a shipping provider can do: get rates, create a label, cancel a label, and get tracking status. Each adapter implements that interface with the specific API calls for its carrier.

With that separation, adding a new carrier means implementing a new adapter without touching the order system. Changing the default provider means swapping which adapter is injected at runtime, not rewriting business logic. The order system only speaks to the interface.

Shipping provider interface and usage in the order fulfillment service.

interface ShippingRate {
  carrierId: string;
  carrierName: string;
  service: string;
  price: number;
  estimatedDays: number;
}

interface ShippingProvider {
  getRates(params: QuoteParams): Promise<ShippingRate[]>;
  createLabel(params: LabelParams): Promise<Label>;
  cancelLabel(trackingNumber: string): Promise<void>;
  getTrackingStatus(trackingNumber: string): Promise<TrackingEvent[]>;
}

// The order service only knows the interface
class OrderFulfillmentService {
  constructor(private shipping: ShippingProvider) {}

  async shipOrder(order: Order) {
    const rates = await this.shipping.getRates({
      from: order.warehouseAddress,
      to: order.shippingAddress,
      parcel: order.parcel,
    });

    const best = rates.sort((a, b) => a.price - b.price)[0];
    return this.shipping.createLabel({ rate: best, order });
  }
}

Runtime carrier selection

With multiple adapters available, carrier selection logic can live in a router that collects quotes from all providers and picks the best based on configurable criteria: lowest price, fastest delivery, zone availability, or customer-preferred carrier.

That router can evolve without touching the rest of the system. Today it selects by price; tomorrow it can factor in incident history by zone or time of day. That flexibility is the result of separating carrier selection from carrier integration.

  • Define selection criteria as configuration, not as hardcoded logic.
  • Log which provider was used per order to analyze performance and costs.
  • Implement a fallback: if the preferred provider fails, use the next one.

Unified tracking and customer notifications

Tracking is the use case where the abstract provider model helps most. Each carrier has its own statuses — in transit, out for delivery, delivered, failed attempt — with different names. The adapter translates those statuses into an internal enum, and the system always works with that enum, not with provider-specific strings.

Customer notifications — email, SMS, push — fire on internal events, not on carrier events. When the adapter receives a webhook and converts it to an internal `ShipmentDelivered` event, the notification system reacts to that event without knowing which carrier triggered it.

More articles

Back to articles