2026.07.03

strangling a webhook monolith: incremental refactoring using adrs

how i broke down a 13,000 line routing file without losing live production events and breaking client integration points.

a webhook route is the worst place for a system to grow.

it starts with signature verification and a direct save. then comes parsing variations, custom actions, event routing, direct message automation and partner state sync. before you know it, a single webhook.py file is handling 13,000 lines of different jobs.

every change becomes a danger to production. a syntax mistake on a new WhatsApp promo flow drops the main Instagram payment route.

this is how i broke one down without a big-bang rewrite. this is a story of how i was tasked with modularizing a single heavy webhook file that was used by an AI commerce startup in production.

the context and the risk

the monolith in question handled webhook deliveries from Meta.

it checked signatures, parsed raw payloads, managed conversation histories and dispatched events to background workers. those background tasks were running in-process inside a simple thread pool.

if Meta sent an event, the system acknowledged it with a 200 OK and queued the processing. but if the application container restarted immediately after that response, the queued task was lost.

the team wanted to fix the reliability issues and extract distinct channels into their own modules. but work on new features could not stop while we rewrote the core routing engine.

writing the ADR first

i did not start by deleting code. i started by writing an Architecture Decision Record (ADR).

an ADR records the context, constraints, decisions and consequences of an architectural choice. it is not a design document that suggests ideas; it is a record of what was decided and why.

for our webhook refactoring, the constraints were clear:

  • public webhook URLs had to remain unchanged;
  • we could not add new infrastructure before stabilizing the processing boundary;
  • all changes had to be merged in small, production-ready iterations.

by writing these constraints down, the team agreed to reuse the existing PostgreSQL database to build a durable transactional queue rather than introducing Celery immediately.

the strangler pattern in action

instead of rewriting all routing logic at once, i wrapped the old file inside a Strangler Fig boundary.

the entry route on the controller was simplified to perform only authentication, payload validation and storage.

python
@router.post(class="syntax-string">"/webhook") async def receive_webhook(request: Request, db: Session = Depends(get_db)): payload = await request.json() class=class="syntax-string">"syntax-comment"># verify signature class=class="syntax-string">"syntax-comment"># write to durable inbox db_inbox = WebhookInbox(payload=payload, status=class="syntax-string">"pending") db.add(db_inbox) db.commit() return {class="syntax-string">"status": class="syntax-string">"accepted"}

once the event is in the WebhookInbox table, the HTTP request is finished. the caller gets a fast acknowledgment.

a separate scheduler worker leases pending inbox rows and runs the actual processing. this shifted the system from volatile in-process threads to a durable, database-backed at-least-once queue.

channel adapters and canonical messages

with the ingestion secured, i extracted channel specific logic.

raw payloads from Instagram and WhatsApp are very different. instead of letting downstream services parse these raw models directly, i introduced adapters.

an adapter takes the raw JSON from a provider and transforms it into a clean, canonical message structure:

python
class CanonicalMessage: def __init__(self, message_id: str, sender_id: str, text: str, channel: str): self.message_id = message_id self.sender_id = sender_id self.text = text self.channel = channel

now, the core conversation router only needs to accept a CanonicalMessage and coordinate downstream handlers. the handlers do not know or care about Meta's specific nested JSON keys.

i migrated the WhatsApp flow to this new adapter and router model first, leaving the legacy Instagram code running through the original compat wrappers in the webhook.py. the monolith was strangled page-by-page.

mock integration tests as a safety net

refactoring without tests is just guessing.

i wrote integration tests that mocked the network boundaries and raw payload deliveries. before any code was split, i ensured the test suite verified that:

  • raw payloads were correctly stored in the WebhookInbox;
  • duplicate webhook IDs were discarded safely (idempotency);
  • conversation state remained consistent when multiple workers pulled events concurrently.

these tests ran on every PR. if a change broke a legacy routing path, the tests caught it before the deployment touched staging.

pragmatism over perfection

it is tempting to look at a 13,000 line file and declare that it must be destroyed.

but code that is running in production is code that is making money. strangling the monolith incrementally allowed us to ship security fixes, database migrations and new dashboard features on the same files i were refactoring.

the old code still exists in wrappers, but it is shrinking. we did not have to stop the business to rebuild the plumbing.