CDK Project Factory: 4 Projects, 11 Stacks, One Entry Point

TL;DR — CDK gives you constructs but no opinion on how to organise them. This article shows how I use project-specific factories to manage 4 projects, 11 stacks, and ~300 resources from a single app.ts that does almost nothing. Typed config modules, SSM-based discovery, and CDK Aspects handle the rest.


1. The "Solo-Preneur" Context

The Constraint

I run four production-grade projects from a single CDK monorepo:

ProjectStacksResourcesKey Services
Next.js6~200ECS, ALB, CloudFront, API Gateway, Lambda, DynamoDB, WAF
Monitoring3~80EC2/ASG, EBS, Prometheus, Grafana, Loki, Docker Compose
Shared1~15VPC, Subnets, Flow Logs, ECR
Org1~5Cross-account IAM DNS role (root account)
Total11~300Across 3 environments × 3 AWS accounts

Every project shares a VPC but has wildly different resource profiles. Next.js needs ECS clusters, ALBs, CloudFront distributions, API Gateway, Lambda functions, and DynamoDB tables. Monitoring needs EC2 instances, EBS volumes, security groups, and SSM documents. Both need hardened security, thoughtful IAM policies, and CDK-Nag compliance.

I needed to be able to add a new project by writing a config file and a factory class, not by rewriting the deployment pipeline.

Why a Factory — and What I Rejected

Before settling on a factory pattern, I seriously considered three alternatives. CDK Pipelines is AWS's blessed answer to multi-stack orchestration, but it wants to own the entire deployment pipeline — and mine already handles OIDC authentication, Checkov scanning, SLSA provenance tagging, and environment-scoped rollbacks across 19 workflow files. Layering CDK Pipelines on top would duplicate that control plane without adding value. I also need per-stack deployment control (cdk deploy MonitoringCompute without touching the Next.js stacks), which CDK Pipelines' wave model makes awkward.

A multi-app monorepo — one cdk.App per project — would give that isolation, but at the cost of duplicating every shared concern: each app gets its own cdk.context.json, its own VPC lookup cache, its own copy of L3 constructs (or an internal npm package to manage). The factory approach gives me shared constructs and a single compilation unit for free.

Finally, an Nx or Turborepo workspace would manage the multi-app approach cleanly, but it adds build-system complexity for a problem that TypeScript's type system already solves. The factory pattern lets one tsc invocation validate every project's configuration, every cross-stack reference, and every construct interface — no task runner needed.

The 2026 Architecture Shift

Old Way (2020)New Way (2026)
One CDK app per project, each with its own patternsSingle mono-CDK app with project-specific factories
Copy-paste constructs between projectsShared L3 construct library with hardened defaults
Environment config scattered across .env, context, secretsTyped config modules per project with fromEnv() resolution
Manual cross-stack wiring with CloudFormation exportsSSM Parameter Store with centralised path conventions
app.ts owned context parsing, VPC lookup, secrets, everythingSlim app.ts that delegates ALL resolution to factories
CDK-Nag as an afterthoughtCDK-Nag in CI — zero suppressions without documented rationale
No compile-time governanceCDK Aspects enforce tagging, DynamoDB read-only, compliance

Repository directory tree showing the lib/ folder structure with config/, factories/, projects/, stacks/, common/, and aspects/ directories

Real screenshot — to be captured via SSM session


2. Architecture Diagram

Hover to zoom

3. CDK Construct Levels: The Foundation

Before explaining the factory pattern, it helps to understand how CDK organises constructs into three levels — because the factory's value comes from which level each resource lives at.

L1: CloudFormation Resource Wrappers (Cfn*)

L1 constructs are 1:1 mappings to CloudFormation resources. CDK auto-generates them from the CloudFormation Resource Specification. They give you full control, zero abstraction, and zero safety net. In this project, L1 constructs appear in exactly one place: WAFv2. As of February 2026, AWS CDK still has no L2 construct for WAFv2:

// lib/stacks/nextjs/edge/edge-stack.ts — L1 construct (no L2 available)
this.webAcl = new wafv2.CfnWebACL(this, "CloudFrontWebAcl", {
  scope: "CLOUDFRONT", // Must be literal string, not an enum
  defaultAction: { allow: {} }, // Raw CFN property bag
  visibilityConfig: {
    cloudWatchMetricsEnabled: true,
    metricName: `${namePrefix}-waf-metrics`,
    sampledRequestsEnabled: true,
  },
  rules: buildWafRules({
    enableRateLimiting: true,
    rateLimitPerIp: 5000,
    enableIpReputationList: true,
  }),
});

// Association is also L1 — no .associate() method exists
new wafv2.CfnWebACLAssociation(this, "AlbWafAssociation", {
  resourceArn: this.api.api.deploymentStage.stageArn,
  webAclArn: this.webAcl.attrArn, // .attrArn, not .arn — L1 pattern
});
L1 Pain Points

L1 constructs use attrArn instead of arn, camelCase property bags instead of builder patterns, and raw strings instead of enums. Every property is optional in TypeScript but required in CloudFormation — you'll only discover missing fields at deploy time. The buildWafRules() helper in lib/common/security/waf-rules.ts exists to create a reusable, type-safe wrapper around this raw L1 surface.

L2: AWS Construct Library (Curated Abstractions)

L2 constructs are CDK's curated abstractions — one per AWS resource, with sensible defaults, grant methods, and cross-service integration. This project uses L2 constructs extensively, but rarely directly in stacks. Instead, they appear inside L3 constructs as implementation details:

// lib/common/security/security-group.ts — L2 used inside L3
export class BaseSecurityGroupConstruct extends Construct {
  public readonly securityGroup: ec2.SecurityGroup; // L2 construct

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

    // L2 gives us: grant methods, .connections, .addIngressRule()
    this.securityGroup = new ec2.SecurityGroup(this, "SG", {
      vpc: props.vpc,
      securityGroupName: props.securityGroupName,
      description: props.description,
      allowAllOutbound: props.allowAllOutbound ?? true,
    });

    cdk.Tags.of(this.securityGroup).add("Component", "SecurityGroup");
  }
}

The pattern I follow: stacks don't create ec2.SecurityGroup directly. They use the project's L3 construct, which wraps the L2 with hardened defaults baked in.

L3: Domain-Specific Constructs (Your Reusable Patterns)

L3 constructs compose multiple L2 resources into a single unit with defaults, helper methods, and compliance checks built in. They're the reusable building blocks that keep individual stacks simple. The lib/common/ directory contains L3 constructs organised by domain:

DomainL3 ConstructsFilesKey Pattern
ComputeLambdaFunctionConstruct, AutoScalingGroupConstruct, Ec2InstanceConstruct, LaunchTemplateConstruct, UserDataBuilder6Log group created first, IAM grants on any role type
NetworkingApplicationLoadBalancerConstruct, CloudFrontConstruct, ApiGatewayConstruct, VpcFlowLogs, GatewayEndpoints5VPC stored internally, 8+ helper methods
SecurityBaseSecurityGroupConstructEcsSecurityGroupConstructNextJsTaskSecurityGroupConstructSecurityGroupConstruct, KmsKeyConstruct, AcmCertificateConstruct, buildWafRules()44-level inheritance hierarchy
StorageEbsVolumeConstruct, S3BucketConstruct, DynamoDbTableConstruct, EcrRepositoryConstruct4DLM snapshots, encryption, lifecycle management
SSMSsmRunCommandDocument2SSM document provisioning
EventsEventBridgeRule2EventBridge patterns
IAMIndex + barrel1Policy helpers

Here's the LambdaFunctionConstruct — the most feature-rich L3. The interesting bits are the log group race condition prevention and the bundling escape hatch:

// lib/common/compute/constructs/lambda-function.ts — L3 construct
export class LambdaFunctionConstruct extends Construct {
  public readonly function: NodejsFunction;
  public readonly logGroup: logs.LogGroup;
  public readonly role: iam.IRole;

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

    // Log group created FIRST — prevents CDK race condition
    // where Lambda creates an implicit log group that conflicts
    this.logGroup = new logs.LogGroup(this, "LogGroup", {
      logGroupName: `/aws/lambda/${functionName}`,
      retention: props.logRetention ?? logs.RetentionDays.ONE_MONTH,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // Bundling with escape hatch — consumer can override any default
    const baseBundling: BundlingOptions = {
      sourceMap: true,
      format: OutputFormat.CJS,
      target: "node22",
      externalModules: ["@aws-sdk/*"],
      ...props.bundlingOverrides,
    };

    this.function = new NodejsFunction(this, "Function", {
      functionName,
      runtime: props.runtime ?? lambda.Runtime.NODEJS_22_X,
      entry: props.entry,
      bundling: baseBundling,
      logGroup: this.logGroup,
      // ... timeout, memorySize, DLQ, environment, role
    });
  }
}
What L3 Constructs Buy You

Every LambdaFunctionConstruct in this project gets: source maps, lock file detection, log group race condition prevention, CDK-Nag compliance, and environment variable merging — without the stack author doing anything. The right defaults are already wired in.


4. The "Golden Path" Implementation: The Project Factory

Layer 1: The Interface Contract — Generic IProjectFactory<TContext>

The factory pattern starts with a generic TypeScript interface that every project implements with its own typed context:

// lib/factories/project-interfaces.ts
export interface ProjectFactoryContext {
  readonly environment: Environment;
  readonly [key: string]: unknown; // Escape hatch for extensibility
}

export interface ProjectStackFamily {
  readonly stacks: cdk.Stack[];
  readonly stackMap: Record<string, cdk.Stack>;
}

export interface IProjectFactory<
  TContext extends ProjectFactoryContext = ProjectFactoryContext,
> {
  readonly project: Project;
  readonly environment: Environment;
  readonly namespace: string;
  createAllStacks(scope: cdk.App, context: TContext): ProjectStackFamily;
}

export type ProjectFactoryConstructor = new (
  environment: Environment,
) => IProjectFactory<any>;

The generic <TContext> parameter is the key design decision — each factory defines its own extended context type with type-safe fields:

FactoryContext TypeProject-Specific Fields
NextJSConsolidatedFactoryContextdomainName, hostedZoneId, crossAccountRoleArn, imageTag, sesFromEmail, verificationSecret
MonitoringMonitoringFactoryContextcomputeMode, trustedCidrs, grafanaPassword
OrgOrgFactoryContexthostedZoneIds, trustedAccountIds, externalId
SharedProjectFactoryContext(no extensions needed)

Layer 2: The Registry (Strategy Pattern)

The registry maps project names to factory constructors using the Strategy Pattern. Adding a new project is three steps: create a factory, register it, add the enum value:

// lib/factories/project-registry.ts
const projectFactoryRegistry: Record<Project, ProjectFactoryConstructor> = {
  [Project.SHARED]: SharedProjectFactory,
  [Project.MONITORING]: MonitoringProjectFactory,
  [Project.NEXTJS]: NextJSProjectFactory,
  [Project.ORG]: OrgProjectFactory,
};

export function getProjectFactoryFromContext(
  projectStr: string,
  environmentStr: string,
): IProjectFactory {
  if (!isValidProject(projectStr)) {
    const available = getAvailableProjects().join(", ");
    throw new Error(`Invalid project: '${projectStr}'. Valid: ${available}`);
  }

  // resolveEnvironment handles short names: dev → development, prod → production
  const resolvedEnv = resolveEnvironment(environmentStr);
  return getProjectFactory(projectStr as Project, resolvedEnv);
}

// Escape hatch for runtime extensibility
export function registerProjectFactory(
  project: Project,
  factory: ProjectFactoryConstructor,
): void {
  projectFactoryRegistry[project] = factory;
}

Layer 3: The Factory Implementation — Context Override > Config Default

Here's where the architectural shift from the old codebase is most visible. Factories now own ALL context resolution using a cascading pattern: context override > typed config > default:

// lib/projects/nextjs/factory.ts
export class ConsolidatedNextJSFactory implements IProjectFactory<ConsolidatedFactoryContext> {
  readonly project = Project.NEXTJS;
  readonly environment: Environment;
  readonly namespace: string;

  createAllStacks(
    scope: cdk.App,
    context: ConsolidatedFactoryContext,
  ): ProjectStackFamily {
    // 1. Load typed config for this environment
    const config = getNextJsConfigs(this.environment);

    // 2. CDK environment from per-account config (NOT CDK_DEFAULT_*)
    const env = cdkEnvironment(this.environment);

    // 3. SSM paths from centralised module — single source of truth
    const ssmPaths = nextjsSsmPaths(this.environment, namePrefix);
    const resourceNames = nextjsResourceNames(namePrefix, this.environment);

    // 4. Context override > config default (Edge / CloudFront)
    const edgeConfig = {
      domainName: context.domainName ?? config.domainName,
      hostedZoneId: context.hostedZoneId ?? config.hostedZoneId,
      crossAccountRoleArn:
        context.crossAccountRoleArn ?? config.crossAccountRoleArn,
    };

    // 5. Soft validation — warn, don't throw (CDK --exclusively means
    //    not all stacks are deployed at once)
    if (!edgeConfig.domainName || !edgeConfig.hostedZoneId) {
      cdk.Annotations.of(scope).addWarning(
        `Edge config incomplete: ${missing.join(", ")}. ` +
          `Edge stack will fail if deployed without these values.`,
      );
    }

    // 6. Domain sanity check (dev.example.com for development, etc.)
    if (edgeConfig.domainName && this.environment !== "production") {
      const expectedPrefix =
        this.environment === "development" ? "dev." : "staging.";
      if (!edgeConfig.domainName.startsWith(expectedPrefix)) {
        cdk.Annotations.of(scope).addWarning(
          `Domain "${edgeConfig.domainName}" does not match ${expectedPrefix}...`,
        );
      }
    }

    // 7. Create stacks in dependency order...
  }
}

This pattern is replicated in the Monitoring factory with Grafana password cascading:

// lib/projects/monitoring/factory.ts — Production fail-fast
const grafanaPassword =
  context.grafanaPassword ??
  config.grafanaAdminPassword ??
  (this.environment === Environment.PRODUCTION
    ? (() => {
        throw new Error("Missing GRAFANA_ADMIN_PASSWORD for production.");
      })()
    : "admin"); // Safe default for non-prod
  1. 1

    CDK Context Parsing

    bin/app.ts parses -c project= and -c environment= from command line.

  2. 2

    Factory Registry Lookup

    getProjectFactoryFromContext() resolves short names (dev → development) and returns the matching factory constructor.

  3. 3

    Typed Config Resolution

    Factory calls getNextJsConfigs(env) or getMonitoringConfigs(env) for environment-specific typed configuration.

  4. 4

    Context Override Cascading

    context.domainName ?? config.domainName — CDK context overrides typed config defaults.

  5. 5

    Stack Creation in Dependency Order

    Factory creates Data → Compute → Network → App → API → Edge stacks, wiring SSM paths and resource names.

  6. 6

    Cross-Cutting Aspects Applied

    TaggingAspect, CDK-Nag, and EnforceReadOnlyDynamoDb visit every construct after synthesis.

Layer 4: The Slim Entry Point

The entry point is radically slim. It parses only structural routing (project + environment), then delegates everything else:

// bin/app.ts — THE ENTIRE FILE
const app = new cdk.App();

// 1. Parse project + environment from CDK context
const projectContext = app.node.tryGetContext("project") as string | undefined;
const environmentContext = app.node.tryGetContext("environment") as
  | string
  | undefined;

if (!projectContext || !isValidProject(projectContext)) {
  throw new Error(
    "Project context required. Use: -c project=monitoring|nextjs|shared|org",
  );
}

// 2. Get the correct factory from the registry
const factory = getProjectFactoryFromContext(projectContext, environment);

// 3. Create ALL stacks — factory handles dependencies, config, VPC lookup, secrets
const { stacks } = factory.createAllStacks(app, { environment });

// 4. Cross-cutting aspects (applied AFTER all stacks are created)
cdk.Aspects.of(app).add(
  new TaggingAspect({
    environment,
    project: projectConfig.namespace,
    owner: process.env.PROJECT_OWNER ?? "Nelson Lamounier",
  }),
);

if (projectContext === "nextjs") {
  cdk.Aspects.of(app).add(
    new EnforceReadOnlyDynamoDbAspect({
      failOnViolation: true,
      roleNamePattern: "taskrole",
    }),
  );
}

if (enableNagChecks) {
  applyCdkNag(app, { packs: [CompliancePack.AWS_SOLUTIONS] });
  stacks.forEach((stack) => applyCommonSuppressions(stack));
}

The power of this design is in what app.ts does not do. It never calls Vpc.fromLookup() — each factory performs its own VPC discovery via a vpcName prop. It never parses environment variables — factories call their own typed config modules (getNextJsConfigs(), getMonitoringConfigs()). It never resolves CloudFront/Edge configuration, secrets, stack dependency ordering, or cross-region handling. All of that complexity lives inside the factories, where it belongs. The entry point is a structural router, not an orchestrator.

The bin/app.ts file in VS Code showing the slim entry point with clear section separators

Real screenshot — to be captured via SSM session


5. Typed Configuration Modules

The Problem with Environment Variables

The old pattern scattered process.env reads across stacks and app.ts. A typo in an environment variable name would silently produce undefined, which only manifested as a CloudFormation deployment failure.

The Solution: Per-Project Typed Config

Each project has a dedicated config module that resolves all synth-time values through a single typed interface:

Hover to zoom

Centralised SSM Paths

The lib/config/ssm-paths.ts module eliminates inline string concatenation and enforces 4 namespace conventions:

// lib/config/ssm-paths.ts — 4 SSM namespace conventions
// Next.js:     /{namePrefix}/{environment}/...
// Shared ECR:  /shared/ecr/{environment}/...
// Shared VPC:  /shared/vpc/{environment}/...
// Monitoring:  /monitoring-{environment}/...

export function nextjsSsmPaths(
  environment: Environment,
  namePrefix: string = "nextjs",
): NextjsSsmPaths {
  const prefix = `/${namePrefix}/${environment}`;
  return {
    prefix,
    dynamodbTableName: `${prefix}/dynamodb-table-name`,
    assetsBucketName: `${prefix}/assets-bucket-name`,
    albDnsName: `${prefix}/alb-dns-name`,
    ecs: {
      clusterName: `${prefix}/ecs/cluster-name`,
      serviceName: `${prefix}/ecs/service-name`,
    },
    cloudmap: {
      namespaceName: `${prefix}/cloudmap/namespace-name`,
    },
    cloudfront: {
      wafArn: `${prefix}/cloudfront/waf-arn`,
      distributionDomain: `${prefix}/cloudfront/distribution-domain`,
    },
    wildcard: `${prefix}/*`,
  };
}

Both the factory (which writes SSM parameters) and consuming stacks (which read them) import from the same module. A mismatched path is a compile-time import error, not a runtime discovery failure.

Environment Identity

Each environment maps to a dedicated AWS account with hardcoded regions — not derived from CDK_DEFAULT_REGION, which changes when the Edge deploy job configures credentials for us-east-1:

// lib/config/environments.ts — Per-environment account + region
const environments: Record<Environment, EnvironmentConfig> = {
    [Environment.DEVELOPMENT]: { account: '771826808455', region: 'eu-west-1', edgeRegion: 'us-east-1' },
    [Environment.STAGING]:     { account: '692738841103', region: 'eu-west-1', edgeRegion: 'us-east-1' },
    [Environment.PRODUCTION]:  { account: '607700977986', region: 'eu-west-1', edgeRegion: 'us-east-1' },
};

// Utility functions for CDK env props
export function cdkEnvironment(env: Environment): cdk.Environment { ... }
export function cdkEdgeEnvironment(env: Environment): cdk.Environment { ... }
export function environmentRemovalPolicy(env: Environment): cdk.RemovalPolicy { ... }
export function resolveEnvironment(contextValue?: string): Environment { ... }
// resolveEnvironment() handles short names: dev → development, prod → production

6. Cross-Stack Communication: SSM vs CloudFormation Exports

The Architecture Decision

The factory uses SSM Parameter Store for all cross-stack and cross-region communication:

ApproachProsCons
CloudFormation ExportsStrong dependency, CDK auto-generatesLock-in: can't delete exporting stack
SSM ParametersDecoupled, cross-region, cross-account capableEventual consistency, no dependency enforcement
Construct role ARNs from namesAvoids token resolution entirelyRequires naming convention discipline
  1. 1

    Stack Writes SSM Parameter

    Data stack creates ssm.StringParameter with path from centralized ssm-paths.ts module.

  2. 2

    Factory Passes SSM Path String

    Factory passes the SSM path string (not the construct reference) to the consuming stack.

  3. 3

    Consumer Reads via AwsCustomResource

    Edge stack uses AwsCustomResource with explicit region parameter for cross-region SSM reads.

  4. 4

    ARN Construction from Names

    Factory constructs IAM role ARNs from role names to avoid implicit CloudFormation exports.

  5. 5

    VPC Discovery via fromLookup()

    Each stack performs Vpc.fromLookup() using VPC Name tag — no cross-stack exports needed.

Pattern 1: Construct ARNs from Names

Instead of passing computeStack.taskRole (which contains CDK tokens and creates implicit CloudFormation exports), the factory constructs ARNs from role names:

// ❌ BAD: Creates implicit CloudFormation export
taskRoleArn: computeStack.taskRole.roleArn,

// ✅ GOOD: No token, no export, no coupling
taskRoleArn: `arn:aws:iam::${env.account}:role/${computeStack.taskRoleName}`,

Pattern 2: Cross-Region SSM Reads

For values that must cross regions (like the ALB DNS name that the Edge stack needs in us-east-1), each stack writes to SSM in its own region, and the Edge stack reads using AwsCustomResource with an explicit region parameter:

// Data stack writes to SSM (eu-west-1)
new ssm.StringParameter(this, "AlbDnsParam", {
  parameterName: ssmPaths.albDnsName,
  stringValue: this.alb.dnsName,
});

// Edge stack reads from SSM (us-east-1 → eu-west-1 cross-region read)
const albDns = new cr.AwsCustomResource(this, "AlbDnsLookup", {
  onUpdate: {
    service: "SSM",
    action: "getParameter",
    parameters: { Name: props.albDnsSsmPath },
    region: props.albDnsSsmRegion, // Cross-region!
  },
});

Pattern 3: VPC Discovery via fromLookup()

Each stack performs its own Vpc.fromLookup() using the VPC Name tag — resolved at synth time via CDK context caching (cdk.context.json). No cross-stack exports needed:

// Factory passes vpcName string, not VPC construct reference
const computeStack = new MonitoringComputeStack(scope, this.stackId('Compute'), {
    vpcName: `shared-vpc-${this.environment}`, // Resolved via Vpc.fromLookup()
    ...
});
SSM vs CloudFormation Exports — The Trade-Off

SSM parameters are intentionally decoupled: a parameter can be recreated manually with aws ssm put-parameter, it works cross-region (which CloudFormation exports cannot do), and stack refactoring requires no consumer changes. The trade-off is eventual consistency and no automatic dependency enforcement.


7. The "Oh No" Moment: SSM Parameter Race Condition

The Failure Mode

During a deployment, I renamed a CDK construct that managed an AwsCustomResource for SSM parameter creation. CDK treats construct renaming as a replace — it deletes the old resource and creates a new one. The old resource's onDelete handler deleted the SSM parameter before the new resource could create it:

Hover to zoom

Why SSM Was Still the Right Choice

CloudFormation exports would have prevented this entirely — but at the cost of permanent lock-in. You cannot delete a stack that has active exports, and you cannot modify the export value without first updating all consumers. SSM parameters are intentionally decoupled: the parameter can be recreated manually with aws ssm put-parameter, it works cross-region (which CloudFormation exports cannot do), and stack refactoring requires no consumer changes.

The fix was straightforward: manually recreate the parameter, then deploy. The lesson: always run cdk diff before deploying construct renames, and treat construct IDs as part of your infrastructure's public API.

Lesson Learned: Construct IDs Are Public API

Always run cdk diff before deploying construct renames. CDK treats construct ID changes as resource replacement — the old resource's onDelete handler runs before the new resource creates its replacement. For SSM-backed custom resources, this creates a window where the parameter doesn't exist.


8. CDK Aspects: Governance at Scale

CDK Aspects are the pattern's enforcement layer. They visit every construct in the tree after synthesis, applying rules that individual stacks can't override.

TaggingAspect — Zero-Effort 5-Key Schema

Every resource gets 5 tags without any construct or stack code:

// bin/app.ts
cdk.Aspects.of(app).add(
  new TaggingAspect({
    environment, // → Tag: "Environment"
    project: projectConfig.namespace, // → Tag: "Project"
    owner: process.env.PROJECT_OWNER ?? "Nelson Lamounier", // → Tag: "Owner"
    costCenter: process.env.COST_CENTER, // → Tag: "CostCenter"
  }),
);
// TaggingAspect also adds "ManagedBy: CDK" automatically

This is why no construct in lib/common/ adds ManagedBy, Environment, or Project tags — the aspect handles them. Constructs only add domain-specific tags like Component: Lambda or Purpose: AccessLogs.

EnforceReadOnlyDynamoDbAspect — Security Boundary (202 Lines)

The Next.js architecture has a clear read/write boundary:

  • ECS Task Role = read-only DynamoDB access (SSR queries)
  • Lambda Execution Role = read-write DynamoDB access (API mutations)
DynamoDB Read-Only Enforcement

The EnforceReadOnlyDynamoDbAspect inspects every iam.CfnPolicy construct, resolves its attached roles, and checks if any task role has 8 forbidden DynamoDB actions. This catches drift before deployment — if someone accidentally grants write access to the task role, CDK synthesis fails with a clear error message, not a production incident.

The aspect inspects every iam.CfnPolicy construct, resolves its attached roles, and checks if any task role has 8 forbidden DynamoDB actions:

// lib/aspects/enforce-readonly-dynamodb-aspect.ts
export const FORBIDDEN_DYNAMODB_ACTIONS: readonly string[] = [
  // Write actions
  "dynamodb:PutItem",
  "dynamodb:DeleteItem",
  "dynamodb:UpdateItem",
  "dynamodb:BatchWriteItem",
  // Admin actions (table management = CDK only, never runtime)
  "dynamodb:CreateTable",
  "dynamodb:DeleteTable",
  "dynamodb:UpdateTable",
  "dynamodb:CreateGlobalTable",
] as const;

export class EnforceReadOnlyDynamoDbAspect implements cdk.IAspect {
  visit(node: IConstruct): void {
    if (!(node instanceof iam.CfnPolicy)) return;

    // Resolve roles array — handles { Ref: 'LogicalId' } tokens
    const resolved = cdk.Stack.of(node).resolve(node.roles);

    // Match role references against the pattern (default: 'taskrole')
    const isTaskRolePolicy = resolved.some((role) => {
      const roleId =
        typeof role === "string"
          ? role
          : ((role as { Ref?: string })?.Ref ?? "");
      return roleId.toLowerCase().includes(this.roleNamePattern);
    });

    if (isTaskRolePolicy) {
      this.inspectPolicyDocument(node, node.policyDocument);
    }
  }
}

CDK-Nag — AwsSolutions Compliance

CDK-Nag runs the AwsSolutions rule pack against all stacks at synthesis time:

// bin/app.ts
if (enableNagChecks) {
  applyCdkNag(app, {
    packs: [CompliancePack.AWS_SOLUTIONS],
    verbose: false,
    reports: true,
  });
  stacks.forEach((stack) => applyCommonSuppressions(stack));
}

The applyCommonSuppressions() function documents every suppression with a rationale — zero suppressions are allowed without explanation. CDK-Nag supports 4 compliance packs: AWS Solutions, HIPAA, NIST 800-53, and PCI DSS.

CDK-Nag compliance report output showing AwsSolutions rule checks with zero unsuppressed findings

Terminal recording — to be captured via SSM session


9. The Security Group Hierarchy: L3 Inheritance in Action

The security group design is the clearest example of how L3 constructs compose L2 primitives into a domain model:

Hover to zoom
Restricted Egress for Tasks — The Key Design Decision

NextJsTaskSecurityGroupConstruct sets allowAllOutbound: false and explicitly adds only HTTPS (443) and HTTP (80) egress. This means Next.js tasks can reach DynamoDB and S3 via HTTPS, but cannot make arbitrary outbound connections — preventing a compromised container from establishing C2 connections.


10. FinOps & Maintenance

The factory pattern costs nothing at runtime — it's synthesis-time TypeScript, not deployed infrastructure. It eliminated roughly 2,000 lines of duplicate constructs, brought app.ts down from ~200 lines to ~40, and caught 3 security regressions via CDK-Nag and the DynamoDB aspect before they reached production. Maintenance runs about 1–2 hours per month, mostly construct library updates in lib/common/ that automatically apply to every project. Adding a new stack means adding it to the factory's createAllStacks() method — no other files change.


11. What Needs Work — and What's Next

What's Working

PatternStatusImpact
Shared L3 Construct Library✅ ShippedHardened defaults, DRY across 4 projects
Generic IProjectFactory<TContext>✅ ShippedType-safe context per project
Strategy Pattern Registry✅ Shipped4 projects from single entry point
Typed Config Modules✅ Shippedcontext > config > default cascading
CDK Aspects (3 aspects)✅ ShippedTagging, compliance, DynamoDB guard
Centralised SSM Parameter Paths✅ ShippedCross-stack + cross-region discovery
Slim Entry Point✅ ShippedFactory owns everything
Security Group Hierarchy✅ Shipped4-level inheritance with least-privilege

Remaining Gaps and Roadmap

ImprovementEffortImpactStatus
Remove [key: string]: unknown from ProjectFactoryContext1 hourFull type safety, no escape hatchPlanned
CDK Pipelines migration for self-mutating deployments1 dayEliminate manual deployment scriptsEvaluating
Contract tests for SSM path conventions3 hoursCatch mismatched paths at test timePlanned
Cloud Development Kit for Terraform (CDKTF) experiment2 daysMulti-cloud construct reuseResearching

I still have an [key: string]: unknown escape hatch in ProjectFactoryContext — that needs to go. And SSM path mismatches should fail in tests, not in production.

The bottom line: this pattern lets me treat new projects as configuration, not engineering. The constructs handle the hard defaults, the factories handle the wiring, and the aspects handle the rules. It's not perfect — the gaps above are real — but it scales well enough that one person can run it without things falling apart.


12. Related Files

FileDescription
bin/app.tsSlim entry point — context parsing, factory delegation, aspect application
lib/factories/project-interfaces.tsGeneric IProjectFactory<TContext>, ProjectStackFamily interfaces
lib/factories/project-registry.tsStrategy pattern registry with registerProjectFactory() escape hatch
lib/projects/nextjs/factory.tsNext.js factory — 6 stacks, ConsolidatedFactoryContext
lib/projects/monitoring/factory.tsMonitoring factory — 3 stacks, Grafana password cascading
lib/projects/shared/factory.tsShared factory — 1 VPC stack
lib/projects/org/factory.tsOrg factory — 1 DNS role stack, CDK context parsing
lib/config/environments.tsPer-account config, cdkEdgeEnvironment(), resolveEnvironment()
lib/config/ssm-paths.tsCentralised SSM paths with 4 namespace conventions
lib/config/projects.tsProject enum, ProjectConfig metadata, requiresSharedVpc flags
lib/config/defaults.tsCentralised constants (ports, volumes, versions)
lib/aspects/tagging-aspect.ts5-key tag schema applied to all resources
lib/aspects/enforce-readonly-dynamodb-aspect.tsDynamoDB read-only IAM enforcement
lib/aspects/cdk-nag-aspect.tsCDK-Nag with 4 compliance packs + documented suppressions
lib/common/compute/constructs/lambda-function.tsL3 Lambda — bundling, log groups, IAM, DLQ
lib/common/security/security-group.ts4-level SG hierarchy (Base → ECS → Task → Generic)
lib/common/security/waf-rules.tsReusable WAF rule builder wrapping L1 CfnWebACL
lib/common/networking/elb/application-load-balancer.tsL3 ALB — 8+ helper methods
lib/common/networking/cloudfront.tsL3 CloudFront — multi-origin, cache policies
lib/common/storage/dynamodb-table.tsL3 DynamoDB — GSI conventions, encryption

13. Tech Stack Summary

LayerToolRole
Infrastructure as CodeAWS CDK v2 (TypeScript)Constructs, stacks, aspects, synthesis
L1 Constructswafv2.CfnWebACLWAF rules (no L2 available as of 2026)
L2 Constructsec2, elbv2, lambda, s3Core AWS resources with grant methods
L3 ConstructsCustom libraryHardened blueprints with validated defaults
Design PatternFactory + StrategyProject-specific stack creation from single app
Config ModulesTypeScript + fromEnv()Typed, environment-specific configuration
Cross-StackSSM Parameter StoreCross-stack + cross-region discovery
GovernanceCDK Aspects (3 aspects)Tagging, DynamoDB guard, compliance
ComplianceCDK-Nag (AwsSolutions)Security rule validation at synthesis time
CI/CDGitHub ActionsSynthesize, scan, deploy per-stack
TestingJest + CDK assertions26 test files, factory integration tests