Daisy Chain Orchestration

Chaining API calls through each other instead of orchestrating from a central point creates fragile, undebuggable systems.

The Wrong Way

Each service calls the next service in a chain:

Your Code -> Service A -> Service B -> Service C -> Service D
// Looks simple, but Service A's webhook calls Service B,
// which calls Service C, which calls Service D.
const result = await fetch('https://api.solenoid.systems/v1/relay/trigger', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${apiKey}` },
  body: JSON.stringify({
    webhook: 'https://your-service-a.com/process',
    payload: { orderId: order.id }
  })
});

What Goes Wrong

Mystery failures. When Service C fails, you get a generic error from Service A. The actual error is buried three services deep. Debugging requires access to every service’s logs.

No partial retry. If charging succeeds but provisioning fails, the customer is charged with nothing to show for it. Retrying from the top would double-charge them.

Timeout explosion. Each service has a 30-second timeout. Four services deep means up to 120 seconds of actual wait time. Your request times out at 30 seconds, but downstream services keep running as orphaned processes.

No visibility. Which step are we on? How long did each step take? Each service only knows its own context.

The Right Way

Use a central orchestrator that calls each service directly:

                 +-> Service A (Process)
Your Code -------+-> Service B (Charge)
(Orchestrator)   +-> Service C (Provision)
                 +-> Service D (Notify)
async function processOrder(order) {
  const steps = [];

  const processed = await callServiceA(order);
  steps.push({ step: 'process', status: 'success' });

  const charged = await callServiceB(order);
  steps.push({ step: 'charge', status: 'success' });

  const provisioned = await callServiceC(order);
  steps.push({ step: 'provision', status: 'success' });

  // Non-critical: log failure but don't abort
  try {
    await callServiceD(order);
  } catch (err) {
    console.warn('Notification failed:', err.message);
  }

  return { success: true, steps };
}

Each step has its own error handling. If step 2 fails, you know it was charging. You can retry just that step. You can roll back step 1 without touching step 3.

Using Solenoid for Orchestration

Relay for async steps

Queue each step with independent retry policies:

await relay.send({
  webhook: 'https://your-api.com/orchestrate/step2',
  payload: { orderId: order.id, step: 'process' },
  delay: 0
});

Your /orchestrate/step2 endpoint verifies step 1 completed, executes step 2, then queues step 3 via Relay. Each step is independently retryable.

Latch for exactly-once processing

Prevent duplicate processing with distributed locks:

const lock = await latch.acquire(`charge:${order.id}`, { ttl: 60 });
if (lock.status === 409) return { status: 'in_progress' };

try {
  return await stripe.charges.create({ amount: order.total });
} finally {
  await latch.release(`charge:${order.id}`);
}

Witness for audit trail

Log each step with cryptographic proof:

await witness.stamp('order.charged', {
  orderId: order.id,
  chargeId: charge.id,
  timestamp: new Date().toISOString()
});

Checklist

Before deploying multi-service workflows:

  • Can you identify exactly which step failed from your logs?
  • Can you retry just the failed step without repeating earlier steps?
  • Do you know the total time budget across all steps?
  • Do you have rollback logic for each step that can fail?
  • Are non-critical steps separated from critical steps?

If any answer is no, you probably have a daisy chain.