Adding a Slack Alert Without Touching Checkout: Event-Driven Side Effects in a Modular Monolith

Adding a Slack Alert Without Touching Checkout: Event-Driven Side Effects in a Modular Monolith

1. The ask: tell ops the moment a pet tag order is paid

Our company sells pet tags (QR tags that clip onto a pet's collar) through a Flutter mobile app. The operations team wanted to know the moment a tag order was paid, instead of checking an admin console for new orders. The request sounded simple: post a card to the team's Slack orders channel for every paid pet tag order. My first instinct was to open the payment code and add a Slack call right after the "mark as paid" step. That would have worked, but it would also have made the payment code responsible for something that has nothing to do with taking money. Every future "also do X when an order is paid" request would land in the same function, making the most critical code path in the app longer and riskier each time. Then I noticed the backend already had a better seam. When an order is paid, the payment code announces a fact: an event called com.payment_completed.

2. Listen, don't modify: one more subscriber to "payment completed"

This is the publish/subscribe pattern. The publisher (payment) says what happened, and it has no idea who is listening. Several subscribers were already reacting to com.payment_completed: one created the shipment, one mirrored the order to Shopify, and one marked the pet's tag as ordered. Free orders, where a coupon covers the full price, settle through the same "mark as paid" step, so they raise the same event. My Slack alert became just one more subscriber. The whole registration is a small piece of data: a name, the event it listens to, the handler to run, and how it should be delivered. I changed zero lines of payment or checkout code. This is the open/closed principle in practice: the system grew by adding a listener, not by editing working code. Because our backend is a modular monolith, my handler lives in the pettag module and fetches the order through the commerce module's public contract. It never reaches into commerce's internals.

3. Not every subscriber needs the same guarantee

Publish/subscribe has a subtle benefit that I only appreciated while building this: each subscriber can choose how reliable its delivery needs to be. Our event engine offers different delivery lanes. A durable lane stores the event, hands it to a separate worker, retries on failure and keeps a record of what failed. That is the right choice for marking a tag as ordered, because losing that update would be a real bug. A local lane runs the handler in the background inside the same process that published the event. It is fast and simple, but if Slack is down or the process restarts, that one alert is gone. I chose the local lane on purpose. A missed Slack card is an inconvenience, while a missed order update is a problem. The publisher doesn't care about any of this. The same event serves both subscribers, each with the guarantee it actually needs.

4. A side effect must never break the thing it watches

The biggest risk with event-driven side effects is a subscriber that hurts the publisher. If posting to Slack could crash, slow down or roll back an order, the "harmless" alert would become a dependency of checkout. So the subscriber was designed to fail quietly and in isolation. Slack sits behind a small adapter that exposes only is_configured() and send(payload). It never throws an error back to the caller, and a failed post simply returns False and logs a warning. The webhook URL lives in a secret, and if that secret is missing the subscriber does nothing. That makes it safe to run the same code on a laptop, in CI or in any environment, and switching it on means creating one secret. The subscriber also keeps its own payload honest. The card carries only what ops needs, never the buyer's phone number or address, and anything the buyer typed is escaped, so a pet named <!channel> can't ping the whole team.

Publish/subscribe let me add a feature by listening to an event that already existed, choose the delivery guarantee it actually needed, and keep a failure in the alert away from checkout.