Skip to content

AWS CDK Project Structure: Service-Based vs Domain-Based Organization

When to use service-based, domain-based, feature-based, or layer-based organization in AWS CDK projects, with decision frameworks and common pitfalls.

Ayhan Sipahi Ayhan Sipahi

An AWS CDK app is a program, so its folder and stack layout is a design decision the framework leaves to you. Skip that decision and the cost shows up later as tight coupling, circular dependencies between stacks, and deploys nobody can run without checking with another team first. The layout that holds up at scale is domain-based organization with a thin foundation layer: everything a business domain owns lives in one folder and one stack, while VPC, security, and monitoring sit in a shared stack beneath them.

Service-based, feature-based, layer-based, and hybrid layouts each win in specific contexts, and every pattern below comes with working TypeScript plus the conditions under which it beats that default. The decision frameworks cover the two questions that bite hardest: when to split a stack instead of adding a construct, and when a monorepo stops paying for itself.

How Accidental Layouts Break#

A recurring pattern in CDK projects: good intentions gradually erode as maintainability declines. Infrastructure code grows organically, files are placed wherever convenient, and before long teams struggle with merge conflicts, unclear ownership, and deployment dependencies they cannot untangle.

The technical challenges manifest in several ways. Teams organizing by AWS service (creating separate folders for lambda/, dynamodb/, api-gateway/) find that changes to a single business feature require touching multiple directories. Cross-stack references create deployment dependencies that aren’t obvious until something breaks. When multiple teams work on shared infrastructure, unclear domain boundaries lead to merge conflicts and coordination overhead.

The question is not really where files live. It is how to model infrastructure so that it reflects business architecture, deployment requirements, and team structure, without the coupling that makes systems fragile.

Whatever layout you land on has to hold up against the same demands. Teams need to deploy their own changes without coordinating with other teams or worrying about breaking unrelated systems. Every stack and construct needs an obvious owner, so it is clear who to ask when something breaks. Related resources should sit together, so developers can find what they need quickly. The pattern has to survive growth from 10 resources to 500, and from 1 team to 10, without a major restructure. And shared infrastructure like VPCs, security policies, and monitoring has to land somewhere without duplication or awkward dependencies.

Four Layouts and a Hybrid#

Service-Based Organization#

The service-based pattern organizes code by AWS service type: all Lambda functions together, all DynamoDB tables together, all API Gateways together.

// Directory structure
// cdk-project/
// ├── lib/
// │  ├── lambda/
// │  │  ├── user-handler.ts
// │  │  ├── order-handler.ts
// │  │  └── payment-handler.ts
// │  ├── dynamodb/
// │  │  ├── user-table.ts
// │  │  ├── order-table.ts
// │  │  └── payment-table.ts
// │  ├── api-gateway/
// │  │  ├── user-api.ts
// │  │  ├── order-api.ts
// │  │  └── payment-api.ts
// │  └── stacks/
// │  ├── lambda-stack.ts
// │  ├── dynamodb-stack.ts
// │  └── api-gateway-stack.ts

// lib/stacks/dynamodb-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Table, BillingMode, AttributeType } from 'aws-cdk-lib/aws-dynamodb';

export class DynamoDBStack extends Stack {
  public readonly userTable: Table;
  public readonly orderTable: Table;
  public readonly paymentTable: Table;

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    this.userTable = new Table(this, 'UserTable', {
      partitionKey: { name: 'userId', type: AttributeType.STRING },
      billingMode: BillingMode.PAY_PER_REQUEST
    });

    this.orderTable = new Table(this, 'OrderTable', {
      partitionKey: { name: 'orderId', type: AttributeType.STRING },
      billingMode: BillingMode.PAY_PER_REQUEST
    });

    this.paymentTable = new Table(this, 'PaymentTable', {
      partitionKey: { name: 'paymentId', type: AttributeType.STRING },
      billingMode: BillingMode.PAY_PER_REQUEST
    });
  }
}

// lib/stacks/lambda-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Table } from 'aws-cdk-lib/aws-dynamodb';

export interface LambdaStackProps extends StackProps {
  readonly userTable: Table;
  readonly orderTable: Table;
  readonly paymentTable: Table;
}

export class LambdaStack extends Stack {
  constructor(scope: Construct, id: string, props: LambdaStackProps) {
    super(scope, id, props);

    const userHandler = new NodejsFunction(this, 'UserHandler', {
      entry: 'src/handlers/user.ts',
      environment: {
        TABLE_NAME: props.userTable.tableName
      }
    });

    props.userTable.grantReadWriteData(userHandler);

    // Similar for order and payment handlers...
  }
}

This approach seems logical initially: all Lambda functions are in one place, making it easy to apply consistent configurations. However, the problems become apparent quickly:

  • Making a change to user functionality requires touching multiple directories and stacks
  • Cross-stack references create deployment dependencies (DynamoDB stack must deploy before Lambda stack)
  • Team ownership is unclear (who owns the “Lambda stack”?)
  • Deploying a subset of infrastructure (just user-related resources) is difficult

None of that hurts much on a small project. Under 10 resources, in a learning or experimentation phase, or in a single-service application, the layout has not started to matter yet.

Domain-Based Organization#

The domain-based pattern organizes code by business domain or bounded context, grouping all infrastructure for a domain together.

// Directory structure
// cdk-project/
// ├── lib/
// │  ├── domains/
// │  │  ├── user/
// │  │  │  ├── user-stack.ts
// │  │  │  ├── user-handler.ts
// │  │  │  ├── user-table.ts
// │  │  │  ├── user-api.ts
// │  │  │  └── constructs/
// │  │  │  └── user-service.ts
// │  │  ├── order/
// │  │  │  ├── order-stack.ts
// │  │  │  ├── order-handler.ts
// │  │  │  ├── order-table.ts
// │  │  │  └── order-api.ts
// │  │  └── payment/
// │  │  ├── payment-stack.ts
// │  │  ├── payment-handler.ts
// │  │  └── payment-table.ts
// │  └── shared/
// │  ├── networking-stack.ts
// │  ├── security-stack.ts
// │  └── monitoring-stack.ts

// lib/domains/user/user-stack.ts
import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Table, BillingMode, AttributeType, TableEncryption } from 'aws-cdk-lib/aws-dynamodb';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { RestApi, LambdaIntegration } from 'aws-cdk-lib/aws-apigateway';

export class UserStack extends Stack {
  public readonly api: RestApi;
  public readonly apiEndpoint: string;

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // All user-related infrastructure in one place
    const table = new Table(this, 'UserTable', {
      partitionKey: { name: 'userId', type: AttributeType.STRING },
      billingMode: BillingMode.PAY_PER_REQUEST,
      encryption: TableEncryption.AWS_MANAGED,
      pointInTimeRecovery: true,
      removalPolicy: RemovalPolicy.RETAIN
    });

    const handler = new NodejsFunction(this, 'UserHandler', {
      entry: 'src/handlers/user.ts',
      environment: {
        TABLE_NAME: table.tableName
      }
    });

    table.grantReadWriteData(handler);

    this.api = new RestApi(this, 'UserApi', {
      restApiName: 'user-service',
      deployOptions: {
        stageName: 'v1',
        tracingEnabled: true
      }
    });

    const users = this.api.root.addResource('users');
    users.addMethod('GET', new LambdaIntegration(handler));
    users.addMethod('POST', new LambdaIntegration(handler));

    const user = users.addResource('{userId}');
    user.addMethod('GET', new LambdaIntegration(handler));
    user.addMethod('PUT', new LambdaIntegration(handler));
    user.addMethod('DELETE', new LambdaIntegration(handler));

    this.apiEndpoint = this.api.url;
  }
}

// bin/app.ts
import { App } from 'aws-cdk-lib';
import { UserStack } from '../lib/domains/user/user-stack';
import { OrderStack } from '../lib/domains/order/order-stack';
import { PaymentStack } from '../lib/domains/payment/payment-stack';

const app = new App();

const userStack = new UserStack(app, 'UserStack', {
  env: { account: '123456789012', region: 'us-east-1' }
});

const orderStack = new OrderStack(app, 'OrderStack', {
  env: { account: '123456789012', region: 'us-east-1' },
  userApiEndpoint: userStack.apiEndpoint
});

// Order stack depends on user stack
orderStack.addDependency(userStack);

const paymentStack = new PaymentStack(app, 'PaymentStack', {
  env: { account: '123456789012', region: 'us-east-1' }
});

This organization aligns infrastructure with business capabilities. Changes to user functionality only touch the user/ directory. Team ownership is clear: the user team owns the user stack. Domains can deploy independently (after shared infrastructure), and extracting a domain into a separate repository is straightforward if needed. Those properties pay off in microservices architectures and multi-team organizations, wherever the bounded contexts are already clear.

Single-Table Design compatibility: Domain-based organization pairs exceptionally well with DynamoDB Single-Table Design pattern. When a single table contains multiple entity types (User, Order, Payment), each domain can maintain its own access patterns and repository logic within its folder. For example, the user/ folder contains user-repository.ts with User-specific queries, while order/ folder has order-repository.ts managing Order access patterns, all against the same physical table. The table definition lives in shared/, while domains own only their access patterns.

Feature-Based Organization#

For product-focused teams, organizing by user-facing features makes more sense than technical domains.

// Directory structure
// cdk-project/
// ├── lib/
// │  ├── features/
// │  │  ├── authentication/
// │  │  │  ├── auth-stack.ts
// │  │  │  ├── cognito-pool.ts
// │  │  │  ├── auth-lambda.ts
// │  │  │  └── auth-api.ts
// │  │  ├── user-profile/
// │  │  │  ├── profile-stack.ts
// │  │  │  ├── profile-handler.ts
// │  │  │  └── profile-table.ts
// │  │  ├── notifications/
// │  │  │  ├── notification-stack.ts
// │  │  │  ├── sns-topics.ts
// │  │  │  ├── email-handler.ts
// │  │  │  └── sms-handler.ts
// │  │  └── search/
// │  │  ├── search-stack.ts
// │  │  ├── opensearch-domain.ts
// │  │  └── indexer-lambda.ts

// lib/features/notifications/notification-stack.ts
import { Stack, StackProps, Duration } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Topic } from 'aws-cdk-lib/aws-sns';
import { EmailSubscription, SmsSubscription, SqsSubscription, LambdaSubscription } from 'aws-cdk-lib/aws-sns-subscriptions';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';
import { Queue } from 'aws-cdk-lib/aws-sqs';

export class NotificationStack extends Stack {
  public readonly emailTopic: Topic;
  public readonly smsTopic: Topic;

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // Email notifications
    this.emailTopic = new Topic(this, 'EmailTopic', {
      displayName: 'Email Notifications'
    });

    const emailQueue = new Queue(this, 'EmailQueue', {
      visibilityTimeout: Duration.seconds(300)
    });

    const emailHandler = new NodejsFunction(this, 'EmailHandler', {
      entry: 'src/handlers/send-email.ts',
      environment: {
        SMTP_HOST: 'smtp.example.com'
      }
    });

    emailHandler.addEventSource(new SqsEventSource(emailQueue));
    this.emailTopic.addSubscription(new SqsSubscription(emailQueue));

    // SMS notifications
    this.smsTopic = new Topic(this, 'SmsTopic', {
      displayName: 'SMS Notifications'
    });

    const smsHandler = new NodejsFunction(this, 'SmsHandler', {
      entry: 'src/handlers/send-sms.ts'
    });

    this.smsTopic.addSubscription(new LambdaSubscription(smsHandler));
  }
}

Feature folders pay off when features span multiple AWS services and domains, when teams are organized around product features rather than technical layers, and when the product needs features developed and deployed independently of each other.

Layer-Based Organization#

Layer-based organization separates infrastructure by technical layer, grouping resources by lifecycle and change frequency.

// Directory structure
// cdk-project/
// ├── lib/
// │  ├── layers/
// │  │  ├── foundation/
// │  │  │  ├── vpc-stack.ts
// │  │  │  ├── security-groups-stack.ts
// │  │  │  └── base-stack.ts
// │  │  ├── data/
// │  │  │  ├── rds-stack.ts
// │  │  │  ├── dynamodb-stack.ts
// │  │  │  └── s3-stack.ts
// │  │  ├── compute/
// │  │  │  ├── lambda-stack.ts
// │  │  │  ├── ecs-stack.ts
// │  │  │  └── batch-stack.ts
// │  │  └── api/
// │  │  ├── apigw-stack.ts
// │  │  └── alb-stack.ts

// bin/app.ts
import { App } from 'aws-cdk-lib';
import { FoundationStack } from '../lib/layers/foundation/vpc-stack';
import { DataStack } from '../lib/layers/data/dynamodb-stack';
import { ComputeStack } from '../lib/layers/compute/lambda-stack';
import { ApiStack } from '../lib/layers/api/apigw-stack';

const app = new App();

// Foundation layer - deployed once, rarely updated
const foundationStack = new FoundationStack(app, 'Foundation', {
  env: { account: '123456789012', region: 'us-east-1' }
});

// Data layer - stateful, needs RETAIN removal policy
const dataStack = new DataStack(app, 'Data', {
  env: { account: '123456789012', region: 'us-east-1' },
  vpc: foundationStack.vpc
});

// Compute layer - stateless, frequently updated
const computeStack = new ComputeStack(app, 'Compute', {
  env: { account: '123456789012', region: 'us-east-1' },
  vpc: foundationStack.vpc,
  tables: dataStack.tables
});

// API layer - deployment artifact, very frequent updates
const apiStack = new ApiStack(app, 'Api', {
  env: { account: '123456789012', region: 'us-east-1' },
  handlers: computeStack.handlers
});

// Explicit dependencies ensure correct deployment order
dataStack.addDependency(foundationStack);
computeStack.addDependency(dataStack);
apiStack.addDependency(computeStack);

Splitting by layer keeps stateful and stateless resources apart and lets each layer move at its own pace: foundation changes rarely, APIs change constantly. Deployment risk drops because the data layer rarely changes, and each layer can carry its own removal policy. The price is that business logic gets scattered across layers, team ownership is less clear, and deploying a complete feature means coordinating across several of them.

Hybrid Approach#

Real-world projects often benefit from combining patterns: layers for cross-cutting concerns, domains for business logic.

// Directory structure
// cdk-project/
// ├── lib/
// │  ├── foundation/
// │  │  ├── network-stack.ts  // Shared VPC, subnets
// │  │  ├── security-stack.ts  // Security groups, IAM roles
// │  │  └── monitoring-stack.ts  // CloudWatch, X-Ray
// │  ├── domains/
// │  │  ├── user/
// │  │  │  ├── user-service-stack.ts
// │  │  │  └── constructs/
// │  │  │  ├── user-api.ts
// │  │  │  ├── user-storage.ts
// │  │  │  └── user-lambda.ts
// │  │  ├── order/
// │  │  │  ├── order-service-stack.ts
// │  │  │  └── constructs/
// │  │  └── payment/
// │  │  ├── payment-service-stack.ts
// │  │  └── constructs/
// │  ├── shared/
// │  │  ├── constructs/
// │  │  │  ├── api-endpoint.ts  // Reusable L3 construct
// │  │  │  ├── lambda-factory.ts // Factory functions
// │  │  │  └── monitored-table.ts
// │  │  └── aspects/
// │  │  ├── tagging-aspect.ts
// │  │  ├── security-aspect.ts
// │  │  └── removal-policy-aspect.ts
// │  └── config/
// │  ├── environment.ts  // Type-safe config
// │  ├── dev.ts
// │  ├── staging.ts
// │  └── prod.ts

// lib/foundation/network-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Vpc, SubnetType, GatewayVpcEndpointAwsService } from 'aws-cdk-lib/aws-ec2';

export class NetworkStack extends Stack {
  public readonly vpc: Vpc;

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    this.vpc = new Vpc(this, 'Vpc', {
      maxAzs: 3,
      natGateways: 1,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'public',
          subnetType: SubnetType.PUBLIC
        },
        {
          cidrMask: 24,
          name: 'private',
          subnetType: SubnetType.PRIVATE_WITH_EGRESS
        },
        {
          cidrMask: 28,
          name: 'isolated',
          subnetType: SubnetType.PRIVATE_ISOLATED
        }
      ]
    });

    // VPC endpoints save data transfer costs
    this.vpc.addGatewayEndpoint('S3Endpoint', {
      service: GatewayVpcEndpointAwsService.S3
    });

    this.vpc.addGatewayEndpoint('DynamoDBEndpoint', {
      service: GatewayVpcEndpointAwsService.DYNAMODB
    });
  }
}

// lib/domains/user/user-service-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { IVpc } from 'aws-cdk-lib/aws-ec2';

export interface UserServiceStackProps extends StackProps {
  readonly vpc: IVpc;
  readonly monitoringDashboardName?: string;
}

export class UserServiceStack extends Stack {
  public readonly apiEndpoint: string;

  constructor(scope: Construct, id: string, props: UserServiceStackProps) {
    super(scope, id, props);

    // Use constructs to organize within the stack
    const storage = new UserStorageConstruct(this, 'Storage', {
      vpc: props.vpc
    });

    const compute = new UserComputeConstruct(this, 'Compute', {
      vpc: props.vpc,
      table: storage.table
    });

    const api = new UserApiConstruct(this, 'Api', {
      handler: compute.handler
    });

    this.apiEndpoint = api.url;
  }
}

// bin/app.ts
import { App } from 'aws-cdk-lib';
import { NetworkStack } from '../lib/foundation/network-stack';
import { UserServiceStack } from '../lib/domains/user/user-service-stack';
import { OrderServiceStack } from '../lib/domains/order/order-service-stack';
import { getConfig } from '../lib/config/environment';

const app = new App();
const config = getConfig(process.env.STAGE || 'dev');

// Foundation layer - deployed first, rarely changes
const network = new NetworkStack(app, 'Network', {
  env: config.env
});

// Domain stacks - business logic, independent deployment
const userStack = new UserServiceStack(app, 'UserService', {
  env: config.env,
  vpc: network.vpc
});

const orderStack = new OrderServiceStack(app, 'OrderService', {
  env: config.env,
  vpc: network.vpc,
  userApiEndpoint: userStack.apiEndpoint
});

orderStack.addDependency(userStack);

Here the foundation layer handles cross-cutting concerns while the domain stacks keep business logic cohesive, and the dependency graph stays explicit enough for domains to deploy independently once the foundation is established.

Where to Draw Stack and Repo Boundaries#

When to Split Stacks vs Use Constructs#

One of the most common CDK questions is: “When should I create a new stack versus just using a construct?” The answer comes down to deployment independence.

Use multiple stacks when:

  • Different teams will maintain different parts
  • Different deployment schedules (data layer deploys monthly, compute layer deploys daily)
  • Different environments or accounts (dev in one account, prod in another)
  • Independent deployment is critical for CI/CD
  • Approaching CloudFormation’s 500 resource limit per stack
  • Different business domains with clear boundaries
  • Stateful resources need different removal policies than stateless resources

Use constructs (not stacks) when:

  • Only separating code ownership within the same team
  • Resources change together and should deploy together
  • No need for independent deployment
  • Resources have tight coupling and many references
  • Organizing purely for code maintainability

Here’s a decision tree example:

// Question 1: Do resources change together?
// YES → Single stack with constructs
// NO → Continue

// Question 2: Different teams maintaining?
// YES → Separate stacks
// NO → Continue

// Question 3: Different deployment schedules?
// YES → Separate stacks
// NO → Continue

// Question 4: Approaching 500 resources?
// YES → Separate stacks (or nested stacks)
// NO → Single stack with constructs

// Example: User service with API, Lambda, DynamoDB
// All change together, same team, deploy together → Single stack
export class UserServiceStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // Organize with constructs, not separate stacks
    const storage = new UserStorageConstruct(this, 'Storage');
    const compute = new UserComputeConstruct(this, 'Compute', {
      table: storage.table
    });
    const api = new UserApiConstruct(this, 'Api', {
      handler: compute.handler
    });
  }
}

// Example: Foundation (VPC) vs Application (Lambda/API)
// Different change frequency, different lifecycle → Separate stacks
const foundationStack = new FoundationStack(app, 'Foundation');
const appStack = new AppStack(app, 'App', {
  vpc: foundationStack.vpc
});
appStack.addDependency(foundationStack);

Monorepo vs Multi-Repo Strategy#

The monorepo versus multi-repo decision affects how teams collaborate and deploy infrastructure.

Monorepo pattern:

// Directory structure
// company-infrastructure/  # Single repository
// ├── packages/
// │  ├── foundation/
// │  │  ├── src/
// │  │  │  └── network-stack.ts
// │  │  ├── package.json
// │  │  └── cdk.json
// │  ├── user-service/
// │  │  ├── src/
// │  │  │  └── user-stack.ts
// │  │  ├── package.json
// │  │  └── cdk.json
// │  ├── order-service/
// │  │  ├── src/
// │  │  │  └── order-stack.ts
// │  │  ├── package.json
// │  │  └── cdk.json
// │  └── shared-constructs/
// │  ├── src/
// │  │  ├── api-endpoint.ts
// │  │  └── lambda-factory.ts
// │  ├── package.json
// │  └── tsconfig.json
// ├── package.json  # Root package.json
// ├── nx.json  # NX workspace config
// └── tsconfig.base.json

// package.json (root)
{
  "name": "company-infrastructure",
  "private": true,
  "workspaces": ["packages/*"],
  "scripts": {
    "build": "nx run-many --target=build --all",
    "deploy:dev": "nx run-many --target=deploy --all --args='--context stage=dev'",
    "deploy:user": "nx run user-service:deploy"
  },
  "devDependencies": {
    "nx": "^17.0.0",
    "aws-cdk": "^2.100.0"
  }
}

A monorepo buys a single source of truth: atomic changes across services, shared constructs without publishing to npm, easier refactoring across boundaries, simpler dependency management, and one CI/CD pipeline configuration. What it costs is slower clones, a standing risk of tight coupling between services, pipeline logic to detect which service actually changed, coarser team permissions, and a wider deployment blast radius.

Multi-repo flips both sides. Separate repositories per service, with shared constructs published to a private npm registry, give you clear service boundaries, independent versioning and deployment, obvious ownership and permissions, smaller checkouts, and room for language diversity. In exchange, cross-service changes need multiple PRs, shared constructs need publishing and versioning, dependency versions get harder to manage, consistency is harder to enforce, and every service carries its own pipeline overhead.

Recommendation: Start with a monorepo for teams under 10 people. The simplicity of atomic changes and shared constructs outweighs the drawbacks at small scale. Split to multi-repo when team size or deployment independence demands it: usually around 50+ engineers or when there are truly independent services with different lifecycles.

What Lives Below the Domains#

Networking, security, monitoring, and other cross-cutting concerns don’t fit cleanly into domain boundaries.

Foundation Stack Pattern#

// lib/foundation/foundation-stack.ts
import { Stack, StackProps, Tags } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Vpc, IVpc, SubnetType, SecurityGroup } from 'aws-cdk-lib/aws-ec2';
import { Dashboard, GraphWidget } from 'aws-cdk-lib/aws-cloudwatch';

export interface FoundationStackProps extends StackProps {
  readonly stage: string;
}

export class FoundationStack extends Stack {
  public readonly vpc: IVpc;
  public readonly monitoring: Dashboard;
  public readonly lambdaSecurityGroup: SecurityGroup;

  constructor(scope: Construct, id: string, props: FoundationStackProps) {
    super(scope, id, props);

    // VPC with standard subnets
    this.vpc = new Vpc(this, 'Vpc', {
      maxAzs: 3,
      natGateways: props.stage === 'prod' ? 3 : 1,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'public',
          subnetType: SubnetType.PUBLIC
        },
        {
          cidrMask: 24,
          name: 'private',
          subnetType: SubnetType.PRIVATE_WITH_EGRESS
        },
        {
          cidrMask: 28,
          name: 'isolated',
          subnetType: SubnetType.PRIVATE_ISOLATED
        }
      ]
    });

    // Centralized monitoring dashboard
    this.monitoring = new Dashboard(this, 'Monitoring', {
      dashboardName: `${props.stage}-metrics`
    });

    // Standard security group for Lambda functions
    this.lambdaSecurityGroup = new SecurityGroup(this, 'LambdaSG', {
      vpc: this.vpc,
      description: 'Security group for Lambda functions',
      allowAllOutbound: true
    });

    // Apply tags to everything in this stack
    Tags.of(this).add('Environment', props.stage);
    Tags.of(this).add('ManagedBy', 'CDK');
    Tags.of(this).add('CostCenter', 'Engineering');
  }
}

// Domain stacks consume foundation
export class UserServiceStack extends Stack {
  constructor(
    scope: Construct,
    id: string,
    props: StackProps & { foundation: FoundationStack }
  ) {
    super(scope, id, props);

    const handler = new NodejsFunction(this, 'Handler', {
      vpc: props.foundation.vpc,
      securityGroups: [props.foundation.lambdaSecurityGroup],
      entry: 'src/handlers/user.ts'
    });

    // Add Lambda metrics to centralized dashboard
    props.foundation.monitoring.addWidgets(
      new GraphWidget({
        title: 'User Service Invocations',
        left: [handler.metricInvocations()]
      })
    );
  }
}

CDK Aspects for Cross-Cutting Policies#

CDK Aspects allow you to apply policies across all resources in your application automatically.

// lib/shared/aspects/security-compliance-aspect.ts
import { App, IAspect, Aspects } from 'aws-cdk-lib';
import { IConstruct } from 'constructs';
import { CfnBucket } from 'aws-cdk-lib/aws-s3';
import { Table } from 'aws-cdk-lib/aws-dynamodb';
import { NodejsFunction, Function } from 'aws-cdk-lib/aws-lambda-nodejs';
import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
import { RemovalPolicy } from 'aws-cdk-lib';

export class SecurityComplianceAspect implements IAspect {
  visit(node: IConstruct): void {
    // Enforce encryption on all S3 buckets
    if (node instanceof CfnBucket) {
      if (!node.bucketEncryption) {
        node.bucketEncryption = {
          serverSideEncryptionConfiguration: [{
            serverSideEncryptionByDefault: {
              sseAlgorithm: 'AES256'
            }
          }]
        };
      }
    }

    // Enforce encryption and retention on DynamoDB tables
    if (node instanceof Table) {
      // Tables without explicit encryption get AWS managed
      node.applyRemovalPolicy(RemovalPolicy.RETAIN);
    }

    // Ensure all Lambda functions have log retention
    if (node instanceof NodejsFunction || node instanceof Function) {
      const logGroupName = `/aws/lambda/${node.functionName}`;
      new LogGroup(node, 'LogGroup', {
        logGroupName,
        retention: RetentionDays.ONE_WEEK,
        removalPolicy: RemovalPolicy.DESTROY
      });
    }
  }
}

// Apply to entire app
const app = new App();
Aspects.of(app).add(new SecurityComplianceAspect());

Shared Constructs Library#

Create reusable L3 constructs that encapsulate best practices. Factory patterns and functional composition take these constructs further; AWS CDK Functional Patterns covers that ground.

// lib/shared/constructs/monitored-lambda.ts
import { Construct } from 'constructs';
import { NodejsFunction, NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Alarm, ComparisonOperator } from 'aws-cdk-lib/aws-cloudwatch';
import { Topic } from 'aws-cdk-lib/aws-sns';
import { EmailSubscription } from 'aws-cdk-lib/aws-sns-subscriptions';
import { SnsAction } from 'aws-cdk-lib/aws-cloudwatch-actions';
import { Tracing } from 'aws-cdk-lib/aws-lambda';

export interface MonitoredLambdaProps extends NodejsFunctionProps {
  readonly alarmEmail?: string;
  readonly errorThreshold?: number;
}

export class MonitoredLambda extends Construct {
  public readonly function: NodejsFunction;
  public readonly errorAlarm: Alarm;
  public readonly throttleAlarm: Alarm;

  constructor(scope: Construct, id: string, props: MonitoredLambdaProps) {
    super(scope, id);

    this.function = new NodejsFunction(this, 'Function', {
      ...props,
      tracing: Tracing.ACTIVE // Enable X-Ray by default
    });

    // Automatic error alarm
    this.errorAlarm = new Alarm(this, 'ErrorAlarm', {
      metric: this.function.metricErrors(),
      threshold: props.errorThreshold ?? 5,
      evaluationPeriods: 2,
      comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
      alarmDescription: `Errors on ${this.function.functionName}`
    });

    // Automatic throttle alarm
    this.throttleAlarm = new Alarm(this, 'ThrottleAlarm', {
      metric: this.function.metricThrottles(),
      threshold: 1,
      evaluationPeriods: 1,
      comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
      alarmDescription: `Throttles on ${this.function.functionName}`
    });

    // Optional SNS notification
    if (props.alarmEmail) {
      const topic = new Topic(this, 'AlarmTopic');
      topic.addSubscription(new EmailSubscription(props.alarmEmail));
      this.errorAlarm.addAlarmAction(new SnsAction(topic));
      this.throttleAlarm.addAlarmAction(new SnsAction(topic));
    }
  }
}

// Usage in any domain stack
const handler = new MonitoredLambda(this, 'Handler', {
  entry: 'src/handlers/user.ts',
  alarmEmail: 'oncall@example.com',
  errorThreshold: 10
});

Environments Created at Synth Time#

Managing dev, staging, and prod takes a deliberate configuration strategy. Creating every environment during synthesis is what keeps them consistent.

// config/environment.ts
export interface EnvironmentConfig {
  readonly stage: 'dev' | 'staging' | 'prod';
  readonly account: string;
  readonly region: string;
  readonly vpcCidr: string;
  readonly lambdaDefaults: {
    readonly timeout: number;
    readonly memorySize: number;
  };
  readonly enableDetailedMonitoring: boolean;
}

// config/dev.ts
export const devConfig: EnvironmentConfig = {
  stage: 'dev',
  account: '111111111111',
  region: 'us-east-1',
  vpcCidr: '10.0.0.0/16',
  lambdaDefaults: {
    timeout: 30,
    memorySize: 512
  },
  enableDetailedMonitoring: false
};

// config/prod.ts
export const prodConfig: EnvironmentConfig = {
  stage: 'prod',
  account: '222222222222',
  region: 'us-east-1',
  vpcCidr: '10.1.0.0/16',
  lambdaDefaults: {
    timeout: 60,
    memorySize: 1024
  },
  enableDetailedMonitoring: true
};

// bin/app.ts
import { App } from 'aws-cdk-lib';
import { devConfig, stagingConfig, prodConfig } from '../config';
import { UserServiceStack } from '../lib/domains/user/user-service-stack';

const app = new App();

// Create all environments during synthesis
[devConfig, stagingConfig, prodConfig].forEach(config => {
  new UserServiceStack(app, `UserService-${config.stage}`, {
    env: { account: config.account, region: config.region },
    config
  });
});

// Benefits:
// - Synthesize once, deploy to multiple environments
// - Guarantees same code runs in dev and prod
// - TypeScript validates ALL environments during development
// - `cdk list` shows all stacks across all environments

This approach ensures that production configuration is validated during development. A typo in production config will be caught immediately when running cdk synth locally, not during production deployment.

Splitting, Cycles, and Logical IDs#

Premature Stack Splitting#

Creating too many small stacks leads to excessive cross-stack references and deployment complexity.

// Over-splitting - 6 stacks for one service (don't do this)
const vpcStack = new VpcStack(app, 'Vpc');
const sgStack = new SecurityGroupStack(app, 'SG', { vpc: vpcStack.vpc });
const tableStack = new TableStack(app, 'Table');
const lambdaStack = new LambdaStack(app, 'Lambda', {
  table: tableStack.table,
  sg: sgStack.lambdaSG
});
const apiStack = new ApiStack(app, 'Api', { handler: lambdaStack.handler });
const alarmStack = new AlarmStack(app, 'Alarm', {
  lambda: lambdaStack.handler,
  api: apiStack.api
});

// Every change requires deploying 6 stacks in correct order
// Cross-stack references create fragile dependencies

// Better: Single stack with constructs for organization
export class UserServiceStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const network = new NetworkConstruct(this, 'Network');
    const storage = new StorageConstruct(this, 'Storage');
    const compute = new ComputeConstruct(this, 'Compute', {
      table: storage.table,
      vpc: network.vpc
    });
    const api = new ApiConstruct(this, 'Api', { handler: compute.handler });
    const monitoring = new MonitoringConstruct(this, 'Monitoring', {
      lambda: compute.handler,
      api: api.restApi
    });
  }
}

// Deploy once, all resources update together

Start with larger stacks and split only when there’s a clear reason: different teams, different lifecycles, or approaching resource limits.

Circular Dependencies#

Circular dependencies between stacks are a common issue when designing resource relationships.

// Circular dependency problem (don't do this)
export class UserStack extends Stack {
  public readonly apiUrl: string;

  constructor(scope: Construct, id: string, props: { orderTable: ITable }) {
    super(scope, id);
    // User service needs order table ← dependency on OrderStack
    const handler = new NodejsFunction(this, 'Handler', {
      environment: { ORDER_TABLE: props.orderTable.tableName }
    });

    this.apiUrl = api.url;
  }
}

export class OrderStack extends Stack {
  public readonly table: Table;

  constructor(scope: Construct, id: string, props: { userApiUrl: string }) {
    super(scope, id);
    // Order service needs user API ← dependency on UserStack
    const handler = new NodejsFunction(this, 'Handler', {
      environment: { USER_API_URL: props.userApiUrl }
    });

    this.table = new Table(/*...*/);
  }
}

// Cannot instantiate either stack first!

// Solution 1: Foundation stack with shared resources
const foundationStack = new FoundationStack(app, 'Foundation');

const userStack = new UserStack(app, 'User', {
  sharedTable: foundationStack.sharedTable
});

const orderStack = new OrderStack(app, 'Order', {
  sharedTable: foundationStack.sharedTable,
  userApiUrl: userStack.apiUrl
});

// Solution 2: Event-driven (no direct dependencies)
const userStack = new UserStack(app, 'User');
const orderStack = new OrderStack(app, 'Order');

// Services communicate via EventBridge/SQS, no cross-references needed

Cross-Account/Region References#

Attempting to reference resources across AWS accounts or regions requires explicit handling.

// This won't work
const dataStack = new DataStack(app, 'Data', {
  env: { account: '111111111111', region: 'us-east-1' }
});

const appStack = new AppStack(app, 'App', {
  env: { account: '222222222222', region: 'eu-west-1' },
  table: dataStack.table // ERROR: Cross-account/region reference
});

// Solution: Export to SSM Parameter Store, then import
export class DataStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const table = new Table(this, 'Table', {/*...*/});

    new StringParameter(this, 'TableArnParameter', {
      parameterName: '/app/table-arn',
      stringValue: table.tableArn
    });
  }
}

export class AppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const tableArn = StringParameter.valueFromLookup(
      this,
      '/app/table-arn'
    );

    const table = Table.fromTableArn(this, 'Table', tableArn);
  }
}

Logical IDs and the Refactor Command#

Refactoring without preserving logical IDs causes resource replacement and potential data loss.

// Before: service-based organization
new Table(this, 'UserTable', {/*...*/});
// CloudFormation Logical ID: UserTable

// After: domain-based organization (moved to UserStack)
new Table(this, 'Table', {/*...*/});
// CloudFormation Logical ID: Table - DIFFERENT!
// CloudFormation will DELETE old table and CREATE new one

// Solution: Use CDK refactor command (preview)
// cdk refactor rename-construct --from UserTable --to UserDomainTable

// Or manually preserve logical ID
new Table(this, 'UserTable', {  // Keep same ID
  // ...
});

// Or use overrideLogicalId
const table = new Table(this, 'Table', {/*...*/});
(table.node.defaultChild as CfnTable).overrideLogicalId('UserTable');

When the Default Holds#

The domain-based default is worth keeping when teams map to business capabilities, when domains deploy on different cadences, and when the project will outgrow a single team. Inside it, reach for a construct rather than a new stack whenever resources change together.

A single-team project with a few dozen resources gains nothing from domain folders, and service-based grouping stays readable at that size. Product teams that ship user-facing features across several domains do better with feature folders, because a feature is the unit they actually deploy. Organizations with a separate platform team usually need layer-based stacks so the stateful data layer can carry its own removal policies and change cadence. Whichever direction you move, treat logical IDs as part of the migration and preserve them with cdk refactor or overrideLogicalId.

References#

Related posts