The Problem
The shipment tracking system had grown organically over years. Carrier-specific logic was tangled directly into the core order fulfillment flow — if-else chains that checked which carrier was handling a package, each branch with its own quirks and hardcoded assumptions. Adding a new carrier meant touching the same files that handled rate calculation, label generation, and tracking updates. Every integration risked breaking existing ones. The team had reached a point where a “simple” new carrier onboarding took weeks and required a full regression cycle.
With 50K+ shipments flowing through daily, the cost of a regression wasn’t theoretical — it was lost packages and angry customers.
Constraints
The migration had to happen without downtime. Existing carrier integrations couldn’t break, and the external APIs they depended on weren’t changing. Internal teams consuming shipment data via existing endpoints needed backwards compatibility. This wasn’t a greenfield rewrite — it was surgery on a running system.
Solution Architecture
I applied the strangler fig pattern, incrementally extracting responsibilities from the monolith into well-defined modules:
-
Bounded Contexts — I identified three core domains: Order Fulfillment, Carrier Integration, and Tracking. Each got its own namespace, models, and service layer. Shared concepts like “shipment status” were modeled explicitly as a finite state machine rather than scattered string comparisons.
-
Carrier Adapter Pattern — Each carrier integration became a self-contained adapter implementing a common interface:
createShipment(),getLabel(),parseTrackingWebhook(). Adding a new carrier meant writing a new adapter class and registering it — zero changes to core logic. -
Event Bus — Shipment lifecycle transitions (created, picked up, in transit, delivered, exception) were published as domain events through a Redis-backed event bus. Downstream consumers — notifications, analytics, billing — subscribed independently. This decoupled “what happened” from “who cares about it.”
-
State Machine — The shipment lifecycle was formalized as a state machine with explicit transitions and guards. Invalid state changes were rejected at the domain level, eliminating an entire class of bugs where shipments ended up in impossible states.
Tech Stack
- Backend: Laravel 10, PHP 8.2
- Event Bus: Redis Streams with Laravel event broadcasting
- State Management: Custom finite state machine built on
spatie/laravel-model-states - Database: MySQL with read replicas for tracking queries
- API Layer: Versioned REST APIs with OpenAPI documentation
What I Learned
The biggest lesson was that incremental migration beats big-bang rewrites every time. The strangler fig approach let us ship improvements weekly while the old code still ran in parallel. We used feature flags to route shipments through the new pipeline carrier by carrier, validating output against the legacy system before cutting over.
I also gained a deep appreciation for domain modeling. Spending time upfront to map out the shipment lifecycle as a proper state machine prevented dozens of edge-case bugs that the old system had accumulated over years. Getting the model right made the code almost obvious.