Skip to content

AWS CDK Code Organization: Service-Based vs Domain-Based Architecture Patterns

Learn when to use service-based, domain-based, feature-based, or layer-based organization patterns in AWS CDK projects. Includes decision frameworks, working examples, and migration strategies for maintainable infrastructure code.

Abstract

AWS CDK projects often start with unclear organization strategies, leading to tight coupling, circular dependencies, and deployment bottlenecks as they scale. This guide examines five organization patterns - service-based, domain-based, feature-based, layer-based, and hybrid - with working TypeScript examples and decision frameworks to help teams structure CDK projects that align with their business needs, team structure, and deployment requirements.

Problem Context

Working with CDK projects across different teams, I've noticed a recurring pattern: projects start with good intentions but gradually become difficult to maintain. The infrastructure code grows organically, files are placed wherever convenient, and before long, teams struggle with merge conflicts, unclear ownership, and deployment dependencies they can't 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 fundamental question isn't just about file organization - it's about modeling infrastructure that reflects business architecture, deployment requirements, and team structure while avoiding coupling issues that make systems fragile.

Technical Requirements

A well-organized CDK project needs to address several technical requirements:

Deployment Independence: Teams should be able to deploy their infrastructure changes without coordinating with other teams or worrying about breaking unrelated systems.

Clear Ownership: Every stack and construct should have an obvious owner, making it clear who to ask when issues arise or changes are needed.

Maintainability: Infrastructure code should be easy to navigate, with related resources grouped logically so developers can find what they need quickly.

Scalability: The organization pattern should work as the project grows from 10 resources to 500, from 1 team to 10 teams, without major restructuring.

Cross-Cutting Concerns: Shared infrastructure like VPCs, security policies, and monitoring needs to be handled without duplication or awkward dependencies.

Organization Patterns

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.

typescript
// 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.tsimport { 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.tsimport { 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

When service-based works: Small projects with fewer than 10 resources, learning/experimentation phase, or single-service applications where the organization pattern doesn't matter much yet.

Domain-Based Organization

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

typescript
// 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.tsimport { 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.tsimport { 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 stackorderStack.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.

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 using the same physical table. The table definition lives in shared/, while domains own only their access patterns.

When domain-based works: Microservices architectures, multi-team organizations, business-aligned infrastructure, Single-Table Design usage, or when clear bounded contexts exist.

Feature-Based Organization

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

typescript
// 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.tsimport { 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-based organization works well when features span multiple AWS services and domains, teams are organized around product features rather than technical layers, and the product requires rapid feature development and deployment.

When feature-based works: Product companies with feature teams, rapid development environments, features that can be deployed independently.

Layer-Based Organization

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

typescript
// 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.tsimport { 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 updatedconst foundationStack = new FoundationStack(app, 'Foundation', {  env: { account: '123456789012', region: 'us-east-1' }});
// Data layer - stateful, needs RETAIN removal policyconst dataStack = new DataStack(app, 'Data', {  env: { account: '123456789012', region: 'us-east-1' },  vpc: foundationStack.vpc});
// Compute layer - stateless, frequently updatedconst computeStack = new ComputeStack(app, 'Compute', {  env: { account: '123456789012', region: 'us-east-1' },  vpc: foundationStack.vpc,  tables: dataStack.tables});
// API layer - deployment artifact, very frequent updatesconst apiStack = new ApiStack(app, 'Api', {  env: { account: '123456789012', region: 'us-east-1' },  handlers: computeStack.handlers});
// Explicit dependencies ensure correct deployment orderdataStack.addDependency(foundationStack);computeStack.addDependency(dataStack);apiStack.addDependency(computeStack);

Layer-based organization provides clear separation of stateful and stateless resources, different update frequencies handled separately (foundation changes rarely, APIs change frequently), reduced deployment risk since the data layer rarely changes, and easier implementation of different removal policies per layer.

The drawbacks are that business logic gets scattered across layers, team ownership is less clear, and deploying complete features requires coordinating across multiple layers.

When layer-based works: Clear infrastructure layers, separate infrastructure and application teams, need to separate stateful from stateless resources.

Hybrid Approach

Real-world projects often benefit from combining patterns - using layers for cross-cutting concerns while organizing business logic by domain.

typescript
// 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.tsimport { 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.tsimport { 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.tsimport { 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 changesconst network = new NetworkStack(app, 'Network', {  env: config.env});
// Domain stacks - business logic, independent deploymentconst 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);

The hybrid approach gives us the best of both worlds: foundation layer handles cross-cutting concerns, domain stacks maintain business logic cohesion, and clear dependency graphs enable independent deployment after foundation is established.

Decision Framework

When to Split Stacks vs Use Constructs

One of the most common questions I encounter 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:

typescript
// 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 stackexport 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 stacksconst 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:

typescript
// 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"  }}

Monorepo benefits include single source of truth, atomic changes across services, easier refactoring across boundaries, shared constructs without publishing to npm, simplified dependency management, and single CI/CD pipeline configuration.

The drawbacks are larger repository clone time, risk of tight coupling between services, CI/CD complexity detecting which services changed, less granular team permissions, and potentially larger deployment blast radius.

Multi-repo pattern uses separate repositories for each service, with shared constructs published to a private npm registry.

Multi-repo benefits include clear service boundaries, independent versioning and deployment, clear team ownership and permissions, smaller repository size, language/framework diversity, and reduced merge conflicts.

The drawbacks are cross-service changes requiring multiple PRs, shared constructs needing publishing and versioning, dependency version management complexity, harder to ensure consistency, and more CI/CD pipeline overhead.

My 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 you have truly independent services with different lifecycles.

Handling Cross-Cutting Concerns

Networking, security, monitoring, and other cross-cutting concerns don't fit cleanly into domain boundaries. Here are three patterns that work well.

Foundation Stack Pattern

typescript
// lib/foundation/foundation-stack.tsimport { Stack, StackProps, Aspects } from 'aws-cdk-lib';import { Construct } from 'constructs';import { Vpc, IVpc, SubnetType, SecurityGroup } from 'aws-cdk-lib/aws-ec2';import { Dashboard } from 'aws-cdk-lib/aws-cloudwatch';import { Tag } from 'aws-cdk-lib';
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 foundationexport 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.

typescript
// lib/shared/aspects/security-compliance-aspect.tsimport { 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 appconst app = new App();Aspects.of(app).add(new SecurityComplianceAspect());

Shared Constructs Library

Create reusable L3 constructs that encapsulate best practices. You can further enhance these constructs with factory patterns and functional programming approaches - see AWS CDK Functional Patterns for details.

typescript
// lib/shared/constructs/monitored-lambda.tsimport { 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 stackconst handler = new MonitoredLambda(this, 'Handler', {  entry: 'src/handlers/user.ts',  alarmEmail: '[email protected]',  errorThreshold: 10});

Multi-Environment Management

Managing multiple environments (dev, staging, prod) requires careful configuration strategy.

Create all environments during synthesis to guarantee consistency.

typescript
// config/environment.tsexport 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.tsexport 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.tsexport 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.tsimport { 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.

Common Pitfalls

Premature Stack Splitting

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

typescript
// 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 organizationexport 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.

typescript
// 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 resourcesconst 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

Design unidirectional dependency flow. Use shared resource stacks or event-driven architecture to break cycles.

Cross-Account/Region References

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

typescript
// This won't workconst 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 importexport 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);  }}

Cross-account/region references require explicit export/import using SSM Parameter Store or manual ARN passing.

Not Using CDK Refactor Tool

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

typescript
// Before: service-based organizationnew 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 IDnew Table(this, 'UserTable', {  // Keep same ID  // ...});
// Or use overrideLogicalIdconst table = new Table(this, 'Table', {/*...*/});(table.node.defaultChild as CfnTable).overrideLogicalId('UserTable');

When reorganizing CDK code, always preserve logical IDs to prevent resource replacement.

Results

After implementing domain-based organization with a foundation layer in a project, the team saw measurable improvements. Deployment time for individual services dropped from 15 minutes (deploying all services) to 3-5 minutes (deploying just the changed service). Merge conflicts decreased significantly - instead of 3-4 conflicts per sprint from multiple teams touching shared stacks, conflicts became rare since each team owned their domain stack.

The organization pattern also improved onboarding. New developers could understand the user service by reading just the domains/user/ directory instead of piecing together infrastructure scattered across service-type folders. Code review time improved because reviewers could focus on a single domain without understanding cross-stack dependencies.

Key Takeaways

Organization pattern matches business structure: Choose domain-based for business-aligned teams, layer-based for infrastructure teams, feature-based for product teams. The pattern should reflect how your organization thinks about the system.

Constructs before stacks: Most code organization problems are solved with constructs, not multiple stacks. Split stacks only for deployment independence, team boundaries, or hitting resource limits.

Foundation pattern is essential: Separate shared infrastructure (VPC, networking, security) from domain-specific resources to avoid circular dependencies and enable domain independence.

Monorepo for small teams, multi-repo for large: Monorepos work well up to ~50 developers. After that, consider multi-repo for clear service boundaries and team autonomy.

Static stack creation guarantees consistency: Creating all environments during synthesis (dev, staging, prod) ensures tested code in dev is exactly what deploys to prod.

Start simple, evolve gradually: Begin with larger stacks and constructs, split into separate stacks only when clear reasons emerge (different teams, different lifecycles, resource limits).

The right organization pattern depends on your specific context - team size, deployment requirements, and business structure. Start simple and let the organization emerge as real needs become clear.

References

Related Posts