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.tsthat 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:
| Project | Stacks | Resources | Key Services |
|---|---|---|---|
| Next.js | 6 | ~200 | ECS, ALB, CloudFront, API Gateway, Lambda, DynamoDB, WAF |
| Monitoring | 3 | ~80 | EC2/ASG, EBS, Prometheus, Grafana, Loki, Docker Compose |
| Shared | 1 | ~15 | VPC, Subnets, Flow Logs, ECR |
| Org | 1 | ~5 | Cross-account IAM DNS role (root account) |
| Total | 11 | ~300 | Across 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 patterns | Single mono-CDK app with project-specific factories |
| Copy-paste constructs between projects | Shared L3 construct library with hardened defaults |
Environment config scattered across .env, context, secrets | Typed config modules per project with fromEnv() resolution |
| Manual cross-stack wiring with CloudFormation exports | SSM Parameter Store with centralised path conventions |
app.ts owned context parsing, VPC lookup, secrets, everything | Slim app.ts that delegates ALL resolution to factories |
| CDK-Nag as an afterthought | CDK-Nag in CI — zero suppressions without documented rationale |
| No compile-time governance | CDK 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
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 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:
| Domain | L3 Constructs | Files | Key Pattern |
|---|---|---|---|
| Compute | LambdaFunctionConstruct, AutoScalingGroupConstruct, Ec2InstanceConstruct, LaunchTemplateConstruct, UserDataBuilder | 6 | Log group created first, IAM grants on any role type |
| Networking | ApplicationLoadBalancerConstruct, CloudFrontConstruct, ApiGatewayConstruct, VpcFlowLogs, GatewayEndpoints | 5 | VPC stored internally, 8+ helper methods |
| Security | BaseSecurityGroupConstruct → EcsSecurityGroupConstruct → NextJsTaskSecurityGroupConstruct → SecurityGroupConstruct, KmsKeyConstruct, AcmCertificateConstruct, buildWafRules() | 4 | 4-level inheritance hierarchy |
| Storage | EbsVolumeConstruct, S3BucketConstruct, DynamoDbTableConstruct, EcrRepositoryConstruct | 4 | DLM snapshots, encryption, lifecycle management |
| SSM | SsmRunCommandDocument | 2 | SSM document provisioning |
| Events | EventBridgeRule | 2 | EventBridge patterns |
| IAM | Index + barrel | 1 | Policy 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
});
}
}
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:
| Factory | Context Type | Project-Specific Fields |
|---|---|---|
| NextJS | ConsolidatedFactoryContext | domainName, hostedZoneId, crossAccountRoleArn, imageTag, sesFromEmail, verificationSecret |
| Monitoring | MonitoringFactoryContext | computeMode, trustedCidrs, grafanaPassword |
| Org | OrgFactoryContext | hostedZoneIds, trustedAccountIds, externalId |
| Shared | ProjectFactoryContext | (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
CDK Context Parsing
bin/app.ts parses -c project= and -c environment= from command line.
- 2
Factory Registry Lookup
getProjectFactoryFromContext() resolves short names (dev → development) and returns the matching factory constructor.
- 3
Typed Config Resolution
Factory calls getNextJsConfigs(env) or getMonitoringConfigs(env) for environment-specific typed configuration.
- 4
Context Override Cascading
context.domainName ?? config.domainName — CDK context overrides typed config defaults.
- 5
Stack Creation in Dependency Order
Factory creates Data → Compute → Network → App → API → Edge stacks, wiring SSM paths and resource names.
- 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:
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:
| Approach | Pros | Cons |
|---|---|---|
| CloudFormation Exports | Strong dependency, CDK auto-generates | Lock-in: can't delete exporting stack |
| SSM Parameters | Decoupled, cross-region, cross-account capable | Eventual consistency, no dependency enforcement |
| Construct role ARNs from names | Avoids token resolution entirely | Requires naming convention discipline |
- 1
Stack Writes SSM Parameter
Data stack creates ssm.StringParameter with path from centralized ssm-paths.ts module.
- 2
Factory Passes SSM Path String
Factory passes the SSM path string (not the construct reference) to the consuming stack.
- 3
Consumer Reads via AwsCustomResource
Edge stack uses AwsCustomResource with explicit region parameter for cross-region SSM reads.
- 4
ARN Construction from Names
Factory constructs IAM role ARNs from role names to avoid implicit CloudFormation exports.
- 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 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:
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.
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)
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:
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
| Pattern | Status | Impact |
|---|---|---|
| Shared L3 Construct Library | ✅ Shipped | Hardened defaults, DRY across 4 projects |
Generic IProjectFactory<TContext> | ✅ Shipped | Type-safe context per project |
| Strategy Pattern Registry | ✅ Shipped | 4 projects from single entry point |
| Typed Config Modules | ✅ Shipped | context > config > default cascading |
| CDK Aspects (3 aspects) | ✅ Shipped | Tagging, compliance, DynamoDB guard |
| Centralised SSM Parameter Paths | ✅ Shipped | Cross-stack + cross-region discovery |
| Slim Entry Point | ✅ Shipped | Factory owns everything |
| Security Group Hierarchy | ✅ Shipped | 4-level inheritance with least-privilege |
Remaining Gaps and Roadmap
| Improvement | Effort | Impact | Status |
|---|---|---|---|
Remove [key: string]: unknown from ProjectFactoryContext | 1 hour | Full type safety, no escape hatch | Planned |
| CDK Pipelines migration for self-mutating deployments | 1 day | Eliminate manual deployment scripts | Evaluating |
| Contract tests for SSM path conventions | 3 hours | Catch mismatched paths at test time | Planned |
| Cloud Development Kit for Terraform (CDKTF) experiment | 2 days | Multi-cloud construct reuse | Researching |
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
| File | Description |
|---|---|
bin/app.ts | Slim entry point — context parsing, factory delegation, aspect application |
lib/factories/project-interfaces.ts | Generic IProjectFactory<TContext>, ProjectStackFamily interfaces |
lib/factories/project-registry.ts | Strategy pattern registry with registerProjectFactory() escape hatch |
lib/projects/nextjs/factory.ts | Next.js factory — 6 stacks, ConsolidatedFactoryContext |
lib/projects/monitoring/factory.ts | Monitoring factory — 3 stacks, Grafana password cascading |
lib/projects/shared/factory.ts | Shared factory — 1 VPC stack |
lib/projects/org/factory.ts | Org factory — 1 DNS role stack, CDK context parsing |
lib/config/environments.ts | Per-account config, cdkEdgeEnvironment(), resolveEnvironment() |
lib/config/ssm-paths.ts | Centralised SSM paths with 4 namespace conventions |
lib/config/projects.ts | Project enum, ProjectConfig metadata, requiresSharedVpc flags |
lib/config/defaults.ts | Centralised constants (ports, volumes, versions) |
lib/aspects/tagging-aspect.ts | 5-key tag schema applied to all resources |
lib/aspects/enforce-readonly-dynamodb-aspect.ts | DynamoDB read-only IAM enforcement |
lib/aspects/cdk-nag-aspect.ts | CDK-Nag with 4 compliance packs + documented suppressions |
lib/common/compute/constructs/lambda-function.ts | L3 Lambda — bundling, log groups, IAM, DLQ |
lib/common/security/security-group.ts | 4-level SG hierarchy (Base → ECS → Task → Generic) |
lib/common/security/waf-rules.ts | Reusable WAF rule builder wrapping L1 CfnWebACL |
lib/common/networking/elb/application-load-balancer.ts | L3 ALB — 8+ helper methods |
lib/common/networking/cloudfront.ts | L3 CloudFront — multi-origin, cache policies |
lib/common/storage/dynamodb-table.ts | L3 DynamoDB — GSI conventions, encryption |
13. Tech Stack Summary
| Layer | Tool | Role |
|---|---|---|
| Infrastructure as Code | AWS CDK v2 (TypeScript) | Constructs, stacks, aspects, synthesis |
| L1 Constructs | wafv2.CfnWebACL | WAF rules (no L2 available as of 2026) |
| L2 Constructs | ec2, elbv2, lambda, s3 | Core AWS resources with grant methods |
| L3 Constructs | Custom library | Hardened blueprints with validated defaults |
| Design Pattern | Factory + Strategy | Project-specific stack creation from single app |
| Config Modules | TypeScript + fromEnv() | Typed, environment-specific configuration |
| Cross-Stack | SSM Parameter Store | Cross-stack + cross-region discovery |
| Governance | CDK Aspects (3 aspects) | Tagging, DynamoDB guard, compliance |
| Compliance | CDK-Nag (AwsSolutions) | Security rule validation at synthesis time |
| CI/CD | GitHub Actions | Synthesize, scan, deploy per-stack |
| Testing | Jest + CDK assertions | 26 test files, factory integration tests |