@composurecdk/sns
v0.10.3
Published
Composable SNS topic builder with well-architected defaults
Maintainers
Readme
@composurecdk/sns
SNS topic and subscription builders for ComposureCDK.
This package provides fluent builders for SNS topics and subscriptions with secure, AWS-recommended defaults. They wrap the CDK Topic and Subscription constructs — refer to the CDK documentation for the full set of configurable properties.
Topic Builder
import { createTopicBuilder } from "@composurecdk/sns";
const alerts = createTopicBuilder()
.topicName("my-alerts")
.displayName("My Alert Topic")
.build(stack, "AlertTopic");Every TopicProps property is available as a fluent setter on the builder.
Secure Defaults
createTopicBuilder applies the following defaults. Each can be overridden via the builder's fluent API.
| Property | Default | Rationale |
| ------------ | ------- | ----------------------------------------------------------------------------- |
| enforceSSL | true | Denies publish/subscribe requests that do not use TLS (transport encryption). |
These defaults are guided by the AWS SNS Security Best Practices.
Encryption at rest
SNS has no service-managed encryption option — a topic is unencrypted at rest until a KMS key is supplied. .masterKey(...) accepts a concrete IKey or a Resolvable, so a key built by @composurecdk/kms can be a component of the same system:
compose(
{
topicKey: createKeyBuilder().description("Encrypts the alerts topic at rest."),
alerts: createTopicBuilder().masterKey(ref<KeyBuilderResult>("topicKey").get("key")),
},
{ topicKey: [], alerts: ["topicKey"] },
);The defaults are exported as TOPIC_DEFAULTS for visibility and testing:
import { TOPIC_DEFAULTS } from "@composurecdk/sns";Overriding defaults
const topic = createTopicBuilder().topicName("my-topic").enforceSSL(false).build(stack, "MyTopic");Topic Access Policy
A topic has one access policy. CDK creates it as an AWS::SNS::TopicPolicy the first time a statement is added — by the enforceSSL default, by allowServicePublish, or by topic.addToResourcePolicy(...) — and every later statement joins that same policy. The build result returns it as result.policy, which CDK keeps private on Topic; it is undefined when the topic had no statements at build time.
Use allowServicePublish to let an AWS service publish to the topic. The statement joins the topic's own policy, next to the enforceSSL statement:
createTopicBuilder()
.allowServicePublish("codestar-notifications.amazonaws.com", {
conditions: { StringEquals: { "aws:SourceAccount": Aws.ACCOUNT_ID } },
})
.build(stack, "Alerts");conditions is optional; use one where the service supports it, to guard against the confused deputy problem. Integrations that wire themselves — EventBridge targets, S3 notifications, and @composurecdk/budgets SNS subscribers — already add their statement to the topic's policy, so they need no allowServicePublish.
Do not create another TopicPolicy for a topic built here: each policy resource replaces the topic's policy outright, so one of them is lost. topicPolicyConflictPolicy catches this at synth.
Recommended Alarms
The builder creates AWS-recommended CloudWatch alarms by default. No alarm actions are configured — access alarms from the build result to add SNS topics or other actions.
| Alarm | Metric | Default threshold | Created when |
| --------------------------------------------------- | --------------------------------------------------------------- | ----------------- | ------------ |
| numberOfNotificationsFailed | NumberOfNotificationsFailed (Sum, 1 min) | > 0 | Always |
| numberOfNotificationsFilteredOutInvalidAttributes | NumberOfNotificationsFilteredOut-InvalidAttributes (Sum, 1 min) | > 0 | Always |
| numberOfNotificationsRedrivenToDlq | NumberOfNotificationsRedrivenToDlq (Sum, 1 min) | > 0 | Always[^dlq] |
| numberOfNotificationsFailedToRedriveToDlq | NumberOfNotificationsFailedToRedriveToDlq (Sum, 1 min) | > 0 | Always[^dlq] |
[^dlq]: Metric only emits when a subscription on the topic has a dead-letter queue attached and SNS attempts redrive. TreatMissingData defaults to notBreaching, so the alarm stays quiet on topics without DLQs. Attach a DLQ on the ITopicSubscription itself (e.g. new LambdaSubscription(fn, { deadLetterQueue: dlq })) — see SNS DLQ docs.
The defaults are exported as TOPIC_ALARM_DEFAULTS for visibility and testing:
import { TOPIC_ALARM_DEFAULTS } from "@composurecdk/sns";Customizing thresholds
Override individual alarm properties via recommendedAlarms. Unspecified fields keep their defaults.
const topic = createTopicBuilder()
.topicName("my-topic")
.recommendedAlarms({
numberOfNotificationsFailed: { threshold: 5, evaluationPeriods: 3 },
});Disabling alarms
Disable all recommended alarms:
builder.recommendedAlarms(false);
// or
builder.recommendedAlarms({ enabled: false });Disable individual alarms:
builder.recommendedAlarms({ numberOfNotificationsFailed: false });Custom alarms
Add custom alarms alongside the recommended ones via addAlarm. The callback receives an AlarmDefinitionBuilder typed to ITopic, so the metric factory has access to the topic's properties.
import { Metric } from "aws-cdk-lib/aws-cloudwatch";
const topic = createTopicBuilder()
.topicName("my-topic")
.addAlarm("highPublishRate", (alarm) =>
alarm
.metric(
(topic) =>
new Metric({
namespace: "AWS/SNS",
metricName: "NumberOfMessagesPublished",
dimensionsMap: { TopicName: topic.topicName },
statistic: "Sum",
period: Duration.minutes(1),
}),
)
.threshold(10000)
.greaterThanOrEqual()
.description("Topic receiving unusually high message volume"),
);Applying alarm actions
Alarms are returned in the build result as Record<string, Alarm>:
const result = topic.build(stack, "MyTopic");
const alertTopic = new Topic(stack, "AlertTopic");
for (const alarm of Object.values(result.alarms)) {
alarm.addAlarmAction(new SnsAction(alertTopic));
}Adding Subscriptions to a Topic
For the common case where a topic and its subscriptions are declared together, use addSubscription on the topic builder. It accepts any CDK ITopicSubscription (e.g. EmailSubscription, LambdaSubscription, SqsSubscription) and binds it via ITopicSubscription.bind(topic) — the same path CDK uses for topic.addSubscription(...), so endpoint-specific wire-up (Lambda invoke permission, SQS queue policy, KMS decrypt policy) happens automatically.
import { createTopicBuilder } from "@composurecdk/sns";
import { EmailSubscription, LambdaSubscription } from "aws-cdk-lib/aws-sns-subscriptions";
const result = createTopicBuilder()
.topicName("alerts")
.addSubscription("ops", new EmailSubscription("[email protected]"))
.addSubscription("handler", new LambdaSubscription(alertHandler))
.build(stack, "Alerts");
result.subscriptions.ops; // AWS SNS Subscription constructEach subscription is exposed on result.subscriptions under the key supplied to addSubscription.
Cross-component subscriptions can be declared with ref(...) inside a compose system, so the subscription's endpoint is resolved from another component's build output at build time:
import { compose, ref } from "@composurecdk/core";
import { createTopicBuilder } from "@composurecdk/sns";
import { createFunctionBuilder, type FunctionBuilderResult } from "@composurecdk/lambda";
import { LambdaSubscription } from "aws-cdk-lib/aws-sns-subscriptions";
const system = compose(
{
handler: createFunctionBuilder()./* ... */,
alerts: createTopicBuilder().addSubscription(
"handler",
ref("handler", (r: FunctionBuilderResult) => new LambdaSubscription(r.function)),
),
},
{ handler: [], alerts: ["handler"] },
);Subscription Builder
Use createSubscriptionBuilder when subscribing to a foreign topic — one that is not built in the same compose system (for example, a topic owned by another stack or account). When the topic and its subscriptions are declared together, prefer TopicBuilder.addSubscription instead.
import { createSubscriptionBuilder } from "@composurecdk/sns";
import { EmailSubscription } from "aws-cdk-lib/aws-sns-subscriptions";
const emailAlerts = createSubscriptionBuilder()
.topic(budgetTopic)
.subscription(new EmailSubscription("[email protected]"))
.build(stack, "BudgetEmailSubscription");The builder accepts any CDK ITopicSubscription (e.g. EmailSubscription, LambdaSubscription, SqsSubscription) and binds it via ITopicSubscription.bind(topic) — the same path CDK uses for topic.addSubscription(...), so endpoint-specific wire-up (Lambda invoke permission, SQS queue policy, KMS decrypt grant) happens automatically. Subscription-specific options — dead-letter queue, filter policy, raw message delivery — are configured on the ITopicSubscription itself, matching CDK's own API:
import { LambdaSubscription } from "aws-cdk-lib/aws-sns-subscriptions";
createSubscriptionBuilder()
.topic(orderEventsTopic)
.subscription(
new LambdaSubscription(handler, {
deadLetterQueue: dlq,
filterPolicy: { severity: SubscriptionFilter.stringFilter({ allowlist: ["HIGH"] }) },
}),
)
.build(stack, "OrderEventsHandler");Both .topic(...) and .subscription(...) accept a Ref, so the builder composes cleanly with a TopicBuilder — or with any other component that produces the endpoint resource:
import { compose, ref } from "@composurecdk/core";
import { createTopicBuilder, createSubscriptionBuilder } from "@composurecdk/sns";
import { EmailSubscription } from "aws-cdk-lib/aws-sns-subscriptions";
const system = compose(
{
budget: createTopicBuilder().topicName("budget-alerts"),
email: createSubscriptionBuilder()
.topic(ref("budget", (r) => r.topic))
.subscription(new EmailSubscription("[email protected]")),
},
{ budget: [], email: ["budget"] },
);Subscription reliability
Attaching a dead-letter queue is the primary reliability control for SNS subscriptions (AWS Well-Architected — Reliability Pillar, SNS DLQ docs). Pass a queue to the ITopicSubscription constructor (e.g. new EmailSubscription("[email protected]", { deadLetterQueue: dlq })).
Neither builder creates a DLQ for you. A dead-letter queue is a separate, billable queue that needs an owner — a DLQ nobody drains is just a slower way to lose messages — so it is declared as a sibling and referenced, which also gets it ComposureCDK's dead-letter defaults (14-day retention) and depth alarm:
const system = compose(
{
orders: createQueueBuilder(),
ordersDlq: createQueueBuilder("dlq"),
orderEvents: createTopicBuilder().addSubscription(
"orders",
combine(
{ orders: ref<QueueBuilderResult>("orders"), dlq: ref<QueueBuilderResult>("ordersDlq") },
({ orders, dlq }) => new SqsSubscription(orders.queue, { deadLetterQueue: dlq.queue }),
),
),
},
{ orders: [], ordersDlq: [], orderEvents: ["orders", "ordersDlq"] },
);CDK's Subscription construct adds the queue policy that lets sns.amazonaws.com write to the DLQ (scoped to the topic ARN), so no extra grant is needed.
The CloudWatch metrics that surface delivery failures (NumberOfNotificationsRedrivenToDlq, NumberOfNotificationsFailedToRedriveToDlq) are topic-level, so the recommended alarms for them live on the TopicBuilder (see Recommended Alarms above) and only report data once at least one subscription has a DLQ attached. Pair them with the DLQ's own depth alarm — that pair is what turns an undelivered notification into a page.
ComposureCDK-OrderProcessorStack deploys this shape end to end and its smoke test proves the delivery path against live AWS.
Subscription Defaults
Both createSubscriptionBuilder and TopicBuilder.addSubscription apply per-protocol defaults to the TopicSubscriptionConfig returned by ITopicSubscription.bind(topic). Defaults are gap-filling: anything the ITopicSubscription itself configured (via its constructor options) wins; defaults only apply where the bound config left a field unset.
| Protocol | Default | Rationale |
| ---------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SQS | rawMessageDelivery: true | Removes the SNS envelope so downstream SQS consumers see the publisher's payload as-is — fewer bytes, no parse step. The typical choice for SNS → SQS fan-out. |
| FIREHOSE | rawMessageDelivery: true | Stores records as the publisher sent them rather than wrapped in an SNS envelope. |
| HTTP | (no default applied) | Emits a synth-time warning instead — plain HTTP delivery means messages and signed-confirmation tokens travel unencrypted. Prefer SubscriptionProtocol.HTTPS. (SNS security best practices) |
LAMBDA is intentionally absent — SNS does not support raw delivery to Lambda subscriptions; the handler always receives the SNS envelope. Other protocols (HTTPS, EMAIL, EMAIL_JSON, SMS, APPLICATION) receive no overrides.
These defaults are guided by SNS raw message delivery and the AWS SNS security best practices.
The map is exported as SUBSCRIPTION_DEFAULTS for visibility and testing:
import { SUBSCRIPTION_DEFAULTS } from "@composurecdk/sns";Overriding a default
Any default is individually overridable through the ITopicSubscription's own constructor options:
import { SqsSubscription } from "aws-cdk-lib/aws-sns-subscriptions";
createSubscriptionBuilder()
.topic(orders)
.subscription(new SqsSubscription(queue, { rawMessageDelivery: false }))
.build(stack, "OrdersToQueue");Topic Policy Conflict Policy
An SNS topic has exactly one access policy. Every AWS::SNS::TopicPolicy and AWS::SNS::TopicInlinePolicy replaces it outright, so when two target the same topic, whichever CloudFormation applies last wins and the other's statements disappear. CloudFormation gives no error or warning, and the order can change between deployments.
topicPolicyConflictPolicy catches this at synth. It installs a CDK Aspect over a scope and fails synth when a second policy resource targets a topic that already has one:
import { topicPolicyConflictPolicy } from "@composurecdk/sns";
topicPolicyConflictPolicy(app); // or { onViolation: "warn" } to annotate insteadThe usual cause is a new TopicPolicy(...) for a topic built in CDK, alongside the policy CDK creates for the topic itself (for example, for the enforceSSL default). Add to the topic's own policy with topic.addToResourcePolicy(...) instead.
Two policy resources that share one PolicyDocument object always render the same document, so they are not reported. The check matches topics by the resolved Topics/TopicArn value: a literal ARN matches across stacks, and a Ref matches within its stack.
Examples
- OrderProcessorStack — SNS → SQS fan-out with a dead-letter queue on the subscription
- DualFunctionStack — Two Lambda functions with TopicBuilder for alarm actions
- StaticWebsiteStack — Static website with TopicBuilder for alarm actions
