Published on

Designing with failure boundaries

avatar for Jigar PatelJigar Patel
3 min read

When people ask me how I structure a service, I usually stop talking about microservices and start with a simpler rule:

Identify what can fail, then isolate it.

This sounds obvious, but it changes every architecture decision in practice.

My rule: every boundary has an owner

I use three layers whenever I start a new component:

  • Input boundary: validates external trust and shape.
  • Processing boundary: owns business logic and side effects.
  • Output boundary: owns retries, idempotency, and fallback behaviors.

I write this in a design doc before any code. The goal is not to overcomplicate; it is to make failure recovery cheap.

What I changed in my own pipeline

I mapped a simple data flow:

  1. Ingest API receives data and verifies schema.
  2. Transform worker performs enrichments.
  3. Storage sink persists canonical output.
  4. Audit stream stores operationally relevant events.

Then I asked three concrete questions:

  • If the API goes down, how do clients observe backpressure?
  • If transform crashes on one payload, do we lose all work?
  • If storage throttles, can retries amplify the problem?

The answers became implementation tasks, not architecture slides.

Concrete checklist I reuse

  • Add strict schema validation at the input edge.
  • Make every side effect idempotent by design.
  • Separate transient and permanent failures.
  • Add a dead-letter path for irrecoverable items.
  • Make timeouts explicit and test them.
  • Verify no single upstream failure can block unrelated flows.

Retry rule of thumb

I keep retries boring:

  • 0–1 retries for validation failures (never retry bad data).
  • Exponential backoff for downstream dependency timeouts.
  • Circuit breaker after consecutive failures to protect the rest of the system.

Step-by-step pattern I follow for a new service

# 1) validate the shape of local schema contracts
node -e "require('fs').readFileSync('./schemas/input.json');\
  require('fs').readFileSync('./schemas/output.json')"

# 2) generate a failure matrix in a markdown table
printf "| callsite | failure mode | recovery |\n" >
  docs/failure-matrix.md
printf "| ingest | timeout | retry with backoff |\n" >> docs/failure-matrix.md

# 3) implement input validation before business logic
npm run test:contract

Why it works in the real world

This pattern makes incidents smaller. If one boundary fails, I can usually fix one bounded area without touching others.

More importantly, it keeps my team from debating architecture forever when the product needs shipping decisions now.

When you design for failure boundaries, you design for what actually breaks.