How to write AWS infrastructure as Java code using CDK — constructs, stacks, the three levels of abstraction, environment-specific configuration, and the CDK synth/deploy workflow.
AWS CDK (Cloud Development Kit) lets you define cloud infrastructure using real programming languages. In Java, that means type-safe infrastructure code with IDE autocompletion, refactoring support, and the ability to share configuration between your application and infrastructure code. The alternative — CloudFormation YAML — provides none of these.
App: The top-level CDK construct. One App contains one or more stacks.
Stack: Maps to a CloudFormation stack. A deployable unit of infrastructure. One Stack per environment (dev, staging, prod) is a common pattern.
Construct: The building block. L1 constructs map directly to CloudFormation resources. L2 constructs are higher-level abstractions with sensible defaults. L3 constructs (Patterns) are opinionated multi-resource groupings.
<dependency>
<groupId>software.amazon.awscdk</groupId>
<artifactId>aws-cdk-lib</artifactId>
<version>2.130.0</version>
</dependency>
<dependency>
<groupId>software.constructs</groupId>
<artifactId>constructs</artifactId>
<version>10.3.0</version>
</dependency>
public class TradingServiceStack extends Stack {
public TradingServiceStack(Construct scope, String id, StackProps props) {
super(scope, id, props);
// VPC
Vpc vpc = Vpc.Builder.create(this, "TradingVpc")
.maxAzs(2)
.natGateways(1)
.build();
// ECS Cluster
Cluster cluster = Cluster.Builder.create(this, "TradingCluster")
.vpc(vpc)
.containerInsights(true)
.build();
// Fargate task definition
FargateTaskDefinition taskDef = FargateTaskDefinition.Builder.create(this, "TaskDef")
.cpu(512)
.memoryLimitMiB(1024)
.build();
taskDef.addContainer("TradingContainer",
ContainerDefinitionOptions.builder()
.image(ContainerImage.fromEcrRepository(
Repository.fromRepositoryName(this, "Repo", "trading-service")))
.portMappings(List.of(PortMapping.builder().containerPort(8080).build()))
.logging(LogDrivers.awsLogs(AwsLogDriverProps.builder()
.logRetention(RetentionDays.ONE_WEEK)
.build()))
.build());
// Fargate service with load balancer
ApplicationLoadBalancedFargateService.Builder.create(this, "TradingService")
.cluster(cluster)
.taskDefinition(taskDef)
.desiredCount(2)
.publicLoadBalancer(true)
.build();
}
}
public class TradingInfraApp {
public static void main(String[] args) {
App app = new App();
Environment prodEnv = Environment.builder()
.account("123456789012")
.region("eu-west-2")
.build();
new TradingServiceStack(app, "TradingServiceProd",
StackProps.builder()
.env(prodEnv)
.stackName("trading-service-prod")
.build());
app.synth();
}
}
L1 (Cfn* prefix): Direct CloudFormation resource wrappers. Full control, no defaults.
// L1 — explicit CloudFormation resource
CfnBucket.Builder.create(this, "Bucket")
.bucketName("my-trading-bucket")
.versioningConfiguration(CfnBucket.VersioningConfigurationProperty.builder()
.status("Enabled").build())
.build();
L2 (most CDK constructs): Sensible defaults, type-safe methods, wiring between related resources.
// L2 — defaults to private access, encryption enabled
Bucket.Builder.create(this, "Bucket")
.bucketName("my-trading-bucket")
.versioned(true)
.removalPolicy(RemovalPolicy.DESTROY)
.autoDeleteObjects(true)
.build();
L3 (Patterns): Multi-resource combinations — ApplicationLoadBalancedFargateService creates the service, load balancer, target groups, and security groups in one call.
Avoid hardcoding values in constructs. Pass configuration via props:
public record TradingStackConfig(
int desiredCount,
int cpu,
int memory,
String imageTag,
boolean enableDeletion
) {}
public class TradingServiceStack extends Stack {
public TradingServiceStack(Construct scope, String id,
StackProps props, TradingStackConfig config) {
super(scope, id, props);
// Use config.desiredCount(), config.cpu(), etc.
}
}
// In App
TradingStackConfig devConfig = new TradingStackConfig(1, 256, 512, "latest", true);
TradingStackConfig prodConfig = new TradingStackConfig(3, 1024, 2048, "1.5.2", false);
CDK’s grant methods wire IAM permissions without writing policy JSON:
Secret dbPassword = Secret.Builder.create(this, "DbPassword")
.secretName("/trading/db-password")
.build();
// Grant task role permission to read the secret
dbPassword.grantRead(taskDef.getTaskRole());
// Grant S3 read/write
tradingBucket.grantReadWrite(taskDef.getTaskRole());
// Grant ECR pull permission (for the execution role)
ecrRepo.grantPull(taskDef.getExecutionRole());
# Synthesise — produces CloudFormation templates in cdk.out/
cdk synth
# Diff — shows what will change
cdk diff TradingServiceProd
# Deploy
cdk deploy TradingServiceProd
# Deploy with auto-approval (CI/CD)
cdk deploy TradingServiceProd --require-approval never
Because infrastructure is Java, your application and infrastructure can share domain types:
// Shared library
public record ServiceConfig(String dbUrl, int port, List<String> allowedOrigins) {}
// Infrastructure reads the same config class to parameterise resources
ServiceConfig config = loadConfig();
taskDef.addEnvironment("SERVER_PORT", String.valueOf(config.port()));
Environment variables, secret names, and topic ARNs are defined once in shared code — no duplication between application configuration and infrastructure templates.
If you’re setting up AWS infrastructure for a Java service and want help with the CDK architecture, get in touch.