A single shared stack is a single shared outage. Cell-based architecture partitions tenants into independently deployable cells — separate ECS services, DynamoDB tables, queues, and alarms — so a bad canary or noisy neighbor takes down one cell, not the fleet. The hard parts are routing, cell sizing, and avoiding a shared control plane that reintroduces global blast radius.
⚡ TL;DR: Map tenant → cell via a highly available directory; keep data plane fully cell-local. Deploy cell-by-cell with per-cell canaries. Cap cell size by blast-radius SLO. Pair with Lambda Reserved Concurrency Bulkheads and Multi-Tenant Rate Limits.
Cell directory and routing
// cell/directory.ts
export type CellId = `cell-${string}`;
export async function cellForTenant(tenantId: string): Promise<CellId> {
const row = await directory.get(`tenant#${tenantId}`);
if (!row) throw new Error("unknown_tenant");
return row.cellId as CellId;
}
export async function route(req: ApiRequest) {
const cell = await cellForTenant(req.tenantId);
// ✅ Edge/API Gateway custom domain or internal router sends to cell VPC endpoint
return forward(cellEndpoints[cell], req);
}
Keep the directory small, multi-AZ, and cached with short TTL. It is allowed to be shared; the data plane must not be.
What lives inside a cell
| Shared (control) | Cell-local (data plane) |
|---|---|
| Tenant → cell map | DynamoDB tables / Aurora |
| Global DNS / auth issuer | SQS / Kafka topics |
| Billing aggregates (async) | ECS services / Lambdas |
| Cell provisioning pipeline | Secrets, IAM roles, alarms |
# infra/cell-stack.yml (illustrative CDK/CFN shape)
Resources:
OrdersTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: !Sub orders-${CellId}
WorkerService:
Type: AWS::ECS::Service
Properties:
ServiceName: !Sub worker-${CellId}
DesiredCount: 6
Deploy without global canaries
# deploy/cell_rollout.py
def rollout(cells: list[str], wave_size: int = 1):
for wave in chunk(cells, wave_size):
for cell in wave:
deploy_cell(cell) # ✅ independent artifact + params
wait_canary(cell, slo="error_rate<0.5%")
# stop the train if any cell fails canary
if any_failed(wave):
halt_and_page(wave)
return
❌ Pushing one giant ECS deploy across all cells simultaneously — you just rebuilt the monolith outage.
Sizing cells
| Signal | Action |
|---|---|
| Tenants/cell > budget | Split cell (migrate subset) |
| Noisy tenant saturates cell | Move tenant to dedicated cell |
| Cell deploy > 30 min | Too big — shrink |
| Cross-cell chatter rising | Wrong boundaries — redesign |
Migration is a first-class feature: dual-write or dual-read during move, then flip directory.
Closing checklist
✅ Dos
– ✅ Keep data plane 100% cell-local
– ✅ Route via tenant→cell directory with short cache TTL
– ✅ Canary and rollback per cell
– ✅ Size cells against blast-radius SLOs
– ✅ Support tenant move / dedicated cells
❌ Don’ts
– ❌ Don’t share a DynamoDB table “for convenience”
– ❌ Don’t make the directory a chatty per-request bottleneck without cache
– ❌ Don’t deploy all cells in one wave
– ❌ Don’t put shared Redis in the critical path for every cell
– ❌ Don’t ignore cross-cell analytics as a new single point of failure
Related reading
- Lambda Reserved Concurrency Bulkheads That Protect Tenant Workloads
- Multi-Tenant Rate Limits: Redis Cluster Token Buckets Without Hot Keys
- Secure AI Sandboxes: Ephemeral ECS Tasks
- CQRS Boundary Criteria
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
