One Interface, Many Carriers: A Clean Multi-Carrier Shipping Architecture

Any serious logistics or 3PL operation ships through more than one carrier, and each carrier exposes a different API with its own authentication, its own request shape, and its own idea of what a tracking status means. Wiring these together naively produces a brittle mess of special cases. On a recent project we built a shipping layer that talks to multiple carriers behind a single clean interface, does live rate shopping, and normalises everything into one consistent model. Here is the architecture.

One interface, many carriers

The core design decision was to define what our application needs from any carrier, then implement that contract once per carrier. The rest of the system never knows or cares which carrier it is talking to.

public interface ICarrierProvider
{
    string Name { get; }
    Task<IReadOnlyList<RateQuote>> GetRatesAsync(Shipment s);
    Task<ShippingLabel> CreateLabelAsync(Shipment s, string service);
    Task<TrackingStatus> GetTrackingAsync(string trackingNumber);
}

Adding a new carrier becomes a self-contained task: implement the interface, register it, done. No existing code changes, which is exactly the property you want in a system that will keep growing.

Rate shopping across carriers in parallel

To offer the cheapest or fastest option, we query every eligible carrier at once and merge the results. Fanning the calls out concurrently means the total wait is the slowest single carrier, not the sum of all of them:

var tasks = _providers.Select(p => SafeRatesAsync(p, shipment));
var results = await Task.WhenAll(tasks);
var cheapest = results.SelectMany(r => r)
                      .OrderBy(q => q.Cost)
                      .FirstOrDefault();

SafeRatesAsync wraps each call so that one carrier’s API having a bad day returns an empty list instead of failing the whole quote. A slow or broken carrier degrades the choices; it never takes down checkout.

Normalising tracking statuses

Every carrier invents its own status vocabulary — “in transit”, “IT”, “MovementReceived”, numeric codes. Downstream, the store and the customer only care about a handful of meaningful states. We map each carrier’s raw statuses onto a single canonical enum so the rest of the system, and every customer-facing notification, speaks one language:

public enum ShipmentState
{ Created, InTransit, OutForDelivery, Delivered, Exception, Returned }

Resilience is not optional

Carrier APIs time out, rate-limit, and occasionally return nonsense. We wrapped every outbound call in a resilience policy — timeout, limited retry with backoff for transient failures, and a circuit breaker so we stop hammering a carrier that is clearly down. External dependencies are treated as unreliable by default, because they are.

What this architecture buys you

  • Multiple carriers unified behind one interface — adding another is an isolated, self-contained task.
  • Live rate shopping that queries every eligible carrier in parallel, so a quote takes as long as the slowest single carrier, not the sum of them all.
  • One carrier’s outage no longer affects label creation or checkout for the others.
  • A single normalised tracking feed powering every customer notification.

Takeaway

Multi-carrier shipping gets messy when carrier-specific quirks leak into your business logic. Define the contract your system needs, adapt each carrier to it behind that boundary, and normalise their data into one model. New carriers become easy, outages become survivable, and the rest of your codebase stays clean. We build shipping and fulfilment integrations for logistics and 3PL operations — reach out if you are wrestling with carrier APIs.