The situation
A platform team has three infrastructure extension problems open at once.
- A Datadog integration: every deployed Lambda should be registered with a Datadog monitor that alerts on p99 latency. Datadog is not an AWS resource; CloudFormation doesn’t know how to create Datadog monitors; the team needs the create-monitor-on-stack-create and delete-monitor-on-stack-delete lifecycle to be tied to the Lambda’s stack.
- A standard service pattern: the team deploys roughly forty Lambda-plus-API-Gateway-plus-DynamoDB services per year. Every one has the same logging, X-Ray, IAM baseline, tag policy, and dashboard. Today each service’s CDK code is 300 lines; most of those lines are identical between services.
- A GitHub Actions workflow: the team manages GitHub Actions workflows declaratively alongside infrastructure. Workflows live in
.github/workflows/*.ymlin the deployed service’s repo; the team wants the infrastructure pipeline to commit the workflow file during deploy.
Two of these call for CloudFormation custom resources. One of them calls for a CDK construct. Getting them confused loses the team weeks.
What actually matters
Before reaching for either, it’s worth naming what each one is.
CloudFormation custom resources extend CloudFormation itself. When CloudFormation encounters a resource of type Custom::<Name> (or AWS::CloudFormation::CustomResource) in a template, it invokes a Lambda function (or publishes to an SNS topic) with the intended operation – Create, Update, or Delete, and a set of input properties. The Lambda does whatever it does (call a third-party API, compute a value, wait for a condition) and signals success or failure back to CloudFormation. The rest of the stack waits. The mechanism’s value is that it gives CloudFormation the ability to manage things CloudFormation itself doesn’t natively understand.
CDK constructs extend CDK, not CloudFormation. A construct is a reusable unit of infrastructure definition written in a programming language (TypeScript, Python, Java, C#, Go). Constructs compose other constructs; the CDK generates a CloudFormation template from them when you run cdk synth. The mechanism’s value is that it gives developers composition, “our standard service” becomes an L3 construct, imported as a package, instantiated with three lines.
The difference: custom resources extend CloudFormation’s runtime capabilities. Constructs extend CDK’s authoring capabilities. They solve different problems; they can coexist in the same stack.
Two related mechanisms worth knowing and separating:
- AWS CloudFormation modules, a way to package reusable templates as CloudFormation registry entries, usable from any template. A convergence with CDK’s construct idea, from the CloudFormation side.
- CloudFormation resource providers (third-party resource types), a way to extend CloudFormation’s native resource catalogue with a proper
Type::Namespace::Resourcethat behaves like an AWS resource. Third parties publish these (e.g. Datadog hasDatadog::Monitors::Monitor). More structured than custom resources; higher bar to build.
Side by side
| Mechanism | Extends | Written in | Deploys as | Good for |
|---|---|---|---|---|
| Custom resource (Lambda-backed) | CloudFormation runtime | Any Lambda language | A resource in the template | One-off or bespoke lifecycle hooks |
| CDK construct (L2/L3) | CDK authoring | TS / Py / Java / C# / Go | Generated CloudFormation | Reusable application shapes |
| CDK construct wrapping a custom resource | Both | Same | Same | A construct whose behaviour needs a custom resource |
| CloudFormation module | CloudFormation authoring | Registered template | A new CloudFormation type | Template-first teams wanting reuse |
| Third-party resource provider | CloudFormation catalogue | Any language (CloudFormation CLI SDK) | Registered type | Polished, reusable, cross-team resources |
Reading the table by problem rather than by mechanism:
- Datadog integration, extends CloudFormation’s runtime (it needs to call Datadog’s API during stack events). Custom resource, Lambda-backed. Optionally: use Datadog’s published third-party resource provider to get a cleaner
Datadog::Monitors::Monitortype. Both work; the second is polished. - Standard service pattern, extends CDK’s authoring. An L3 construct is exactly the shape of this problem:
new PaymentsStandardService(this, 'Receipts', {...})expands into the Lambda, API, table, IAM, dashboards, and alarms. No runtime hook needed; the template that comes out is plain CloudFormation. - GitHub Actions workflow, extends CloudFormation’s runtime. A custom resource backed by a Lambda that calls GitHub’s API to commit a file. A CDK construct could wrap the custom resource so teams don’t think about the Lambda, but the custom resource is the thing doing the work.
The two mechanisms in depth
Custom resource anatomy. A resource in a template:
DatadogMonitor:
Type: Custom::DatadogMonitor
Properties:
ServiceToken: !GetAtt MonitorProviderFunction.Arn
Name: !Sub "${AWS::StackName}-p99-latency"
Query: "avg:aws.lambda.duration.p99{functionname:receipts}"
Threshold: 2000
ApiKeySecretArn: !Ref DatadogApiKeySecret
The ServiceToken points at the Lambda function (or SNS topic). Everything else is input for the Lambda.
The Lambda’s event payload looks like:
{
"RequestType": "Create",
"ResponseURL": "https://...pre-signed S3 URL...",
"StackId": "...",
"RequestId": "...",
"LogicalResourceId": "DatadogMonitor",
"ResourceType": "Custom::DatadogMonitor",
"ResourceProperties": {
"Name": "...",
"Query": "...",
"Threshold": 2000,
"ApiKeySecretArn": "..."
}
}
The Lambda creates the Datadog monitor, then PUTs a response to the pre-signed URL:
{
"Status": "SUCCESS",
"PhysicalResourceId": "datadog-monitor-abc123",
"StackId": "...",
"RequestId": "...",
"LogicalResourceId": "DatadogMonitor",
"Data": { "MonitorId": "abc123" }
}
CloudFormation waits for this response (up to the timeout) before proceeding. Update events include the old properties; Delete events run when the stack is deleted or when the resource is replaced. The Lambda has to be idempotent – Create might be retried, Update might run on every stack update, Delete must succeed even if the underlying thing was already gone.
AWS provides helper libraries: the aws-cloudformation-response library for raw Lambda-backed custom resources, and cfn-lambda / custom-resource-helper for Python, all of which handle the signing, PUT, and error paths.
CDK custom resources specifically are a wrapper. cdk-custom-resources (or the CDK Provider framework) packages the Lambda, the SNS topic or direct invocation, and the response-signalling into a single construct. You write a Lambda handler that returns a result; the CDK Provider handles the CloudFormation signalling. Cleaner than hand-rolling.
CDK construct anatomy. An L3 construct:
export interface PaymentsStandardServiceProps {
codePath: string;
routes: Array<{ path: string, method: string }>;
tableSchema: dynamodb.TableProps;
ownership: { team: string, service: string };
}
export class PaymentsStandardService extends Construct {
public readonly function: lambda.Function;
public readonly table: dynamodb.Table;
public readonly api: apigw.RestApi;
constructor(scope: Construct, id: string, props: PaymentsStandardServiceProps) {
super(scope, id);
this.table = new dynamodb.Table(this, 'Table', props.tableSchema);
this.function = new lambda.Function(this, 'Handler', {
code: lambda.Code.fromAsset(props.codePath),
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
tracing: lambda.Tracing.ACTIVE,
logRetention: logs.RetentionDays.ONE_MONTH,
});
this.table.grantReadWriteData(this.function);
// tags, alarms, dashboards, X-Ray trace role, etc.
this.api = new apigw.RestApi(this, 'Api', { /* ... */ });
for (const r of props.routes) {
this.api.root.resourceForPath(r.path).addMethod(r.method,
new apigw.LambdaIntegration(this.function));
}
}
}
Services consume it:
new PaymentsStandardService(this, 'Receipts', {
codePath: './src',
routes: [{ path: '/receipts/{id}', method: 'GET' }],
tableSchema: { partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING }},
ownership: { team: 'payments', service: 'receipts' },
});
The generated CloudFormation template contains all the underlying resources; the template is plain and deployable. No custom resource, no Lambda-during-deploy, no runtime hook.
Choosing between them, visualised
The picks in depth
Datadog monitor → custom resource, ideally wrapped in a construct. A Lambda-backed custom resource with three handlers: create posts to POST /api/v1/monitor, update posts to PUT /api/v1/monitor/:id, delete posts to DELETE /api/v1/monitor/:id. The Lambda reads the Datadog API key from a Secrets Manager secret given to it by reference. The custom resource’s PhysicalResourceId is the Datadog monitor ID, so when CloudFormation deletes the stack it can call the right delete path.
The CDK wrapping makes this a single-line import:
new DatadogMonitor(this, 'Latency', {
name: 'p99-latency',
query: `avg:aws.lambda.duration.p99{functionname:${fn.functionName}}`,
threshold: 2000,
});
Inside the construct: a cdk.custom_resources.Provider that packages the Lambda; the Lambda shared across monitors (the custom resource’s backing Lambda is a singleton per stack, invoked many times). Alternatively: Datadog’s third-party resource provider, installed once in the account, turns this into a native Datadog::Monitors::Monitor resource, even cleaner, no Lambda to manage.
Standard service pattern → CDK L3 construct. No custom resource needed; no deploy-time hook. The construct encapsulates CloudFormation-native resources and outputs a plain template. Publish to the team’s internal npm registry as @acme/cdk-patterns; every new service imports and instantiates. When the platform standard changes (next quarter: every function gets a SnapStart flag), update the construct, bump the version, every service picks it up on its next deploy.
GitHub workflow → custom resource. The workflow file is not an AWS resource; it lives in a GitHub repository. A custom resource with create/update/delete handlers calls GitHub’s Contents API to write .github/workflows/deploy.yml in the service’s repo on stack create and delete it on stack delete. The physical ID is the path in the repo; the delete handler tolerates a 404 (file already gone) because idempotency matters.
When to reach for a third-party resource provider instead
Writing a custom resource is fine for one-off things. Writing the same custom resource twice for the same external system signals it’s time for a resource provider. A resource provider registers a proper CloudFormation resource type (MyOrg::Datadog::Monitor) that behaves like any AWS resource: handled by the CloudFormation registry, cached types, real schema, proper drift detection. The bar to build is higher (implement the CloudFormation CLI contract), but for platforms serving many teams, it’s worth it.
A worked combination
A service that wants the standard pattern and a Datadog monitor:
export class Receipts extends Stack {
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
const svc = new PaymentsStandardService(this, 'Service', {
codePath: './src',
routes: [{ path: '/receipts/{id}', method: 'GET' }],
tableSchema: { partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING }},
ownership: { team: 'payments', service: 'receipts' },
});
new DatadogMonitor(this, 'Latency', {
name: `${id}-p99-latency`,
query: `avg:aws.lambda.duration.p99{functionname:${svc.function.functionName}}`,
threshold: 2000,
});
}
}
Two constructs, one stack. The standard-service construct produces plain CloudFormation; the Datadog-monitor construct produces a Lambda-backed custom resource. Both deploy as one cdk deploy.
What’s worth remembering
- Custom resources extend CloudFormation’s runtime. A Lambda that runs during
Create/Update/Delete, signalling back with success and a physical ID. - Constructs extend CDK’s authoring. Composable infrastructure pieces in a real programming language; compiled to CloudFormation.
- They are not alternatives. They solve different problems. A construct can contain a custom resource inside it.
- Custom resources must be idempotent.
Createcan be retried;Deletemust tolerate “already gone”;Updatesees old and new property bags. - The
PhysicalResourceIdis how CloudFormation tracks the underlying thing. Change it duringUpdateand CloudFormation treats it as a replacement (old physical gets aDelete). - The CDK
Providerframework is the clean way to package custom resources. It handles the signalling, the Lambda, and the lifecycle. Avoid hand-rolling in CDK. - Third-party resource providers are the polished alternative. Register a type with the CloudFormation registry; get a real resource that behaves like AWS’s native ones. Higher build cost; worth it for cross-team use.
- CloudFormation modules are the module-system answer from the template side. Convergence with CDK constructs; a reusable template-plus-schema published to the registry.
- L1, L2, L3 is the CDK construct hierarchy. L1 mirrors CloudFormation; L2 adds defaults; L3 composes application shapes. Most platform-library constructs are L3.
- Match the tool to the job. Need a hook during deploy: custom resource. Need a reusable authoring shape: construct. Need both: a construct that wraps a custom resource.
Custom resources when CloudFormation needs to run logic during stack events; constructs when the team needs to reuse an authoring pattern. Two mechanisms, two extensions, cleanly separable. The work isn’t picking one and fitting everything to it, it’s reading the shape of each extension problem and using the mechanism that fits.