AWS Lambda's Java 21 runtime provides the latest LTS version of Java for serverless applications, offering improved performance, new language features, and enhanced developer productivity. This runtime leverages Amazon Corretto 21, a no-cost, multi-platform, production-ready distribution of OpenJDK.
What's New in Java 21 for Lambda?
Java 21 introduces powerful features for serverless development:
- Virtual Threads (Project Loom) for massive concurrency
- Record Patterns and Pattern Matching for cleaner code
- Sequenced Collections for better collection APIs
- String Templates (preview) for simplified string composition
- Structured Concurrency for easier concurrent programming
Lambda Java 21 Architecture
[Lambda Function] → [Java 21 Runtime] → [AWS Services] → [External Systems] | | | | Business logic Corretto 21 S3, DynamoDB, APIs, Databases execution SQS, EventBridge Message queues
Hands-On Tutorial: Building Modern Lambda Functions with Java 21
Let's build a complete serverless application using Java 21 features and modern AWS services.
Step 1: Project Setup and Dependencies
Maven Dependencies (pom.xml):
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>lambda-java21</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- AWS Lambda -->
<aws.lambda.java.core.version>1.2.3</aws.lambda.java.core.version>
<aws.java.sdk.version>2.21.0</aws.java.sdk.version>
<!-- Logging -->
<slf4j.version>2.0.9</slf4j.version>
<!-- JSON Processing -->
<jackson.version>2.16.1</jackson.version>
<!-- Testing -->
<junit.version>5.10.1</junit.version>
<testcontainers.version>1.19.3</testcontainers.version>
</properties>
<dependencies>
<!-- AWS Lambda Java Core -->
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-core</artifactId>
<version>${aws.lambda.java.core.version}</version>
</dependency>
<!-- AWS Lambda Java Events -->
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-events</artifactId>
<version>3.11.3</version>
</dependency>
<!-- AWS SDK v2 -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>${aws.java.sdk.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>dynamodb</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sqs</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>secretsmanager</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>lambda</artifactId>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- Logging -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>${slf4j.version}</version>
</dependency>
<!-- Utilities -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.13.0</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.13.0</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>localstack</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.8.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<version>4.2.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
<compilerArgs>
<arg>--enable-preview</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.4.1</version>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Step 2: Base Lambda Handler with Java 21 Features
src/main/java/com/example/lambda/core/BaseLambdaHandler.java:
package com.example.lambda.core;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.amazonaws.services.lambda.runtime.LambdaLogger;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.StructuredTaskScope;
/**
* Base Lambda handler with Java 21 features and common functionality
*/
@Slf4j
public abstract class BaseLambdaHandler<I, O> implements RequestHandler<I, O> {
protected final ObjectMapper objectMapper;
protected BaseLambdaHandler() {
this.objectMapper = new ObjectMapper();
this.objectMapper.findAndRegisterModules();
}
@Override
public O handleRequest(I input, Context context) {
log.info("Lambda invocation started - Function: {}, Version: {}, Memory: {}MB",
context.getFunctionName(),
context.getFunctionVersion(),
context.getMemoryLimitInMB());
try {
// Log input (be careful with sensitive data)
logInput(input);
// Process the request
O result = processRequest(input, context);
log.info("Lambda invocation completed successfully");
return result;
} catch (Exception e) {
log.error("Lambda invocation failed", e);
throw new LambdaExecutionException("Request processing failed", e);
}
}
/**
* Main processing method to be implemented by subclasses
*/
protected abstract O processRequest(I input, Context context);
/**
* Process request asynchronously using virtual threads
*/
protected CompletableFuture<O> processAsync(I input, Context context) {
return CompletableFuture.supplyAsync(() -> processRequest(input, context),
Executors.newVirtualThreadPerTaskExecutor());
}
/**
* Execute multiple tasks concurrently using Structured Concurrency
*/
protected <T, U> ConcurrentResult<T, U> executeConcurrently(Task<T> task1, Task<U> task2) {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var fork1 = scope.fork(task1::execute);
var fork2 = scope.fork(task2::execute);
scope.join(); // Join both forks
scope.throwIfFailed(); // Propagate exception if any failed
return new ConcurrentResult<>(
fork1.get(),
fork2.get()
);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LambdaExecutionException("Concurrent execution interrupted", e);
}
}
/**
* Log input safely (mask sensitive fields)
*/
protected void logInput(I input) {
if (log.isDebugEnabled()) {
try {
String inputJson = objectMapper.writeValueAsString(input);
String maskedInput = maskSensitiveData(inputJson);
log.debug("Lambda input: {}", maskedInput);
} catch (JsonProcessingException e) {
log.debug("Failed to serialize input for logging");
}
}
}
/**
* Mask sensitive data in logs
*/
protected String maskSensitiveData(String json) {
// Implement sensitive data masking
return json.replaceAll("(\"password\"\\s*:\\s*\")([^\"]+)(\")", "$1***$3")
.replaceAll("(\"apiKey\"\\s*:\\s*\")([^\"]+)(\")", "$1***$3")
.replaceAll("(\"token\"\\s*:\\s*\")([^\"]+)(\")", "$1***$3");
}
/**
* Create standardized error response
*/
protected Map<String, Object> createErrorResponse(String message, String errorCode) {
return Map.of(
"error", true,
"message", message,
"errorCode", errorCode,
"timestamp", System.currentTimeMillis()
);
}
/**
* Create standardized success response
*/
protected Map<String, Object> createSuccessResponse(Object data, String message) {
return Map.of(
"success", true,
"data", data,
"message", message,
"timestamp", System.currentTimeMillis()
);
}
// Java 21 Record for concurrent results
public record ConcurrentResult<T, U>(T result1, U result2) {}
// Functional interface for tasks
@FunctionalInterface
public interface Task<T> {
T execute() throws Exception;
}
public static class LambdaExecutionException extends RuntimeException {
public LambdaExecutionException(String message) {
super(message);
}
public LambdaExecutionException(String message, Throwable cause) {
super(message, cause);
}
}
}
Step 3: Virtual Threads for High-Concurrency Processing
src/main/java/com/example/lambda/handler/ImageProcessingHandler.java:
package com.example.lambda.handler;
import com.example.lambda.core.BaseLambdaHandler;
import com.amazonaws.services.lambda.runtime.Context;
import lombok.extern.slf4j.Slf4j;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.util.List;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
/**
* Image processing handler using Java 21 virtual threads for high concurrency
*/
@Slf4j
public class ImageProcessingHandler extends BaseLambdaHandler<ImageProcessingHandler.ImageRequest,
ImageProcessingHandler.ProcessResult> {
private final S3Client s3Client;
public ImageProcessingHandler() {
this.s3Client = S3Client.create();
}
// Constructor for testing
public ImageProcessingHandler(S3Client s3Client) {
this.s3Client = s3Client;
}
@Override
protected ProcessResult processRequest(ImageRequest request, Context context) {
log.info("Processing image from bucket: {}, key: {}", request.sourceBucket(), request.sourceKey());
try {
// Download image from S3 using virtual threads
byte[] originalImage = downloadImageAsync(request.sourceBucket(), request.sourceKey()).get();
// Process multiple transformations concurrently using virtual threads
List<CompletableFuture<ProcessedImage>> transformations = processTransformationsAsync(
originalImage, request.transformations());
// Wait for all transformations to complete
CompletableFuture.allOf(transformations.toArray(new CompletableFuture[0])).join();
// Collect results
List<ProcessedImage> results = transformations.stream()
.map(CompletableFuture::join)
.toList();
// Upload results to S3
uploadResultsAsync(results, request.destinationBucket()).join();
return new ProcessResult(
true,
"Successfully processed " + results.size() + " transformations",
results.stream()
.map(ProcessedImage::toSummary)
.toList()
);
} catch (Exception e) {
log.error("Image processing failed", e);
return new ProcessResult(false, "Processing failed: " + e.getMessage(), List.of());
}
}
/**
* Download image from S3 using virtual threads
*/
private CompletableFuture<byte[]> downloadImageAsync(String bucket, String key) {
return CompletableFuture.supplyAsync(() -> {
try {
log.debug("Downloading image from S3: {}/{}", bucket, key);
var getObjectRequest = GetObjectRequest.builder()
.bucket(bucket)
.key(key)
.build();
return s3Client.getObjectAsBytes(getObjectRequest).asByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to download image from S3", e);
}
}, Executors.newVirtualThreadPerTaskExecutor());
}
/**
* Process multiple image transformations concurrently using virtual threads
*/
private List<CompletableFuture<ProcessedImage>> processTransformationsAsync(
byte[] originalImage, List<Transformation> transformations) {
return transformations.stream()
.map(transformation -> processTransformationAsync(originalImage, transformation))
.toList();
}
/**
* Process single transformation using virtual threads
*/
private CompletableFuture<ProcessedImage> processTransformationAsync(
byte[] originalImage, Transformation transformation) {
return CompletableFuture.supplyAsync(() -> {
try {
log.debug("Processing transformation: {}", transformation.type());
BufferedImage image = ImageIO.read(new ByteArrayInputStream(originalImage));
if (image == null) {
throw new IllegalArgumentException("Invalid image data");
}
BufferedImage processedImage = switch (transformation.type()) {
case "thumbnail" -> createThumbnail(image, transformation);
case "resize" -> resizeImage(image, transformation);
case "grayscale" -> convertToGrayscale(image);
case "rotate" -> rotateImage(image, transformation);
default -> throw new IllegalArgumentException("Unsupported transformation: " + transformation.type());
};
// Convert to desired format
String format = transformation.parameters().getOrDefault("format", "jpg");
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ImageIO.write(processedImage, format, outputStream);
return new ProcessedImage(
transformation.type(),
format,
outputStream.toByteArray(),
processedImage.getWidth(),
processedImage.getHeight()
);
} catch (Exception e) {
throw new RuntimeException("Transformation failed: " + transformation.type(), e);
}
}, Executors.newVirtualThreadPerTaskExecutor());
}
/**
* Upload processed images to S3 using virtual threads
*/
private CompletableFuture<Void> uploadResultsAsync(List<ProcessedImage> results, String bucket) {
List<CompletableFuture<Void>> uploads = results.stream()
.map(result -> uploadImageAsync(result, bucket))
.toList();
return CompletableFuture.allOf(uploads.toArray(new CompletableFuture[0]));
}
private CompletableFuture<Void> uploadImageAsync(ProcessedImage image, String bucket) {
return CompletableFuture.runAsync(() -> {
try {
String key = "processed/" + image.type() + "-" + System.currentTimeMillis() + "." + image.format();
var putObjectRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType("image/" + image.format())
.build();
s3Client.putObject(putObjectRequest, RequestBody.fromBytes(image.data()));
log.debug("Uploaded processed image to S3: {}/{}", bucket, key);
} catch (Exception e) {
throw new RuntimeException("Failed to upload image to S3", e);
}
}, Executors.newVirtualThreadPerTaskExecutor());
}
// Image processing methods
private BufferedImage createThumbnail(BufferedImage original, Transformation transformation) {
int width = Integer.parseInt(transformation.parameters().getOrDefault("width", "200"));
int height = Integer.parseInt(transformation.parameters().getOrDefault("height", "200"));
BufferedImage thumbnail = new BufferedImage(width, height, original.getType());
Graphics2D g = thumbnail.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(original, 0, 0, width, height, null);
g.dispose();
return thumbnail;
}
private BufferedImage resizeImage(BufferedImage original, Transformation transformation) {
int newWidth = Integer.parseInt(transformation.parameters().get("width"));
int newHeight = Integer.parseInt(transformation.parameters().get("height"));
BufferedImage resized = new BufferedImage(newWidth, newHeight, original.getType());
Graphics2D g = resized.createGraphics();
g.drawImage(original, 0, 0, newWidth, newHeight, null);
g.dispose();
return resized;
}
private BufferedImage convertToGrayscale(BufferedImage original) {
BufferedImage grayscale = new BufferedImage(
original.getWidth(), original.getHeight(), BufferedImage.TYPE_BYTE_GRAY);
Graphics2D g = grayscale.createGraphics();
g.drawImage(original, 0, 0, null);
g.dispose();
return grayscale;
}
private BufferedImage rotateImage(BufferedImage original, Transformation transformation) {
double angle = Math.toRadians(Double.parseDouble(
transformation.parameters().getOrDefault("angle", "90")));
double sin = Math.abs(Math.sin(angle));
double cos = Math.abs(Math.cos(angle));
int newWidth = (int) Math.floor(original.getWidth() * cos + original.getHeight() * sin);
int newHeight = (int) Math.floor(original.getHeight() * cos + original.getWidth() * sin);
BufferedImage rotated = new BufferedImage(newWidth, newHeight, original.getType());
Graphics2D g = rotated.createGraphics();
g.translate((newWidth - original.getWidth()) / 2, (newHeight - original.getHeight()) / 2);
g.rotate(angle, original.getWidth() / 2.0, original.getHeight() / 2.0);
g.drawRenderedImage(original, null);
g.dispose();
return rotated;
}
// Java 21 Records for data transfer
public record ImageRequest(
String sourceBucket,
String sourceKey,
String destinationBucket,
List<Transformation> transformations
) {}
public record Transformation(
String type,
Map<String, String> parameters
) {}
public record ProcessedImage(
String type,
String format,
byte[] data,
int width,
int height
) {
public Map<String, Object> toSummary() {
return Map.of(
"type", type,
"format", format,
"width", width,
"height", height,
"size", data.length
);
}
}
public record ProcessResult(
boolean success,
String message,
List<Map<String, Object>> processedImages
) {}
}
Step 4: DynamoDB Integration with Structured Concurrency
src/main/java/com/example/lambda/handler/UserManagementHandler.java:
package com.example.lambda.handler;
import com.example.lambda.core.BaseLambdaHandler;
import com.amazonaws.services.lambda.runtime.Context;
import lombok.extern.slf4j.Slf4j;
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.StructuredTaskScope;
/**
* User management handler using Java 21 structured concurrency for database operations
*/
@Slf4j
public class UserManagementHandler extends BaseLambdaHandler<Map<String, Object>, Map<String, Object>> {
private final DynamoDbEnhancedClient enhancedClient;
private final DynamoDbTable<User> userTable;
private static final String TABLE_NAME = System.getenv("USERS_TABLE");
public UserManagementHandler() {
DynamoDbClient dynamoDbClient = DynamoDbClient.create();
this.enhancedClient = DynamoDbEnhancedClient.builder()
.dynamoDbClient(dynamoDbClient)
.build();
this.userTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(User.class));
}
// Constructor for testing
public UserManagementHandler(DynamoDbEnhancedClient enhancedClient) {
this.enhancedClient = enhancedClient;
this.userTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(User.class));
}
@Override
protected Map<String, Object> processRequest(Map<String, Object> input, Context context) {
String httpMethod = (String) input.get("httpMethod");
String path = (String) input.get("path");
log.info("Processing {} request for path: {}", httpMethod, path);
return switch (httpMethod) {
case "POST" -> handlePostRequest(input);
case "GET" -> handleGetRequest(path, input);
case "PUT" -> handlePutRequest(path, input);
case "DELETE" -> handleDeleteRequest(path);
default -> createErrorResponse("Method not allowed", "METHOD_NOT_ALLOWED");
};
}
private Map<String, Object> handlePostRequest(Map<String, Object> input) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) input.get("body");
String email = (String) body.get("email");
String name = (String) body.get("name");
// Check if user already exists
User existingUser = findUserByEmail(email);
if (existingUser != null) {
return createErrorResponse("User already exists", "USER_EXISTS");
}
// Create new user
User user = new User();
user.setUserId(UUID.randomUUID().toString());
user.setEmail(email);
user.setName(name);
user.setCreatedAt(Instant.now().toString());
user.setUpdatedAt(Instant.now().toString());
user.setStatus("ACTIVE");
userTable.putItem(user);
log.info("Created user: {}", user.getUserId());
return createSuccessResponse(
Map.of(
"userId", user.getUserId(),
"email", user.getEmail(),
"name", user.getName()
),
"User created successfully"
);
} catch (Exception e) {
log.error("Failed to create user", e);
return createErrorResponse("Failed to create user", "CREATE_FAILED");
}
}
private Map<String, Object> handleGetRequest(String path, Map<String, Object> input) {
if ("/users".equals(path)) {
return getAllUsers(input);
} else if (path.startsWith("/users/")) {
String userId = extractUserId(path);
return getUserById(userId);
} else {
return createErrorResponse("Not found", "NOT_FOUND");
}
}
private Map<String, Object> getAllUsers(Map<String, Object> input) {
try {
@SuppressWarnings("unchecked")
Map<String, String> queryParams = (Map<String, String>) input.get("queryStringParameters");
int limit = queryParams != null && queryParams.containsKey("limit") ?
Integer.parseInt(queryParams.get("limit")) : 10;
// Using structured concurrency for parallel operations
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var usersTask = scope.fork(() -> userTable.scan().items().stream()
.limit(limit)
.map(this::toUserResponse)
.toList());
var countTask = scope.fork(() -> userTable.scan().items().stream().count());
scope.join();
scope.throwIfFailed();
List<Map<String, Object>> users = usersTask.get();
long totalCount = countTask.get();
return createSuccessResponse(
Map.of(
"users", users,
"totalCount", totalCount,
"returnedCount", users.size()
),
"Users retrieved successfully"
);
}
} catch (Exception e) {
log.error("Failed to retrieve users", e);
return createErrorResponse("Failed to retrieve users", "RETRIEVE_FAILED");
}
}
private Map<String, Object> getUserById(String userId) {
try {
User user = userTable.getItem(r -> r.key(k -> k.partitionValue(userId)));
if (user == null) {
return createErrorResponse("User not found", "USER_NOT_FOUND");
}
return createSuccessResponse(toUserResponse(user), "User retrieved successfully");
} catch (Exception e) {
log.error("Failed to retrieve user: {}", userId, e);
return createErrorResponse("Failed to retrieve user", "RETRIEVE_FAILED");
}
}
private Map<String, Object> handlePutRequest(String path, Map<String, Object> input) {
String userId = extractUserId(path);
try {
User existingUser = userTable.getItem(r -> r.key(k -> k.partitionValue(userId)));
if (existingUser == null) {
return createErrorResponse("User not found", "USER_NOT_FOUND");
}
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) input.get("body");
// Update user fields
if (body.containsKey("name")) {
existingUser.setName((String) body.get("name"));
}
if (body.containsKey("status")) {
existingUser.setStatus((String) body.get("status"));
}
existingUser.setUpdatedAt(Instant.now().toString());
userTable.putItem(existingUser);
log.info("Updated user: {}", userId);
return createSuccessResponse(toUserResponse(existingUser), "User updated successfully");
} catch (Exception e) {
log.error("Failed to update user: {}", userId, e);
return createErrorResponse("Failed to update user", "UPDATE_FAILED");
}
}
private Map<String, Object> handleDeleteRequest(String path) {
String userId = extractUserId(path);
try {
User existingUser = userTable.getItem(r -> r.key(k -> k.partitionValue(userId)));
if (existingUser == null) {
return createErrorResponse("User not found", "USER_NOT_FOUND");
}
// Soft delete by updating status
existingUser.setStatus("DELETED");
existingUser.setUpdatedAt(Instant.now().toString());
userTable.putItem(existingUser);
log.info("Soft deleted user: {}", userId);
return createSuccessResponse(
Map.of("userId", userId),
"User deleted successfully"
);
} catch (Exception e) {
log.error("Failed to delete user: {}", userId, e);
return createErrorResponse("Failed to delete user", "DELETE_FAILED");
}
}
private User findUserByEmail(String email) {
return userTable.scan().items().stream()
.filter(user -> email.equals(user.getEmail()))
.findFirst()
.orElse(null);
}
private String extractUserId(String path) {
return path.substring("/users/".length());
}
private Map<String, Object> toUserResponse(User user) {
return Map.of(
"userId", user.getUserId(),
"email", user.getEmail(),
"name", user.getName(),
"status", user.getStatus(),
"createdAt", user.getCreatedAt(),
"updatedAt", user.getUpdatedAt()
);
}
// DynamoDB entity class
public static class User {
private String userId;
private String email;
private String name;
private String status;
private String createdAt;
private String updatedAt;
// Getters and setters
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getCreatedAt() { return createdAt; }
public void setCreatedAt(String createdAt) { this.createdAt = createdAt; }
public String getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(String updatedAt) { this.updatedAt = updatedAt; }
}
}
Step 5: Event-Driven Processing with Java 21 Features
src/main/java/com/example/lambda/handler/OrderProcessingHandler.java:
package com.example.lambda.handler;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.events.SQSEvent;
import com.example.lambda.core.BaseLambdaHandler;
import lombok.extern.slf4j.Slf4j;
import software.amazon.awssdk.services.sns.SnsClient;
import software.amazon.awssdk.services.sns.model.PublishRequest;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
/**
* Order processing handler using Java 21 features for event-driven processing
*/
@Slf4j
public class OrderProcessingHandler extends BaseLambdaHandler<SQSEvent, String> {
private final SnsClient snsClient;
private final String notificationTopicArn;
public OrderProcessingHandler() {
this.snsClient = SnsClient.create();
this.notificationTopicArn = System.getenv("NOTIFICATION_TOPIC_ARN");
}
// Constructor for testing
public OrderProcessingHandler(SnsClient snsClient, String topicArn) {
this.snsClient = snsClient;
this.notificationTopicArn = topicArn;
}
@Override
protected String processRequest(SQSEvent event, Context context) {
log.info("Processing {} SQS messages", event.getRecords().size());
// Process messages concurrently using virtual threads
List<CompletableFuture<Void>> processingFutures = event.getRecords().stream()
.map(message -> processMessageAsync(message, context))
.toList();
// Wait for all processing to complete
CompletableFuture.allOf(processingFutures.toArray(new CompletableFuture[0])).join();
log.info("Successfully processed {} messages", event.getRecords().size());
return "Processed " + event.getRecords().size() + " messages";
}
private CompletableFuture<Void> processMessageAsync(SQSEvent.SQSMessage message, Context context) {
return CompletableFuture.runAsync(() -> {
try {
log.debug("Processing message: {}", message.getMessageId());
// Parse order from message body
Order order = objectMapper.readValue(message.getBody(), Order.class);
// Validate order using Java 21 pattern matching
if (!isValidOrder(order)) {
log.warn("Invalid order received: {}", order.orderId());
return;
}
// Process order steps concurrently
processOrderConcurrently(order).join();
log.info("Successfully processed order: {}", order.orderId());
} catch (Exception e) {
log.error("Failed to process message: {}", message.getMessageId(), e);
// Message will be retried or sent to DLQ based on SQS configuration
}
}, Executors.newVirtualThreadPerTaskExecutor());
}
/**
* Process order steps concurrently using structured concurrency
*/
private CompletableFuture<Void> processOrderConcurrently(Order order) {
return CompletableFuture.runAsync(() -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Execute order processing steps concurrently
var inventoryTask = scope.fork(() -> updateInventory(order));
var paymentTask = scope.fork(() -> processPayment(order));
var notificationTask = scope.fork(() -> sendNotification(order));
scope.join(); // Wait for all tasks
scope.throwIfFailed(); // Propagate any failures
// All tasks completed successfully
log.debug("All order processing tasks completed for order: {}", order.orderId());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Order processing interrupted", e);
} catch (Exception e) {
throw new RuntimeException("Order processing failed", e);
}
}, Executors.newVirtualThreadPerTaskExecutor());
}
private Void updateInventory(Order order) {
log.debug("Updating inventory for order: {}", order.orderId());
// Simulate inventory update
try {
Thread.sleep(100); // Simulate database operation
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Inventory update interrupted", e);
}
log.debug("Inventory updated for order: {}", order.orderId());
return null;
}
private Void processPayment(Order order) {
log.debug("Processing payment for order: {}", order.orderId());
// Simulate payment processing
try {
Thread.sleep(200); // Simulate payment gateway call
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Payment processing interrupted", e);
}
// Validate payment amount using Java 21 features
if (order.amount() <= 0) {
throw new RuntimeException("Invalid payment amount: " + order.amount());
}
log.debug("Payment processed for order: {}", order.orderId());
return null;
}
private Void sendNotification(Order order) {
log.debug("Sending notification for order: {}", order.orderId());
try {
String message = String.format(
"Order %s for %s has been processed. Amount: $%.2f",
order.orderId(),
order.customerName(),
order.amount()
);
var publishRequest = PublishRequest.builder()
.topicArn(notificationTopicArn)
.message(message)
.subject("Order Processed")
.build();
snsClient.publish(publishRequest);
log.debug("Notification sent for order: {}", order.orderId());
} catch (Exception e) {
log.error("Failed to send notification for order: {}", order.orderId(), e);
// Don't fail the entire order processing if notification fails
}
return null;
}
/**
* Validate order using Java 21 pattern matching and record patterns
*/
private boolean isValidOrder(Order order) {
// Using Java 21 pattern matching for instanceof and record patterns
return order instanceof Order(String orderId, String customerId, String customerName,
Double amount, List<OrderItem> items)
&& orderId != null && !orderId.isBlank()
&& customerId != null && !customerId.isBlank()
&& customerName != null && !customerName.isBlank()
&& amount != null && amount > 0
&& items != null && !items.isEmpty()
&& items.stream().allMatch(this::isValidOrderItem);
}
private boolean isValidOrderItem(OrderItem item) {
return item instanceof OrderItem(String productId, Integer quantity, Double price)
&& productId != null && !productId.isBlank()
&& quantity != null && quantity > 0
&& price != null && price >= 0;
}
// Java 21 Records for event data
public record Order(
String orderId,
String customerId,
String customerName,
Double amount,
List<OrderItem> items
) {}
public record OrderItem(
String productId,
Integer quantity,
Double price
) {}
}
Step 6: SAM Template for Deployment
template.yml:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Parameters:
Environment:
Type: String
Default: dev
AllowedValues:
- dev
- staging
- prod
Globals:
Function:
Runtime: java21
Architectures:
- x86_64
Timeout: 30
MemorySize: 512
Environment:
Variables:
JAVA_TOOL_OPTIONS: >
-XX:+TieredCompilation
-XX:TieredStopAtLevel=1
-Xshare:on
-XX:MaxRAMPercentage=75.0
-Xss256k
POWERTOOLS_SERVICE_NAME: lambda-java21
LOG_LEVEL: INFO
Resources:
# Image Processing Function
ImageProcessingFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub image-processor-${Environment}
CodeUri: target/lambda-java21-1.0.0.jar
Handler: com.example.lambda.handler.ImageProcessingHandler::handleRequest
Policies:
- S3ReadPolicy:
BucketName: !Ref SourceImageBucket
- S3WritePolicy:
BucketName: !Ref ProcessedImageBucket
Environment:
Variables:
SOURCE_BUCKET: !Ref SourceImageBucket
DESTINATION_BUCKET: !Ref ProcessedImageBucket
Events:
ApiEvent:
Type: Api
Properties:
Path: /images/process
Method: post
# User Management Function
UserManagementFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub user-manager-${Environment}
CodeUri: target/lambda-java21-1.0.0.jar
Handler: com.example.lambda.handler.UserManagementHandler::handleRequest
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref UsersTable
Environment:
Variables:
USERS_TABLE: !Ref UsersTable
Events:
UserApi:
Type: Api
Properties:
Path: /users
Method: any
UserByIdApi:
Type: Api
Properties:
Path: /users/{userId}
Method: any
# Order Processing Function
OrderProcessingFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub order-processor-${Environment}
CodeUri: target/lambda-java21-1.0.0.jar
Handler: com.example.lambda.handler.OrderProcessingHandler::handleRequest
Policies:
- SNSPublishMessagePolicy:
TopicName: !GetRef NotificationTopic
- SQSPollerPolicy:
QueueName: !GetRef OrderQueue
Environment:
Variables:
NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic
Events:
OrderQueueEvent:
Type: SQS
Properties:
Queue: !GetAtt OrderQueue.Arn
BatchSize: 10
# S3 Buckets
SourceImageBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub source-images-${AWS::AccountId}-${Environment}
LifecycleConfiguration:
Rules:
- Id: CleanupOldImages
Status: Enabled
ExpirationInDays: 7
ProcessedImageBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub processed-images-${AWS::AccountId}-${Environment}
# DynamoDB Table
UsersTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: !Sub users-${Environment}
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: userId
AttributeType: S
KeySchema:
- AttributeName: userId
KeyType: HASH
SSESpecification:
SSEEnabled: true
# SQS Queue
OrderQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub orders-${Environment}
VisibilityTimeout: 300
RedrivePolicy:
deadLetterTargetArn: !GetAtt OrderDLQ.Arn
maxReceiveCount: 3
OrderDLQ:
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub orders-dlq-${Environment}
# SNS Topic
NotificationTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: !Sub notifications-${Environment}
Outputs:
ApiUrl:
Description: "API Gateway URL"
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}"
ImageProcessingFunctionArn:
Description: "Image Processing Function ARN"
Value: !GetAtt ImageProcessingFunction.Arn
UserManagementFunctionArn:
Description: "User Management Function ARN"
Value: !GetAtt UserManagementFunction.Arn
OrderProcessingFunctionArn:
Description: "Order Processing Function ARN"
Value: !GetAtt OrderProcessingFunction.Arn
Step 7: Build and Deployment Scripts
build-and-deploy.sh:
#!/bin/bash
set -e
# Configuration
STACK_NAME="lambda-java21-demo"
ENVIRONMENT="${1:-dev}"
REGION="${AWS_REGION:-us-east-1}"
ARTIFACT_BUCKET="lambda-artifacts-$(aws sts get-caller-identity --query Account --output text)"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
build_project() {
log_info "Building project with Maven..."
# Clean and build
mvn clean package -DskipTests
# Verify build
if [ ! -f "target/lambda-java21-1.0.0.jar" ]; then
log_error "Build failed - JAR file not found"
exit 1
fi
log_info "Build completed successfully"
}
upload_artifacts() {
log_info "Uploading artifacts to S3..."
# Create artifact bucket if it doesn't exist
if ! aws s3 ls "s3://$ARTIFACT_BUCKET" 2>&1 | grep -q 'NoSuchBucket'; then
log_info "Creating artifact bucket: $ARTIFACT_BUCKET"
aws s3 mb "s3://$ARTIFACT_BUCKET" --region "$REGION"
fi
# Upload JAR to S3
aws s3 cp "target/lambda-java21-1.0.0.jar" \
"s3://$ARTIFACT_BUCKET/lambda-java21/$ENVIRONMENT/"
log_info "Artifacts uploaded to S3"
}
deploy_stack() {
log_info "Deploying CloudFormation stack: $STACK_NAME"
sam deploy \
--stack-name "$STACK_NAME" \
--parameter-overrides "Environment=$ENVIRONMENT" \
--s3-bucket "$ARTIFACT_BUCKET" \
--s3-prefix "lambda-java21/$ENVIRONMENT" \
--region "$REGION" \
--capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \
--no-fail-on-empty-changeset \
--tags "Environment=$ENVIRONMENT" "Project=lambda-java21"
log_info "Stack deployment completed"
}
run_tests() {
log_info "Running tests..."
mvn test
log_info "Tests completed successfully"
}
show_outputs() {
log_info "Stack outputs:"
aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs" \
--output table \
--region "$REGION"
}
# Main execution
main() {
log_info "Starting deployment for environment: $ENVIRONMENT"
log_info "Region: $REGION"
log_info "Artifact bucket: $ARTIFACT_BUCKET"
build_project
run_tests
upload_artifacts
deploy_stack
show_outputs
log_info "Deployment completed successfully!"
}
# Handle errors
trap 'log_error "Deployment failed"; exit 1' ERR
main
Step 8: Testing with LocalStack
src/test/java/com/example/lambda/UserManagementHandlerTest.java:
package com.example.lambda;
import com.example.lambda.handler.UserManagementHandler;
import com.amazonaws.services.lambda.runtime.Context;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.localstack.LocalStackContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;
import java.net.URI;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
@Testcontainers
class UserManagementHandlerTest {
@Container
static LocalStackContainer localStack = new LocalStackContainer(
DockerImageName.parse("localstack/localstack:2.0.0"))
.withServices(LocalStackContainer.Service.DYNAMODB);
private UserManagementHandler handler;
private DynamoDbEnhancedClient enhancedClient;
@BeforeEach
void setUp() {
// Create DynamoDB client pointing to LocalStack
DynamoDbClient dynamoDbClient = DynamoDbClient.builder()
.endpointOverride(localStack.getEndpointOverride(LocalStackContainer.Service.DYNAMODB))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(localStack.getAccessKey(), localStack.getSecretKey())))
.region(Region.of(localStack.getRegion()))
.build();
this.enhancedClient = DynamoDbEnhancedClient.builder()
.dynamoDbClient(dynamoDbClient)
.build();
this.handler = new UserManagementHandler(enhancedClient);
// Create table
createUsersTable(dynamoDbClient);
}
@Test
void testCreateUser() {
// Given
Map<String, Object> input = Map.of(
"httpMethod", "POST",
"path", "/users",
"body", Map.of(
"email", "[email protected]",
"name", "Test User"
)
);
Context context = mock(Context.class);
// When
Map<String, Object> result = handler.handleRequest(input, context);
// Then
assertTrue((Boolean) result.get("success"));
assertNotNull(((Map<?, ?>) result.get("data")).get("userId"));
assertEquals("[email protected]", ((Map<?, ?>) result.get("data")).get("email"));
}
@Test
void testGetUser() {
// First create a user
Map<String, Object> createInput = Map.of(
"httpMethod", "POST",
"path", "/users",
"body", Map.of(
"email", "[email protected]",
"name", "Get User"
)
);
Context context = mock(Context.class);
Map<String, Object> createResult = handler.handleRequest(createInput, context);
String userId = (String) ((Map<?, ?>) createResult.get("data")).get("userId");
// Then get the user
Map<String, Object> getInput = Map.of(
"httpMethod", "GET",
"path", "/users/" + userId
);
Map<String, Object> getResult = handler.handleRequest(getInput, context);
// Verify
assertTrue((Boolean) getResult.get("success"));
assertEquals(userId, ((Map<?, ?>) getResult.get("data")).get("userId"));
assertEquals("[email protected]", ((Map<?, ?>) getResult.get("data")).get("email"));
}
private void createUsersTable(DynamoDbClient dynamoDbClient) {
try {
CreateTableRequest request = CreateTableRequest.builder()
.tableName("users-test")
.keySchema(KeySchemaElement.builder()
.attributeName("userId")
.keyType(KeyType.HASH)
.build())
.attributeDefinitions(AttributeDefinition.builder()
.attributeName("userId")
.attributeType(ScalarAttributeType.S)
.build())
.billingMode(BillingMode.PAY_PER_REQUEST)
.build();
dynamoDbClient.createTable(request);
// Wait for table to be active
dynamoDbClient.waiter().waitUntilTableExists(
DescribeTableRequest.builder()
.tableName("users-test")
.build()
);
} catch (ResourceInUseException e) {
// Table already exists
}
}
}
Step 9: Performance Optimization Configuration
src/main/resources/log4j2.xml:
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Lambda name="Lambda">
<PatternLayout>
<pattern>%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1} - %m%n</pattern>
</PatternLayout>
</Lambda>
</Appenders>
<Loggers>
<Root level="INFO">
<AppenderRef ref="Lambda"/>
</Root>
<Logger name="com.example" level="DEBUG" additivity="false">
<AppenderRef ref="Lambda"/>
</Logger>
<Logger name="software.amazon.awssdk" level="WARN" additivity="false">
<AppenderRef ref="Lambda"/>
</Logger>
</Loggers>
</Configuration>
Step 10: Usage Examples
Deploy the Application:
# Build and deploy ./build-and-deploy.sh dev # Deploy to production ./build-and-deploy.sh prod # Deploy with custom region AWS_REGION=eu-west-1 ./build-and-deploy.sh staging
Test the Functions:
# Test image processing
curl -X POST https://your-api-gateway-url/dev/images/process \
-H "Content-Type: application/json" \
-d '{
"sourceBucket": "source-bucket",
"sourceKey": "image.jpg",
"destinationBucket": "processed-bucket",
"transformations": [
{"type": "thumbnail", "parameters": {"width": "200", "height": "200"}},
{"type": "grayscale", "parameters": {}}
]
}'
# Test user management
curl -X POST https://your-api-gateway-url/dev/users \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "name": "John Doe"}'
curl https://your-api-gateway-url/dev/users
Monitor Performance:
# View CloudWatch logs aws logs tail "/aws/lambda/user-manager-dev" --follow # Check Lambda metrics aws cloudwatch get-metric-statistics \ --namespace AWS/Lambda \ --metric-name Duration \ --dimensions Name=FunctionName,Value=user-manager-dev \ --start-time 2024-01-01T00:00:00Z \ --end-time 2024-01-02T00:00:00Z \ --period 3600 \ --statistics Average Maximum
Best Practices for Java 21 Lambda
1. Performance Optimization
- Use Virtual Threads for I/O-bound operations
- Enable Lambda SnapStart for faster cold starts
- Optimize JVM flags for serverless environment
- Use provisioned concurrency for critical functions
2. Memory Management
- Set appropriate memory size (512MB-3008MB)
- Use
-XX:MaxRAMPercentage=75.0to leave memory for system - Enable
-Xshare:onfor class data sharing - Use
-Xss256kto reduce thread stack size
3. Monitoring and Observability
- Structured logging with JSON format
- Custom metrics for business KPIs
- Distributed tracing with X-Ray
- Function insights for performance analysis
Benefits of Java 21 for Lambda
- Faster Cold Starts: Improved startup performance with optimized JVM
- Better Concurrency: Virtual threads for massive I/O parallelism
- Reduced Memory: Lower memory footprint with better GC
- Modern Language Features: Records, pattern matching, sealed classes
- Enhanced Developer Experience: Better tooling and debugging support
Conclusion
AWS Lambda with Java 21 runtime provides a powerful platform for building modern serverless applications that are:
- High-performance with optimized cold starts and execution
- Cost-effective with efficient resource utilization
- Scalable with automatic scaling and virtual threads
- Maintainable with modern Java language features
- Observable with comprehensive monitoring and tracing
By leveraging Java 21 features like virtual threads, structured concurrency, and records, you can build serverless applications that are both performant and maintainable while taking full advantage of the AWS serverless ecosystem.