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.