Available Hire Me
← All Writing AWS

AWS CDK in Java — Writing Infrastructure as Code with Constructs and Stacks

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.

Core concepts

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.

Project setup

<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>

A minimal stack

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();
    }
}

The App entry point

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 vs L2 vs L3 constructs

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.

Environment-specific configuration

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);

Granting permissions between constructs

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());

The synth/diff/deploy workflow

# 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

Sharing code between app and infrastructure

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.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 25+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.