Cell-Based Architecture on AWS: Limit Blast Radius Across SaaS Tenants

Cell-Based Architecture on AWS: Limit Blast Radius Across SaaS Tenants

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

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply