Think of a restaurant where one person takes orders, cooks, serves, and runs the register. Any rush and the whole system stalls at once. Give each task its own role instead, and each one can be improved, tested, or replaced without disrupting the others. In code, that means a module should have exactly one reason to change — not that it can only do one small thing.
At Infracommerce, a customer-registration flow originally did its job — creating the account — and then called an external loyalty API synchronously in the same request: one piece of code carrying two responsibilities, one outage away from blocking checkout. I split it so registration stayed synchronous while the loyalty sync moved into a RabbitMQ-queued job with its own cron-driven worker, dispatched only after the registration transaction commits — so a rolled-back signup never reaches a queued listener. Each piece now changes for its own reason: registration changes when customer-creation rules change, the loyalty listener changes when the external API changes — different reasons to change, not just different tasks.
Trade-off: what was one synchronous call became two moving pieces — a queue and a worker — that now need their own monitoring, and a customer's loyalty sync, which used to complete instantly, can now lag by a few seconds.
OrderService
Before
class RegisterCustomer
{
public function handle(array $data): void
{
$customer = Customer::create($data);
Http::post('https://loyalty.example.com/sync', [
'email' => $customer->email,
]);
Mail::to($customer->email)->send(new WelcomeEmail($customer));
}
}After
class RegisterCustomer
{
public function handle(array $data): Customer
{
$customer = Customer::create($data);
event(new CustomerRegistered($customer));
return $customer;
}
}
// Listeners: SyncCustomerToLoyaltyApi, SendWelcomeEmail
// each queued, owning exactly one job