Skip to content
Ayhan Sipahi Ayhan Sipahi

Multi-Account AWS Architecture: Event-Driven Systems at Scale

Multi-account AWS architecture patterns for resilient event-driven systems: account structure, EventBridge routing, and cross-service communication.

Single-Account Architecture Limits

Multi-account AWS architecture becomes essential when organizations reach certain scale and complexity thresholds. The shape worth defaulting to is one account per service team, a shared EventBridge bus between them, and event choreography instead of a central orchestrator.

Consider a multi-service platform with nine development teams deploying to the same AWS account. This works for small organizations, but it creates several critical problems as scale increases.

Common Single-Account Anti-Patterns

Multiple teams sharing a single AWS account often leads to resource conflicts, security issues, and operational complexity. Here’s a typical anti-pattern configuration:

# Single-account shared resources anti-pattern
Resources:
  CustomerWebLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: platform-customer-web-api
      Role: !GetAtt SharedLambdaRole.Arn

  OrderProcessingLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: platform-order-processing
      Role: !GetAtt SharedLambdaRole.Arn

  PaymentLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: platform-payment-service
      Role: !GetAtt SharedLambdaRole.Arn

  SharedLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: 'sts:AssumeRole'
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/PowerUserAccess

This approach creates several problems:

  1. Blast Radius: Resource modifications by one team can impact others
  2. Permission Complexity: IAM policies become unwieldy and difficult to audit
  3. Cost Attribution: Difficulty tracking resource usage per team or service
  4. Deployment Conflicts: Shared CI/CD pipelines create bottlenecks
  5. Security Boundaries: All teams operate within the same security perimeter

Multi-Account Architecture Pattern

Multi-account architecture provides clear boundaries between services while enabling controlled communication through shared infrastructure. This pattern separates concerns into distinct AWS accounts while maintaining system coherence through centralized services.

Here’s an effective multi-account structure:

Production Organization Unit

Shared Services

Core Services Accounts

Customer-Facing Accounts

Events

Events

Events

Events

Events

Routed Events

Routed Events

Routed Events

Routed Events

Identity Service Account: 000000000000

Customer Web Account: 111111111111

Mobile Apps Account: 222222222222

Partner Portal Account: 333333333333

Driver App Account: 444444444444

Merchant Dashboard Account: 555555555555

Event Bus Account: 121212121212

Order Processing Account: 777777777777

Delivery Orchestration Account: 888888888888

Payment Service Account: 999999999999

Inventory Management Account: 666666666666

Central Identity Service: Trust Boundary Pattern

Multi-account architectures require centralized authentication and authorization to maintain security boundaries while enabling cross-account communication. The Identity Service acts as the single source of truth for token validation and permissions across all accounts:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowIdentityServiceToAssumeRole",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::000000000000:role/identity-service-validator"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "${IDENTITY_SERVICE_EXTERNAL_ID}",
          "aws:PrincipalOrgID": "o-quickgrocer123",
          "aws:SourceVpce": "vpce-0abc123def4567890"
        }
      }
    }
  ]
}

This centralized approach ensures consistent authentication across all services while avoiding distributed JWT validation complexity. Each customer-facing service validates requests through the central identity service, maintaining security boundaries.

EventBridge: Communication Backbone

Event-driven architecture eliminates direct service dependencies by using EventBridge as a central communication hub. Services publish events to a shared event bus, which routes them to appropriate subscribers based on configured rules.

Here’s an EventBridge rule configuration for order processing:

// Cross-account event routing with CDK
import { Duration } from 'aws-cdk-lib';
import { Rule, EventBus } from 'aws-cdk-lib/aws-events';
import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets';
import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';

const orderPlacedRule = new Rule(this, 'OrderPlacedRule', {
  eventBus: EventBus.fromEventBusArn(
    this,
    'CentralEventBus',
    'arn:aws:events:us-east-1:121212121212:event-bus/central-bus'
  ),
  eventPattern: {
    source: ['quickgrocer.customer-web'],
    detailType: ['Order Placed'],
    detail: {
      orderStatus: ['PENDING'],
      paymentMethod: ['CREDIT_CARD', 'DEBIT_CARD', 'APPLE_PAY']
    }
  },
  targets: [
    new LambdaFunction(orderProcessingLambda, {
      retryAttempts: 2,
      deadLetterQueue: orderProcessingDLQ,
      maxEventAge: Duration.hours(2)
    })
  ]
});

// Grant permissions for cross-account event publishing
const centralBusArn = 'arn:aws:events:us-east-1:121212121212:event-bus/central-bus';
const publishPolicy = new PolicyStatement({
  effect: Effect.ALLOW,
  actions: ['events:PutEvents'],
  resources: [centralBusArn],
  conditions: {
    StringEquals: {
      'events:detail-type': [
        'Order Placed',
        'Order Updated',
        'Order Cancelled'
      ]
    }
  }
});

Event-Driven Data Flow Patterns

Event-driven architecture requires careful orchestration of data flow across services. The subscription upgrade workflow demonstrates how events coordinate state changes across multiple accounts.

Here’s the subscription upgrade event flow:

Order Processing (777777777777)Inventory Mgmt (666666666666)Subscription Service (101010101010)Payment Service (999999999999)Event Bus (121212121212)Identity Service (000000000000)Customer Web (111111111111)Order Processing (777777777777)Inventory Mgmt (666666666666)Subscription Service (101010101010)Payment Service (999999999999)Event Bus (121212121212)Identity Service (000000000000)Customer Web (111111111111)All services maintain eventual consistency through event choreographyValidate user permissionsJWT with subscription scopesSubscriptionUpgradeRequestedRoute to payment processingRoute to subscription servicePaymentProcessed (success)SubscriptionActivatedUpdate inventory allocationsEnable priority orderingUpdate user interface state

Cross-Service Data Synchronization

Subscription status must be available across multiple services without direct database access between accounts. The solution involves event-sourced state replication with local caches.

// Subscription Service implementation
export class SubscriptionService {
  async upgradeSubscription(userId: string, planId: string) {
    // 1. Process the upgrade locally
    const subscription = await this.subscriptionRepo.create({
      userId,
      planId,
      status: 'ACTIVE',
      startDate: new Date(),
      features: this.getFeaturesByPlan(planId)
    });

    // 2. Publish the authoritative event
    await this.eventPublisher.publish({
      source: 'quickgrocer.subscription-service',
      detailType: 'Subscription Activated',
      detail: {
        userId,
        subscriptionId: subscription.id,
        plan: {
          id: planId,
          name: 'QuickGrocer Plus',
          features: ['priority_delivery', 'free_shipping', 'exclusive_deals']
        },
        pricing: {
          monthlyFee: 9.99,
          currency: 'USD'
        },
        metadata: {
          activatedAt: subscription.startDate.toISOString(),
          previousPlan: 'free'
        }
      }
    });

    return subscription;
  }
}

// Order Processing Service with local subscription cache
export class OrderProcessor {
  private subscriptionCache = new Map<string, SubscriptionInfo>();

  // Event handler for subscription updates
  @EventHandler('Subscription Activated')
  async onSubscriptionActivated(event: SubscriptionEvent) {
    // Update local cache
    this.subscriptionCache.set(event.detail.userId, {
      plan: event.detail.plan,
      features: event.detail.plan.features,
      lastUpdated: Date.now()
    });

    // Update any existing pending orders for this user
    await this.updatePendingOrdersForUser(event.detail.userId);
  }

  async processOrder(order: Order) {
    // Fast local lookup instead of cross-service call
    const subscription = this.subscriptionCache.get(order.userId);

    if (subscription?.features.includes('priority_delivery')) {
      order.priority = 'HIGH';
      order.estimatedDelivery = this.calculatePriorityDelivery();
    }

    // Continue order processing...
  }
}

// Inventory Management with subscription-aware allocation
export class InventoryAllocator {
  @EventHandler('Subscription Activated')
  async onSubscriptionActivated(event: SubscriptionEvent) {
    const userId = event.detail.userId;

    // Reserve priority inventory slots for subscribers
    if (event.detail.plan.features.includes('priority_delivery')) {
      await this.allocatePrioritySlots(userId, {
        reservedSlots: 5,
        expirationHours: 24
      });
    }

    // Update inventory algorithms
    await this.updateAllocationWeights(userId, 'PREMIUM');
  }
}

Event Choreography vs Orchestration

Orchestration patterns where one service controls the entire flow create tight coupling and single points of failure. Here’s an anti-pattern to avoid:

// Orchestration anti-pattern - avoid this approach
export class SubscriptionOrchestrator {
  async upgradeSubscription(userId: string, planId: string) {
    try {
      // 1. Call payment service directly
      const payment = await this.paymentService.processPayment(userId, planId);

      // 2. Call subscription service directly
      const subscription = await this.subscriptionService.create(userId, planId);

      // 3. Call inventory service directly
      await this.inventoryService.allocatePrioritySlots(userId);

      // 4. Call order service directly
      await this.orderService.enablePriorityProcessing(userId);

      // Orchestration creates complex error handling
      // and rollback scenarios

    } catch (error) {
      // Complex rollback logic required
      await this.rollbackEverything(userId, planId);
    }
  }
}

Event choreography provides better resilience and loose coupling:

// Event choreography - each service knows its part
export class PaymentEventHandlers {
  @EventHandler('Subscription Upgrade Requested')
  async handleUpgradeRequest(event: UpgradeEvent) {
    try {
      const result = await this.processPayment(event.detail);

      // Publish success event
      await this.publishEvent('Payment Processed', {
        userId: event.detail.userId,
        amount: result.amount,
        transactionId: result.id
      });
    } catch (error) {
      // Publish failure event
      await this.publishEvent('Payment Failed', {
        userId: event.detail.userId,
        reason: error.message,
        retryAfter: Date.now() + 300000 // 5 minutes
      });
    }
  }
}

// Each service reacts independently
export class SubscriptionEventHandlers {
  @EventHandler('Payment Processed')
  async activateSubscription(event: PaymentEvent) {
    // Only activate if payment succeeded
    const subscription = await this.create(event.detail.userId);

    await this.publishEvent('Subscription Activated', {
      userId: event.detail.userId,
      subscriptionId: subscription.id,
      plan: subscription.plan
    });
  }

  @EventHandler('Payment Failed')
  async handlePaymentFailure(event: PaymentFailureEvent) {
    // Log the failure, maybe retry later
    await this.scheduleRetry(event.detail.userId, event.detail.retryAfter);
  }
}

Account Structure and Isolation

Each team operates within isolated AWS accounts with clear boundaries and responsibilities:

# Multi-account organization structure
platform-org/
├── production/
  ├── customer-facing/
  ├── customer-web-111111111111/
  ├── mobile-apps-222222222222/
  ├── partner-portal-333333333333/
  ├── driver-app-444444444444/
  └── merchant-dashboard-555555555555/
  ├── core-services/
  ├── inventory-mgmt-666666666666/
  ├── order-processing-777777777777/
  ├── delivery-orchestration-888888888888/
  └── payment-service-999999999999/
  └── shared-services/
  ├── identity-service-000000000000/
  ├── event-bus-121212121212/
  └── monitoring-131313131313/
├── staging/
  └── [mirrors production structure]
└── development/
    └── [one account per developer team]

Benefits of Multi-Account Architecture

1. Team Autonomy

Teams can deploy independently without coordination overhead. Different teams can maintain separate release cycles and deployment schedules without impacting others.

2. Blast Radius Containment

Resource issues and configuration errors remain isolated within individual accounts. Service failures in one account don’t cascade to other services, maintaining overall system availability.

3. Clear Cost Attribution

Cost allocation becomes straightforward with dedicated accounts per team or service:

// Cost allocation tagging strategy
function applyCostTags(resource: any, teamName: string, serviceName: string): Record<string, string> {
    return {
        'Team': teamName,
        'Service': serviceName,
        'Environment': process.env.ENVIRONMENT || 'dev',
        'CostCenter': TEAM_COST_CENTERS[teamName],
        'Owner': TEAM_LEADS[teamName],
        'CreatedDate': new Date().toISOString(),
        'ManagedBy': 'CDK'
    };
}

// Example monthly cost breakdown:
// Customer Web:  $12,450 (25%)
// Mobile Apps:  $8,230  (17%)
// Order Processing:  $15,670 (32%)
// Delivery Orchestration: $7,890 (16%)
// Identity Service:  $4,760  (10%)

4. Security Boundaries

Each account maintains its own security perimeter. Compliance requirements can be applied selectively to specific accounts without affecting others:

// Payment service account security baseline.
// This stack is deployed only into account 999999999999.
import { Stack } from 'aws-cdk-lib';
import { CfnHub, CfnStandard } from 'aws-cdk-lib/aws-securityhub';

const region = Stack.of(this).region;

// Standards are declared one by one below, so the hub skips its defaults.
const hub = new CfnHub(this, 'SecurityHub', {
  enableDefaultStandards: false
});

// CloudFormation creates resources in parallel unless told otherwise, and a
// standard cannot be enabled before the hub exists.
const pciDss = new CfnStandard(this, 'PciDss', {
  standardsArn: `arn:aws:securityhub:${region}::standards/pci-dss/v/3.2.1`
});
pciDss.addDependency(hub);

const foundationalSecurity = new CfnStandard(this, 'FoundationalSecurity', {
  standardsArn: `arn:aws:securityhub:${region}::standards/aws-foundational-security-best-practices/v/1.0.0`
});
foundationalSecurity.addDependency(hub);

Challenges and Solutions

1. Event Schema Evolution

Managing event schema changes in distributed systems requires careful versioning strategies. Event schemas tend to evolve over time:

// Version 1
{
  "orderId": "ord-123",
  "customerId": "cust-456",
  "items": ["item-1", "item-2"],
  "total": 45.99
}

After multiple iterations and requirements changes:

// Version 7, after six rounds of additions
{
  "orderId": "ord-123",
  "customerId": "cust-456",
  "customerIdV2": "usr_cust-456",  // New ID format
  "items": ["item-1", "item-2"],  // Deprecated, use itemsV2
  "itemsV2": [
    {
      "id": "item-1",
      "quantity": 2,
      "price": 12.99,
      "modifiers": []  // Added in v4
    }
  ],
  "total": 45.99,  // Deprecated in v5
  "totalAmount": {  // Added in v5
    "value": 45.99,
    "currency": "USD"
  },
  "metadata": {  // Added in v6
    "source": "mobile-app",
    "version": "2.3.1"
  }
}

Without proper schema management, event consumers become complex:

// Complex version handling without schema registry
export const handleOrderPlaced = async (event: any) => {
  // Check which version we're dealing with
  const version = event.metadata?.schemaVersion ||
                  (event.customerIdV2 ? 7 :
                   event.totalAmount ? 5 :
                   event.items?.[0]?.modifiers ? 4 : 1);

  switch(version) {
    case 1:
    case 2:
    case 3:
      return handleLegacyOrder(event);
    case 4:
      return handleV4Order(migrateV4ToV7(event));
    case 5:
    case 6:
      return handleV5Order(migrateV5ToV7(event));
    case 7:
      return handleCurrentOrder(event);
    default:
      // Handle unknown versions gracefully
      console.error('Unknown order version:', event);
      throw new Error('Unknown schema version');
  }
};

2. Cross-Account Observability

Tracing requests across multiple AWS accounts requires comprehensive observability infrastructure. Distributed tracing becomes essential:

Common debugging challenges:

  • Latency issues may originate in any account
  • Event routing errors can be difficult to trace
  • Service dependencies span multiple accounts
  • Traditional monitoring tools provide limited cross-account visibility

Implementing distributed tracing solves these challenges:

// Distributed tracing implementation
import { trace, context, propagation, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('quickgrocer-order-service', '1.0.0');

export const processOrder = async (event: any) => {
  // Extract trace context from EventBridge event
  const traceParent = event.detail?.traceContext?.traceparent;
  const traceState = event.detail?.traceContext?.tracestate;

  // Continue the trace from the upstream service
  const extractedContext = propagation.extract(context.active(), {
    traceparent: traceParent,
    tracestate: traceState
  });

  return context.with(extractedContext, () => {
    const span = tracer.startSpan('process-order', {
      attributes: {
        'order.id': event.detail.orderId,
        'order.account': process.env.AWS_ACCOUNT_ID,
        'order.region': process.env.AWS_REGION,
        'order.service': 'order-processing'
      }
    });

    try {
      // Process the order
      const result = await actuallyProcessOrder(event);
      span.setStatus({ code: SpanStatusCode.OK });
      return result;
    } catch (error) {
      span.recordException(error);
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: error.message
      });
      throw error;
    } finally {
      span.end();
    }
  });
};

3. Cost Optimization

Multi-account architectures introduce additional costs that require careful management. Cross-account data transfer, event processing, and resource duplication can increase expenses:

# Illustrative monthly overhead for a nine-account setup
EventBridge Events:  $345  # 345 million custom events at $1.00 per million
Cross-AZ Data Transfer:  $2,100  # Events crossing AZs instead of staying regional
NAT Gateway (9 accounts):  $315  # ~$35 per account per month
CloudWatch Logs:  $4,500  # Default retention, no log-level filtering
Secrets Manager:  $1,800  # The same secrets replicated per account
Parameter Store API calls:  $890  # No caching, so every invocation refetches

Total:  $9,950

Cost optimization strategies:

// Before: Every service fetching secrets on every request
const getSecret = async (secretName: string) => {
  const client = new SecretsManagerClient({});
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: secretName })
  );
  return response.SecretString;
};

// After: Caching with TTL
class SecretCache {
  private cache = new Map<string, {value: string, expiry: number}>();
  private ttl = 3600000; // 1 hour

  async getSecret(secretName: string): Promise<string> {
    const cached = this.cache.get(secretName);
    if (cached && cached.expiry > Date.now()) {
      return cached.value;
    }

    const client = new SecretsManagerClient({});
    const response = await client.send(
      new GetSecretValueCommand({ SecretId: secretName })
    );

    this.cache.set(secretName, {
      value: response.SecretString!,
      expiry: Date.now() + this.ttl
    });

    return response.SecretString!;
  }
}

// Significant cost reduction through caching

Operational Monitoring Patterns

Monitoring carries more weight here than in a monolith, because a stalled event bus keeps reporting healthy while nothing is delivered. One routing disruption reaches every subscriber at once.

Common failure modes include:

  • Disabled event routing rules
  • Misconfigured event patterns
  • Cross-account permission issues
  • Service throttling and limits

Implementing comprehensive monitoring prevents these issues:

// Automated monitoring for event bus health
const eventBusMonitor = new Function(this, 'EventBusMonitor', {
  runtime: Runtime.NODEJS_22_X,
  handler: 'monitor.handler',
  code: Code.fromAsset('lambda/event-bus-monitor'),
  environment: {
    EXPECTED_EVENTS_PER_MINUTE: '1000',
    ALERT_THRESHOLD: '100',
    SLACK_WEBHOOK: process.env.SLACK_WEBHOOK
  }
});

// Run every minute
new Rule(this, 'MonitorSchedule', {
  schedule: Schedule.rate(Duration.minutes(1)),
  targets: [new LambdaFunction(eventBusMonitor)]
});

// The actual monitoring logic
export const handler = async () => {
  const cloudWatch = new CloudWatchClient({});

  // Check events published in last minute
  const metrics = await cloudWatch.send(new GetMetricStatisticsCommand({
    Namespace: 'AWS/Events',
    MetricName: 'SuccessfulRuleMatches',
    StartTime: new Date(Date.now() - 120000),  // 2 minutes ago
    EndTime: new Date(),
    Period: 60,
    Statistics: ['Sum']
  }));

  const eventCount = metrics.Datapoints?.[0]?.Sum || 0;

  if (eventCount < parseInt(process.env.ALERT_THRESHOLD!)) {
    // Page the on-call engineer
    await sendSlackAlert({
      text: `[ALERT] EVENT BUS CRITICAL: Only ${eventCount} events in last minute!`,
      color: 'danger'
    });

    // Auto-healing attempt
    await enableAllRules();
  }
};

Practices Worth Adopting Early

Four practices cost far less on day one than they cost to retrofit:

1. Implement Schema Registry Early

EventBridge Schema Registry stores and versions the contract, but it does not reject malformed events on PutEvents. Pair it with client-side validation so the contract is enforced where events are published:

// Register the contract, then validate against it before publishing
import { SchemasClient, CreateSchemaCommand } from '@aws-sdk/client-schemas';
import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';
import Ajv from 'ajv';

const schemas = new SchemasClient({});
const eventBridge = new EventBridgeClient({});
const ajv = new Ajv();

// Define schema with versioning built-in
const orderSchema = {
  openapi: '3.0.0',
  info: {
    version: '1.0.0',
    title: 'OrderPlaced'
  },
  paths: {},
  components: {
    schemas: {
      OrderPlaced: {
        type: 'object',
        required: ['orderId', 'customerId', 'items', 'totalAmount'],
        properties: {
          orderId: { type: 'string', pattern: '^ord-[0-9a-f]{8}$' },
          customerId: { type: 'string', pattern: '^cust-[0-9a-f]{8}$' },
          items: {
            type: 'array',
            items: {
              $ref: '#/components/schemas/OrderItem'
            }
          },
          totalAmount: {
            $ref: '#/components/schemas/Money'
          }
        }
      }
    }
  }
};

// Register the schema once, at deploy time
await schemas.send(new CreateSchemaCommand({
  RegistryName: 'quickgrocer-events',
  SchemaName: 'OrderPlaced',
  Type: 'OpenApi3',
  Content: JSON.stringify(orderSchema)
}));

// Validate before publishing, using the JSON Schema generated from the registry
const validateOrderPlaced = ajv.compile(orderPlacedJsonSchema);

const validateAndPublish = async (entry: { Source: string; DetailType: string; Detail: string }) => {
  if (!validateOrderPlaced(JSON.parse(entry.Detail))) {
    throw new Error(`Schema validation failed: ${ajv.errorsText(validateOrderPlaced.errors)}`);
  }
  return await eventBridge.send(new PutEventsCommand({ Entries: [entry] }));
};

2. Observability-First Architecture

Monitoring and tracing should be built into the architecture from the beginning:

// Comprehensive observability implementation
class InstrumentedEventPublisher {
  private metrics: MetricsClient;
  private tracer: Tracer;

  async publish(event: Event): Promise<void> {
    const span = this.tracer.startSpan('event.publish');
    const timer = this.metrics.startTimer('event.publish.duration');

    try {
      // Add trace context to event
      event.traceContext = {
        traceparent: span.spanContext().traceId,
        tracestate: span.spanContext().traceState
      };

      await this.eventBridge.putEvents({
        Entries: [{
          ...event,
          Detail: JSON.stringify({
            ...JSON.parse(event.Detail),
            _metadata: {
              timestamp: Date.now(),
              account: process.env.AWS_ACCOUNT_ID,
              service: process.env.SERVICE_NAME,
              version: process.env.SERVICE_VERSION,
              traceId: span.spanContext().traceId
            }
          })
        }]
      });

      this.metrics.increment('event.published', {
        type: event.DetailType,
        source: event.Source
      });

    } catch (error) {
      this.metrics.increment('event.publish.error', {
        type: event.DetailType,
        error: error.name
      });
      span.recordException(error);
      throw error;
    } finally {
      timer.end();
      span.end();
    }
  }
}

3. Automated Account Management

Manual account creation doesn’t scale. Automated account vending becomes essential:

// Automated account vending implementation
import { OrganizationsClient, CreateAccountCommand } from '@aws-sdk/client-organizations';

class AccountVendingMachine {
  private organizations = new OrganizationsClient({});

  async createTeamAccount(team: TeamConfig): Promise<AWSAccount> {
    // 1. Request the account. CreateAccount is asynchronous, so poll
    //    DescribeCreateAccountStatus until the state leaves IN_PROGRESS.
    const { CreateAccountStatus } = await this.organizations.send(new CreateAccountCommand({
      AccountName: `quickgrocer-${team.name}-${team.environment}`,
      Email: `aws+${team.name}+${team.environment}@quickgrocer.com`,
      RoleName: 'OrganizationAccountAccessRole'
    }));
    const account = await this.waitForAccount(CreateAccountStatus!.Id!);

    // 2. Move it into the right OU and turn on the baseline services
    await this.moveToOrganizationalUnit(account.id, this.getOUForTeam(team));
    await this.enableBaselineServices(account.id, {
      cloudTrail: true,
      config: true,
      securityHub: true,
      guardDuty: true,
      budgetLimit: team.monthlyBudget
    });

    // 3. Apply team-specific SCPs
    await this.applyServiceControlPolicies(account.id, team.permissions);

    // 4. Set up cross-account roles
    await this.setupCrossAccountRoles(account.id, {
      identityServiceRole: 'arn:aws:iam::000000000000:role/identity-validator',
      eventBusRole: 'arn:aws:iam::121212121212:role/event-publisher'
    });

    // 5. Deploy baseline infrastructure
    await this.deployBaseline(account.id, {
      vpcCidr: this.allocateVpcCidr(team),
      eventBusArn: 'arn:aws:events:us-east-1:121212121212:event-bus/central-bus',
      logGroupRetention: 30
    });

    return account;
  }
}

4. Multi-Region Architecture Planning

Regional expansion should be considered early in the design process:

// Multi-region architecture design
const multiRegionStack = new Stack(app, 'MultiRegionInfra', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION
  }
});

// Deploy to multiple regions
['us-east-1', 'eu-west-1', 'ap-southeast-1'].forEach(region => {
  new RegionalStack(app, `Regional-${region}`, {
    env: { region },
    eventBusArn: `arn:aws:events:${region}:121212121212:event-bus/central-bus`,
    // Regional event routing
    eventRouting: {
      primary: region,
      failover: getFailoverRegion(region)
    }
  });
});

When Multi-Account Pays Off

The default holds once independent deployment cadence matters more than the convenience of a shared account: one account per service team, a shared EventBridge bus between them, and choreography rather than a central orchestrator. Past that point, the coordination cost of a single account exceeds the cost of running the extra IAM, networking, and observability machinery.

Below that line, override it. Two or three teams shipping one product do not need cross-account roles, an account vending machine, or distributed tracing to answer “where did this request go”. Start with one account per environment, and split when a team first blocks another team’s deploy.

Whichever side of the line you land on, keep the event contracts explicit from the start. Schema versioning and trace propagation are the two pieces every consumer ends up hard-coding around, and once they have, changing them stops being a code change and becomes a migration.

References

Related posts