AWS CDK in TypeScript is the best way I have found to define serverless infrastructure, and the easiest way I have found for eight engineers to create eight different conventions. The CDK gives you a real programming language, so every engineer brings their own idea of how to structure it. After a few years of leading teams that own CDK codebases, these are the patterns I now set on day one.
One construct per repeated shape
The first thing to notice in any CDK codebase is repetition: a queue, its dead-letter queue, the Lambda that consumes it, the alarm on the DLQ, the log retention, the IAM grants. Written inline, that is forty lines, and the fifth copy will be missing the alarm.
Turn each repeated shape into a construct with a small interface:
export interface QueueWorkerProps {
handler: string;
entry: string;
visibilityTimeout?: Duration;
alarmTopic: ITopic;
}
export class QueueWorker extends Construct {
readonly queue: Queue;
readonly fn: NodejsFunction;
constructor(scope: Construct, id: string, props: QueueWorkerProps) {
super(scope, id);
const dlq = new Queue(this, "Dlq", { retentionPeriod: Duration.days(14) });
this.queue = new Queue(this, "Queue", {
visibilityTimeout: props.visibilityTimeout ?? Duration.seconds(60),
deadLetterQueue: { queue: dlq, maxReceiveCount: 3 },
});
this.fn = new NodejsFunction(this, "Fn", {
entry: props.entry,
handler: props.handler,
logRetention: RetentionDays.ONE_MONTH,
});
this.fn.addEventSource(new SqsEventSource(this.queue));
new Alarm(this, "DlqAlarm", {
metric: dlq.metricApproximateNumberOfMessagesVisible(),
threshold: 1,
evaluationPeriods: 1,
}).addAlarmAction(new SnsAction(props.alarmTopic));
}
}
Now the fifth copy cannot miss the alarm. The construct is where the team's operational standards live, and a code review of a new worker is a review of ten lines instead of forty.
Let the CDK name things
Hard-coded resource names feel tidy and cause two real problems. They collide when you deploy a second copy of the stack, and they block CloudFormation from replacing a resource when a change requires it, which turns a small change into a failed deploy.
Omit functionName, tableName, queueName unless something outside the stack needs the name. When something does, export it through an SSM parameter or a stack output rather than by agreeing on a string in two places.
Stages, not copied stacks
Development, staging and production are the same stacks with different parameters, not three directories. A CDK Stage holds the set of stacks, and the app instantiates it once per environment:
const app = new App();
new PlatformStage(app, "Dev", { env: dev, config: configs.dev });
new PlatformStage(app, "Prod", { env: prod, config: configs.prod });
Everything that varies by environment lives in one typed config object. If a value is not in that object, it is the same everywhere, and that is a feature.
cdk diff in every pull request
The most useful line in our pipeline is the one that runs cdk diff against the target environment and posts the output on the pull request. Reviewers see the CloudFormation changes, not just the TypeScript. A one-character change that replaces a DynamoDB table shows up as a replacement before it is merged, not after.
Assertions tests for the things that must never regress
Unit tests on infrastructure sound excessive until the first time someone removes a removal policy from a production table. The CDK assertions module makes those tests cheap:
const template = Template.fromStack(stack);
template.hasResource("AWS::DynamoDB::Table", {
DeletionPolicy: "Retain",
});
template.resourceCountIs("AWS::SQS::Queue", 2); // worker queue plus its DLQ
We do not test everything. We test retention policies, encryption settings, DLQ presence, and that no Lambda has an unlimited log retention. Those tests take seconds and have caught real mistakes.
Keep cross-stack references few and deliberate
CDK makes it one line to reference a resource from another stack, and CloudFormation turns that into an export that locks the two stacks together. Deleting or changing the exporting side then fails until the importing side is updated, and in the wrong order.
The rule that works: resources that change together live in the same stack. Cross-stack references are for a small number of stable, shared things, such as a VPC or an alarm topic. For everything else, pass values through SSM parameters, which decouple the deployment order.
Pipelines deploy, people do not
The last pattern is organisational. Nobody runs cdk deploy to production from a laptop. A pipeline does, from a merged commit, with an OIDC role rather than long-lived keys. Development environments can be deployed from laptops, with per-engineer stage names so two people do not fight over one stack.
Why this matters for a team
Each pattern on its own is small. Together they are what lets a team of six to eight engineers, some of them new to AWS, change production infrastructure every week without a dedicated platform team. The constructs encode the standards, the diff shows the blast radius, the tests hold the floor, and the pipeline removes the human from the deploy. That is the whole point of putting infrastructure in a real language: you get to treat it like the rest of the code.
Top comments (0)