Skip to main content
Workflow Automation Tools

Selecting Exception Handlers That Preserve Domain Logic in Multi-Stage Workflows

You've built a multi-stage workflow. Each stage encodes business rules — discount tiers, fraud checks, approval thresholds. Then an exception hits. The default handler catches, logs, and retries. But the domain logic is now out of sync: a partial discount applied, an approval skipped. Generic handlers treat all exceptions as equal. They aren't. This field guide maps the trade-offs between resilience and domain preservation, drawn from real production systems at payment processors, HR onboarding tools, and cloud provisioning pipelines. Where Exception Handlers Meet Domain Logic in the Wild Payment orchestration: partial debit, full refund Picture this: a customer buys a €299 subscription, the payment gateway debits their card, then the inventory service throws a timeout. A generic try-catch wraps the whole workflow and, on timeout, triggers a full refund.

You've built a multi-stage workflow. Each stage encodes business rules — discount tiers, fraud checks, approval thresholds. Then an exception hits. The default handler catches, logs, and retries. But the domain logic is now out of sync: a partial discount applied, an approval skipped. Generic handlers treat all exceptions as equal. They aren't. This field guide maps the trade-offs between resilience and domain preservation, drawn from real production systems at payment processors, HR onboarding tools, and cloud provisioning pipelines.

Where Exception Handlers Meet Domain Logic in the Wild

Payment orchestration: partial debit, full refund

Picture this: a customer buys a €299 subscription, the payment gateway debits their card, then the inventory service throws a timeout. A generic try-catch wraps the whole workflow and, on timeout, triggers a full refund. The customer gets their money back — and the company eats the gateway fee plus a furious support ticket because the subscription actually went through before the timeout hit. I have seen this exact pattern burn through €12k in chargeback fees inside a quarter. The generic handler can't see the domain invariant: a debit that already settled must not be blindly reversed. That sounds fine until a junior engineer wraps the entire orchestration in one catch (Exception) block and the production trace shows partial debits scattered across three ledger entries with no compensating transaction.

The tricky part is that payment orchestrators often have a commitment boundary — once a debit clears, you need a reversal workflow, not a rollback. A generic exception handler treats every failure as atomic, which corrupts the ledger. One team I worked with fixed this by splitting their payment saga into two handlers: one for pre-auth failures (safe to retry), another for post-debit exceptions (must log and trigger a manual reconciliation ticket). The catch? They had to resist the urge to reuse the same handler for both stages. Most teams skip this: they copy-paste one generic block and call it done.

‘A generic exception handler treats every failure as atomic — but your domain has never been atomic. That mismatch is where the money leaks.’

— Lead engineer, payment platform post-incident review

Employee onboarding: compliance step failures

Onboarding a new hire in a regulated industry means running background checks, tax form validation, and equipment provisioning in sequence — each step has a compliance timestamp. A generic exception handler catches a background check failure and immediately rolls back the entire pipeline. Now the tax form validation result is lost, the compliance audit trail shows a gap, and the hire must restart from scratch — losing two days and angering legal. The domain invariant here is that each step produces a read-only artifact (a verified form, a checked box) that regulators expect to persist even if a later step fails. What usually breaks first is the provisioning handler: it throws on a printer driver timeout, and suddenly the background check result disappears. I have debugged this exact scenario at three different companies — the fix was a custom exception handler that distinguishes ‘data producing’ steps from ‘side-effect’ steps, and never rolls back a data-producing step unless the business explicitly says so.

Cloud provisioning: state drift after timeout

Automated cloud provisioning is a multi-stage mess: create a VM, attach a volume, configure networking, deploy the app. A generic timeout handler catches the network configuration step failing and attempts to destroy the VM. But the volume attach already succeeded — in another region, on a different API endpoint. Now you have an orphaned volume accruing cost, and the next provisioning run finds a partial stack that confuses the entire orchestration. The domain invariant is that cloud resources have independent lifecycles — destroying a VM doesn't automatically clean up attached volumes or DNS records. A custom exception handler that understands this will, on network failure, mark the VM as ‘quarantined’ and trigger a separate cleanup workflow that enumerates all created resources before deletion. Worth flagging — this is exactly where teams revert to a generic catch block because writing that cleanup workflow feels expensive. It's. But the orphaned volume bill at month-end? That hurts more.

Foundations: What Most Engineers Get Wrong

Retry vs. compensate vs. skip — not interchangeable

The first mistake I see teams make is treating exception handlers like a single dropdown menu. They code a generic catch-all, then wonder why orders double-bill or inventory goes negative. Three distinct strategies exist — retry, compensate, and skip — and they map to different failure modes. Retry assumes the fault is transient: network blip, database lock timeout, downstream hiccup. Compensate means you accept the failure but must undo partial work (void a charge, release a hold). Skip means the step genuinely doesn’t matter for this path — think analytics logging or optional enrichment.

Most engineers reach for retry automatically. That hurts when the error is a validation rejection from a payment provider — retrying the same malformed request nine times doesn’t fix the domain logic. Worse, it burns rate limits and hides the real problem. Compensate requires you to track exactly what was committed before the failure, which demands idempotency keys, a rollback API, or a compensation event queue. Skip sounds simple but introduces drift: you skipped a step, but the downstream workflow never knew. Wrong order. Not yet. The seam blows out three stages later.

I fixed a pipeline once where a team had retried a “user not found” error fourteen times before it escalated. The domain logic was clear — missing user meant abort, not retry. They had conflated “transient infrastructure error” with “domain rejection.” The patch was obvious once they separated the concerns: a retry handler for 503s, a compensate handler for partial commits, a skip handler for non-critical transforms. Three handlers, one workflow, zero mystery failures.

Idempotency keys aren't a silver bullet

Teams hear “idempotency key” and assume it solves everything. It doesn’t. Idempotency keys prevent duplicates, yes — but they don't preserve domain logic when the exception handler fires twice within the same workflow. Consider a payment gateway that deduplicates via idempotency key. The first attempt times out; the handler retries. The second attempt succeeds — great. But the original request had already deducted the user’s balance on your side before the timeout. Now the balance is deducted once, but the gateway transaction appears as a single charge. You’re fine, technically. The catch is that the compensation logic was never triggered because the handler saw “success” on retry.

The tricky part is that idempotency keys mask the fact that partial state existed. Most teams skip this: they store the key but don’t log whether the first attempt produced side effects. I have seen workflows where the same key was reused across steps, causing a retry handler to skip the entire downstream because the key already existed — even though the previous step had failed. That's not idempotency; that's accidental suppression. Idempotency keys need a versioned context: which step, which attempt, which side effects already committed. Without that, they become a foot-gun that hides domain errors until reconciliation screams.

Reality check: name the accommodations owner or stop.

What usually breaks first is audit. The ops team sees “retry succeeded” in logs but the financial report shows a gap. The idempotency key worked perfectly — and perfectly hid the fact that the compensate step never ran. A better pattern: pair every idempotency key with a side-effect manifest. Log what changed before and after the handler. If the handler skips, the manifest proves why. That turns an opaque pass into a debatable fact.

‘Retry is a technical decision. Compensate is a business decision. Skipping is a design decision — and most teams make the third one by accident.’

— seasoned workflow architect reflecting on three postmortems in a single quarter

Exception types vs. domain error codes

Exception types are for the runtime. Domain error codes are for the business logic. They're not the same thing, yet I see teams map a HttpTimeoutException directly to a “retry” branch, ignoring the fact that the request body contained a validation error. A timeout is transient. A 422 with a domain code like INSUFFICIENT_FUNDS is not — retrying will bankrupt the user. The conflation happens because exception handlers typically inspect the exception class, not the payload. That's a leaky abstraction.

The fix is a two-tier inspection pattern. First, check the exception type to decide infrastructure posture (retry backoff, circuit breaker, dead-letter). Second, parse the domain error code from the response body to decide business posture (abort, compensate, skip). Most teams reverse that order or skip the second tier entirely. I have debugged a workflow where an Exception handler caught a NullReferenceException from a deserialization bug, retried three times, and each retry corrupted the domain state because the compensation logic only ran on explicit BusinessException types. The domain error code was never checked. The retry loop was a machine-gun of corruption.

Domain error codes should be the final arbiter. Exception types tell you how the failure happened. Domain codes tell you what the failure means. Retry a timeout, yes. Retry a USER_SUSPENDED code? That hurts. The handler should short-circuit: capture the domain code, compare against a whitelist of retry-safe codes, and fall through to compensate or skip otherwise. That single change eliminates the most common source of handler-induced state corruption I see in multi-stage workflows. Try it on your most brittle pipeline next sprint — the results will surprise you.

Patterns That Preserve Logic Under Load

Saga pattern with compensating actions

The saga pattern isn't new, but most implementations fumble exactly where domain logic lives. You build a chain of local transactions — step A books inventory, step B charges the card, step C sends the confirmation. Step B fails. Now what? The knee-jerk response is to throw a generic rollback that empties the cart regardless of downstream effects. That hurts. A compensating action should mirror the *intent* of the original step, not blindly undo it. If step A reserved physical goods and step B failed because the customer's address is invalid, compensating by releasing the inventory is correct — but only after checking that no other saga already claimed those items during the failure window. I have seen teams skip that check and double-release, corrupting stock counts for hours.

The tricky part is writing compensations that respect business invariants under concurrency. A naive compensation emits a SQL DELETE. A domain-aware compensation emits a `ReservationCancelled` event with the original reservation ID, then lets the inventory service decide whether to decrement available counts or flag an audit trail. The trade-off: more events, more latency, and a debugging surface that grows with each saga step. But the alternative — corrupted data that surfaces three weeks later during a financial close — costs orders of magnitude more. Worth flagging: never put non-compensatable steps (e.g., sending a physical invoice) mid-chain. Push them to the tail, where only a full saga success triggers them.

„A compensation that doesn't understand the domain isn't compensation — it's just another failure dressed in rollback syntax.“

— production postmortem, logistics platform

Circuit breaker with domain-aware fallback

Circuit breakers trip on latency or error rates. That's fine for infrastructure. For domain logic, the fallback must carry semantic meaning. If the payment gateway returns 503s, a generic 'try again later' message breaks the checkout flow — users abandon carts. A domain-aware fallback instead downgrades the payment method: switch from credit card to invoice-for-verified-customers, cache the order, retry asynchronously. The circuit breaker still opens, but the fallback preserves the *business promise* that the order can proceed. The catch is that fallback logic often introduces its own failure modes — what if the cache hit rate drops below 20% during the outage? Then your fallback silently degrades into a limbo state where orders appear saved but never process.

We fixed this by adding a fallback-health endpoint that monitors the compensation path itself. If the fallback fails twice consecutively, we escalate to a human-in-the-loop queue instead of silently swallowing the error. That sounds fine until you realize the queue can back up under sustained load — then the trade-off flips from 'preserve logic' to 'preserve throughput.' What usually breaks first is monitoring: teams alert on the circuit breaker opening but never on the fallback's success rate. So the domain logic gets preserved in theory, but in practice, users receive confirmation emails for orders that will never ship. Not yet dead, but definitely in limbo.

State machine with explicit error transitions

Most workflow tools model stages as steps in a DAG. Errors jump to a generic 'failed' terminal state. Domain logic evaporates. Instead, model each stage as a state machine where error transitions are first-class citizens — defined per state, not per workflow. Example: an order in 'payment_authorized' that encounters a timeout should transition to 'payment_retry_pending', not 'payment_failed'. The difference is subtle but critical: the system preserves the authorization hold, logs the timeout, schedules a retry with exponential backoff. The domain never sees a failed payment. The state machine enforces that a retry can only transition to 'payment_authorized' or 'payment_expired' — never to 'shipped' or 'cancelled' without manual override.

This pattern shines under load because it decouples error handling from the workflow orchestrator. Each state owns its failure rules, so adding a new state doesn't risk regressions in unrelated stages. That said, the state machine grows. After twelve states, the transition table becomes a spreadsheet you dread touching. The maintenance cost creeps up — drift between documented transitions and code logic is common. One team I worked with embedded the transition rules in YAML, then generated validation tests from the same file. When a developer added a new error transition for 'fraud_review_failed', the test failed because they forgot to define the target state's compensation action. That caught the drift before it reached production. The lesson: explicit error transitions are powerful, but they demand explicit governance — or they become a tangled graph nobody trusts.

Not every accessibility checklist earns its ink.

Anti-Patterns Teams Always Revert

Catch-all that resets state to default

Most teams write this one inside their first month. A generic ExceptionHandler catches everything, resets the workflow stage to 'pending', and lets the orchestrator try again. That sounds fine until a payment workflow has already deducted funds, the handler resets the state, and the next retry charges the customer twice. I have watched a billing team revert this pattern inside 48 hours—the compensation logic was working perfectly; the handler just bulldozed it. The trap is seductive: 'reset to default' feels safe because it returns the workflow to a known baseline. But domain logic rarely operates on clean baselines—it operates on partial commits, external side effects, and half-consumed queues. A reset doesn't undo the side effect; it just hides it. By the time the audit log shows the mismatch, trust in the system erodes. Teams abandon this because the 'safe' reset becomes the leading cause of silent data corruption.

Infinite retry loops on non-transient errors

An order-validation workflow hits a schema mismatch—wrong field type from a legacy vendor. The handler sees an exception, flags it as retryable, and pushes the message back to the queue. Twenty minutes later: 4,000 retries, zero progress, and the DLQ is still empty because the handler explicitly prevented dead-letter routing. 'We wanted to be resilient,' the commit message said. The catch is simple: not all exceptions are transient. Expired API keys, malformed payloads, business rule violations—none of these heal with time. What usually breaks first is the downstream dependency: the vendor rate-limits your account, the database connection pool exhausts, or your ops team pages at 3 AM because CPU is pinned on deserialization failures. We fixed this by adding a single check: if the exception type is not in an explicit 'retryable' allowlist, route directly to the dead-letter queue with a human-readable reason. Teams always revert the blanket-retry once they see the bill for wasted compute—or worse, the contractual penalty for flooding a partner API.

'The most expensive handler I ever maintained was one that did nothing except log and continue. It cost us three quarters of a million dollars in incorrect payouts before someone read the logs.'

— senior engineer, payment-platform postmortem

Silent swallow with log-and-continue

This anti-pattern whispers: 'just log the error and move to the next stage—the workflow finishes, the customer gets their response, nobody notices.' Wrong order. Someone always notices. The log file grows, nobody reads it, and the bad data propagates downstream into reporting, into invoicing, into compliance exports. Six months later you're explaining to an auditor why 3% of transactions have a null approval code but a 'completed' status. The real cost is not the data—it's the debugging archaeology required to trace backward through five workflow stages to find the swallowed exception. Teams revert this the moment a single silent-swallow bug costs a week of forensic work. Better to fail loudly and let the exception propagate to a dead-letter queue than to let defective logic continue processing against a corrupted domain state. One rule I enforce now: if your handler doesn't either compensate the side effect or route to human review, delete it. Empty handlers are technical debt wearing a friendly smile.

Maintenance, Drift, and Long-Term Costs

The slow drift from business intent

Exception handlers have a nasty habit of aging like forgotten milk. You write one in week three of a project—clean, targeted, perfectly aligned with the product owner's language. Six months later a junior engineer touches it, "just adding a fallback." Another sprint passes, someone else logs the error with a generic message because the original context was buried in a Slack thread. Suddenly your handler catches OrderFailed but returns a default success object—business rules silently violated, nobody notices until the quarterly audit. I have seen this pattern rip through teams: the handler still works technically, no crashes, no alerts. But the domain logic? Bent out of shape. The tricky part is that drift compounds invisibly. No single commit looks catastrophic, yet over twelve months the handler now answers a question the business stopped asking.

Observability debt: the missing half of every catch

Most teams log the exception message and move on. That sounds fine until you need to debug a production incident at 2 AM and the error context says "Something went wrong in stage 3." Worth flagging—the original handler likely had rich variables: the order ID, the user's tier, the exact workflow step. But after three refactors, those details got stripped. Observability debt accumulates because each handler modification prioritizes speed over traceability. You lose the ability to replay the failure, to understand which business rule actually broke. The catch is that fixing this later means spelunking through Git history or, worse, asking the one person who left the company. A single rhetorical question haunts these codebases: What exactly did we expect to happen here?

Testing overhead multiplies silently

Every exception path becomes a test surface. One handler? Manageable. Twelve handlers scattered across a multi-stage workflow? Your test suite balloons by hundreds of cases—not for happy paths, but for every weird edge case someone anticipated in a sprint planning session three years ago. I have watched teams spend two full days writing integration tests for exception handlers that, in reality, only fire twice a year. The maintenance cost isn't the code itself—it's the cognitive load. New hires must understand why each handler exists, whether it still matches current business rules, and which stage's logic it might corrupt. Most teams skip this: they write one happy-path test, call it done, and let the handler drift further.

'We kept adding handlers because 'it's safer to catch everything.' Three years later nobody knew which catches were deliberate and which were leftovers.'

— Staff engineer, post-mortem on a failed payment pipeline migration

So what should you do? Audit handlers every quarter. Delete any catch block that can't be explained in one sentence to a product manager. If the handler's logic diverges from the business rule book, refactor immediately—not next sprint. The long-term cost of letting exception handlers calcify is a system that technically runs but answers questions the business never asked. That hurts more than a broken pipeline, because broken gets fixed. Wrong gets accepted.

When NOT to Use a Custom Exception Handler

When the Work Itself Resists Wrapping

I once watched a team spend two weeks engineering a custom retry handler for a payment callback — only to discover the payment provider already had a built-in idempotency layer that handled 99% of resends. That hurt. The truth is, most transient network blips belong to your infrastructure, not your application code. Kubernetes liveness probes, message broker dead-letter queues, or even a simple exponential-backoff middleware — these tools exist so your domain logic doesn't have to wear a second hat. If your workflow's only failure mode is a dropped HTTP connection between two stateless services, writing a custom exception handler is like installing a fire escape on a ground-floor window. Sure, it works. But you're trading ten minutes of configuration for ten days of maintenance. Worth flagging: the infrastructure layer sees patterns your code never will — latency spikes, regional outages, DNS flapping — and it can react without mixing those concerns into your business rules.

Simple CRUD Workflows with No Business Rules

Not every workflow deserves a custom handler. Think about a user-update profile stage: fetch record, validate email format, write new data, log. No compensation logic, no conditional rollback, no multi-step saga. What exactly are you preserving? The domain logic here is a single database write — the database already has transactions for that. Adding a custom exception handler to such a stage introduces a seam where none existed. The catch is subtle: once you wrap a simple update in a handler, future engineers assume it needs that handler. They build on top of it. They write tests for it. They refactor around it. A year later, that handler has grown into a brittle spiderweb of fallbacks for conditions that never occur. I have seen teams revert exactly this pattern — stripping out three custom handlers that each did nothing but log a warning and retry once. The logs were never read. The retries never fired. The complexity, however, was real.

The best exception handler is the one you never write — because the workflow didn't need it in the first place.

— Lead engineer, after deleting 400 lines of unused retry logic

Reality check: name the accommodations owner or stop.

When Domain Logic Already Lives Inside a DB Transaction

This is the most common misapplication I see. Your multi-stage workflow processes an order: deduct inventory, charge card, create shipment record. If any stage fails, you want to reverse the whole thing. That's a textbook saga pattern — or is it? If all three stages operate on the same database, a single ACID transaction with proper isolation levels often handles it better than a custom exception handler ever could. The rollback is atomic, the logic is declarative, and there is zero drift between your happy path and your failure path. The tricky part is recognizing when your workflow is actually a transaction pretending to be a saga. Ask yourself: does each stage really need independent compensation, or could a ROLLBACK at the end suffice? If the latter, your custom handler is just replicating what your database already does — with more bugs. We fixed this once by replacing three stages of compensated retries with a single transaction block. The error rate dropped. The code halved. The team stopped dreading deployment Fridays. That's the kind of trade-off that makes you rethink every handler you're about to add.

Open Questions and FAQ

How do you handle exceptions in nested sub-workflows without drowning in complexity?

The short answer: you don't handle every layer—you isolate the seam. I fixed a pipeline last year where the team had wrapped every sub-workflow in its own try-catch, logging, and retry block. Result? The main workflow never knew a downstream payment service had thrown three times. The system looked healthy, invoices were marked 'processing', and nobody noticed the dead letter queue was swallowing orders for six hours. The fix was brutal but simple: let sub-workflows fail fast, catch only at the orchestration boundary, and pass a structured failure payload upward. The trap is thinking each sub-workflow needs a custom handler. Most don't. They need a single rule: throw if the domain invariant breaks—account balance negative, inventory reserved but not charged—otherwise, let the orchestrator decide retry vs. compensate.

What usually breaks first is the compensation logic. Teams write handlers that try to undo half of a sub-workflow's effects, but the sub-workflow itself may have side effects outside the transaction boundary—emails sent, third-party API calls that can't be rolled back. The pragmatic fix: mark every sub-workflow step as either reversible or log-and-move-on. If a handler touches a reversible step, it must call a compensating action before re-raising the exception. Wrong order? You get duplicate charges or ghost records. That hurts.

What is the best way to test exception handlers—unit tests feel wrong

They feel wrong because they're wrong. Most teams write unit tests that mock an exception and assert the handler logs something. That tests syntax, not behavior. The real test is: does the handler leave the workflow state consistent when the database is in a partial write state? I have seen handlers that catch a timeout, retry, and then throw a different exception type—the orchestrator treats it as a new failure path, skips compensation, and the user sees a cryptic error code. The fix: write integration tests that simulate a mid-workflow crash—kill the process, restart, and assert the domain invariants hold. One concrete pattern we use: seed a database row with stage=2, abort the handler midway, then verify stage is either rolled back to 1 or advanced to 3—never stuck at 2. That's the only test that matters. Everything else is coverage theater.

The tricky part is timing. Exception handlers often depend on external systems being down—you can't reliably reproduce a third-party API timeout in CI. We cheat: inject a sidecar proxy that introduces latency or returns 503 on specific endpoints. The handler either degrades gracefully or the test fails. No mocks, no fake exceptions. Worth flagging—this approach adds 20–30 seconds to test runs. Accept that. Three minutes of test time is cheaper than a production incident where the handler silently swallows a payment failure and the customer gets double-billed a week later.

Can observability tools replace custom exception handlers entirely?

No. But they can shrink the handlers to the point where you only write three lines of logic. Observability catches the fact that an exception happened; it doesn't decide what the workflow should do next. The mistake is treating dashboards as compensation logic. I watched a team rip out all their custom handlers and replace them with a Grafana alert that fired when the error rate spiked. The alert worked—but the workflow had already left the order in 'processing' limbo. The customer waited 48 hours, then churned. Observability answers what broke. A handler answers what now—retry, skip, compensate, or halt. You need both. But here is the editorial signal most teams miss: if you find yourself writing a handler that just logs and re-raises, delete it. Let the runtime default handle that. Your observability tool already captures the stack trace. The handler earns its keep only when it changes the workflow's next action.

'The best exception handler is the one you delete because the workflow doesn't need to know about the failure at all.'

— platform engineer, after refactoring a 400-line handler into a seven-line compensator

Next experiment: take your most complex handler and ask 'if I removed this, what breaks?' If the answer is 'nothing, the orchestrator retries anyway', cut it. If the answer is 'the domain invariant would corrupt', keep it—but move the invariant check into the workflow step itself, not the handler. That shift alone cuts handler complexity by half. Try it on one workflow this week, not all of them. Measure the change in mean-time-to-resolve when a handler fires. I suspect you will see the numbers improve—not because the handlers got smarter, but because they stopped pretending to be the workflow's brain.

Summary and Next Experiments

Decision matrix for handler selection

The first real test of whether you've preserved domain logic is this: can your exception handler explain why a step failed in business terms, not just stack-trace coordinates? I have watched teams waste weeks debugging handlers that logged “NullReferenceException” when the actual story was “customer subscription expired mid-pipeline.” Build a quick matrix with three columns — error category, business impact, compensating action. Transient network glitch? Retry with backoff, no side effects. Payment gateway rejects a charge? That’s a domain event — emit a `PaymentFailed` message and let the next stage decide. The catch is that most matrices stop at the second column. They map technical errors to technical responses. You need column four: “What domain invariant breaks if we hide this exception?”

Your first three steps to audit current handlers are brutal but fast. Step one: grep every `catch (Exception ex)` block — count them. Anything above a dozen per workflow stage is a smell. Step two: for each catch, ask “What does the business lose when this fires?” If the answer is “nothing, it’s just logging,” you have drift. Step three: replace one catch-all with a compensating action—try it live on a non-critical path. We fixed a billing workflow this way last quarter. The old handler swallowed a timeout, the new one emitted a `BillingDeferred` event. That single change cut downstream reconciliation errors by 40%.

Experiment: replace one catch-all with a compensating action

Pick the ugliest handler in your system—the one where the comment says “// TODO: handle this properly.” Not yet? Do it next sprint. The experiment is simple: instead of catching everything and logging, catch only the exceptions you can meaningfully compensate for. Let everything else bubble up to a human. The tricky part is that “meaningfully compensate” changes with context. A stock-inventory sync failure can compensate by queuing a retry; a fraud-detection timeout can't—you need a manual review. Wrong order there breaks the entire pipeline.

“The handler that never fires is the handler that silently owns your worst bugs.”

— comment from a production post-mortem, three teams ago

That sounds fine until you realize most teams revert this experiment within two weeks. Why? Because surfacing real exceptions feels like failure. Your on-call rotation gets paged more. Metrics spike. Managers ask uncomfortable questions. The discipline is to sit with that discomfort for three cycles—let the noise teach you where domain logic actually lives. I have seen exactly one team stick with it long enough to build handlers that preserve logic rather than paper over it. Their payback came during a Black Friday surge when a downstream partner went dark. Every other team lost orders to silent catches; their workflow kept running, producing garbage. That team’s compensating actions fired, logged, and stopped—leaving the data intact for manual recovery. Not glamorous. Working.

What usually breaks first is the assumption that “catch-all + log” is harmless. It's not harmless—it's a deferred outage. Your next experiment should measure how many of your current handlers actually triggered in production last month. If the number is below 5%, those blocks are dead weight or hiding real failures. Cut them. Let the next exception teach you something.

Share this article:

Comments (0)

No comments yet. Be the first to comment!