How to define, deploy, and manage AWS infrastructure using the CDK for Java — stacks, constructs, environment separation, and the patterns that make CDK codebases maintainable at scale.
CloudFormation YAML is infrastructure as configuration. CDK is infrastructure as code — and there’s a meaningful difference. When I was provisioning the AWS ingestion pipeline at Mosaic Smart Data, the infrastructure had conditional logic, shared constructs reused across environments, and typed relationships between components that would have been unmaintainable in raw YAML. CDK for Java let us express all of that naturally, using the same language and tooling as the rest of the platform.
This post covers how to structure a CDK project in Java, the constructs and patterns that keep it maintainable, and the deployment workflow that integrates cleanly with CI/CD.
CDK for Java is a standard Maven or Gradle project. Bootstrap with:
cdk init app --language java
This generates a Maven project with two entry points: App.java (the CDK application) and a stack class. The cdk.json file tells the toolkit how to build and run your app.
public class InfraApp {
public static void main(final String[] args) {
App app = new App();
Environment prodEnv = Environment.builder()
.account("123456789012")
.region("eu-west-1")
.build();
new ClaimsIngestionStack(app, "ClaimsIngestionProd", StackProps.builder()
.env(prodEnv)
.build());
app.synth();
}
}
CDK constructs come at three levels of abstraction:
L1 — CloudFormation resources (prefix Cfn*): a direct mapping to CloudFormation. Every property maps to a CloudFormation property. Maximum control, maximum verbosity.
L2 — Curated constructs: the primary CDK layer. Sensible defaults, convenience methods, and typed relationships between resources. This is what you’ll use for almost everything.
L3 — Patterns: higher-level constructs that provision multiple related resources in one call (e.g. ApplicationLoadBalancedFargateService provisions ALB, Fargate service, task definition, and security groups together).
Use L2 as your default. Drop to L1 when you need a property L2 doesn’t expose. Use L3 patterns when they match your architecture exactly — otherwise they can fight you when you need fine-grained control.
public class ClaimsIngestionStack extends Stack {
public ClaimsIngestionStack(final Construct scope,
final String id,
final StackProps props) {
super(scope, id, props);
// SNS topic for incoming claim events
Topic claimEventsTopic = Topic.Builder.create(this, "ClaimEventsTopic")
.topicName("claim-events")
.build();
// Dead-letter queue for failed processing
Queue dlq = Queue.Builder.create(this, "ClaimsDlq")
.queueName("claims-processor-dlq")
.retentionPeriod(Duration.days(14))
.build();
// Main processing queue
Queue processingQueue = Queue.Builder.create(this, "ClaimsProcessingQueue")
.queueName("claims-processor")
.visibilityTimeout(Duration.seconds(300))
.deadLetterQueue(DeadLetterQueue.builder()
.queue(dlq)
.maxReceiveCount(3)
.build())
.build();
// Subscribe queue to topic
claimEventsTopic.addSubscription(
new SqsSubscription(processingQueue));
// Lambda function
Function processorLambda = Function.Builder.create(this, "ClaimsProcessor")
.functionName("claims-processor")
.runtime(Runtime.JAVA_21)
.handler("com.trinitylogic.claims.Handler::handleRequest")
.code(Code.fromAsset("../lambda/target/claims-processor.jar"))
.memorySize(1024)
.timeout(Duration.seconds(30))
.environment(Map.of(
"ENVIRONMENT", "prod",
"MONGODB_URI", ""
))
.build();
// Wire SQS → Lambda
processorLambda.addEventSource(
new SqsEventSource(processingQueue, SqsEventSourceProps.builder()
.batchSize(10)
.reportBatchItemFailures(true)
.build()));
}
}
The typed relationships here are the key advantage over YAML. addSubscription, addEventSource, deadLetterQueue — these methods wire resources together and automatically generate the correct IAM permissions. CDK knows that connecting a Lambda to an SQS queue requires sqs:ReceiveMessage, sqs:DeleteMessage, and sqs:GetQueueAttributes on the queue, and adds them without you needing to write them.
The real power of CDK comes from building your own constructs — reusable components that encode your organisation’s standards. Every Lambda in your platform should have structured logging, X-Ray tracing, and a DLQ. Encoding that in a construct means you don’t repeat it:
public class StandardLambda extends Construct {
private final Function function;
public StandardLambda(final Construct scope,
final String id,
final StandardLambdaProps props) {
super(scope, id);
Queue dlq = Queue.Builder.create(this, "Dlq")
.retentionPeriod(Duration.days(14))
.build();
this.function = Function.Builder.create(this, "Function")
.functionName(props.getFunctionName())
.runtime(Runtime.JAVA_21)
.handler(props.getHandler())
.code(props.getCode())
.memorySize(props.getMemorySize() != null ? props.getMemorySize() : 512)
.timeout(props.getTimeout() != null ? props.getTimeout() : Duration.seconds(30))
.tracing(Tracing.ACTIVE) // X-Ray always on
.logRetention(RetentionDays.ONE_MONTH) // structured log retention
.deadLetterQueue(dlq) // DLQ always wired
.environment(props.getEnvironment() != null
? props.getEnvironment() : Map.of())
.build();
}
public Function getFunction() { return function; }
}
Consuming a StandardLambda from a stack takes one call and gives you a Lambda with all platform standards baked in. When you later add mandatory tagging or update the default memory size, one change to StandardLambda propagates across every Lambda in every stack.
The cleanest pattern for environment separation in CDK is configuration objects injected at synth time, not environment variables read at runtime:
public record EnvironmentConfig(
String accountId,
String region,
String mongodbSecretArn,
int lambdaMemoryMb,
boolean enableTracing
) {
public static EnvironmentConfig prod() {
return new EnvironmentConfig(
"123456789012", "eu-west-1",
"arn:aws:secretsmanager:eu-west-1:123456789012:secret:prod/mongodb",
1024, true);
}
public static EnvironmentConfig staging() {
return new EnvironmentConfig(
"987654321098", "eu-west-1",
"arn:aws:secretsmanager:eu-west-1:987654321098:secret:staging/mongodb",
512, false);
}
}
public class InfraApp {
public static void main(final String[] args) {
App app = new App();
String env = (String) app.getNode().tryGetContext("env");
EnvironmentConfig config = "prod".equals(env)
? EnvironmentConfig.prod()
: EnvironmentConfig.staging();
new ClaimsIngestionStack(app, "ClaimsIngestion-" + env,
StackProps.builder()
.env(Environment.builder()
.account(config.accountId())
.region(config.region())
.build())
.build(),
config);
app.synth();
}
}
Deploy to staging with cdk deploy -c env=staging, prod with cdk deploy -c env=prod. The configuration is type-safe, discoverable, and version-controlled alongside the infrastructure code.
cdk synth # synthesise CloudFormation templates (good for review)
cdk diff # show what will change before deploying
cdk deploy # deploy to AWS
cdk deploy --hotswap # fast deploy for Lambda code changes only (dev only)
cdk destroy # tear down the stack
cdk diff before every deploy in a CI pipeline is non-negotiable. It surfaces unintended changes — a dependency update that silently modifies a security group rule, or a construct upgrade that changes a resource name and triggers a replacement. Review the diff, understand it, then deploy.
CDK requires a one-time bootstrap of each account/region combination:
cdk bootstrap aws://123456789012/eu-west-1
This creates an S3 bucket and ECR repository CDK uses for assets, and an IAM role for deployments. In a CI/CD pipeline, grant the pipeline role sts:AssumeRole on the CDK deploy role rather than giving it broad IAM permissions directly.
A GitHub Actions deploy job:
- name: CDK Diff
run: cdk diff -c env=$
- name: CDK Deploy
run: cdk deploy --require-approval never -c env=$
env:
AWS_ROLE_ARN: $
--require-approval never suppresses interactive prompts in CI. Use it only in pipelines where the diff step has already been reviewed — never locally where you might skip reviewing the diff.
I reach for CDK when:
I use raw CloudFormation (or the CDK CfnInclude escape hatch) when:
CDK’s value scales with complexity. For a single Lambda with an S3 trigger, YAML is fine. For a multi-service platform with shared constructs, environment parity requirements, and typed cross-stack references, CDK pays for itself quickly.
If you’re building AWS infrastructure for event-driven Java systems and want an engineer who’s done this at production scale, get in touch.