AWS Lambda Function in Java: Create, Deploy and Invoke

Write an AWS Lambda function in Java 25 as a RequestHandler with records, test it with JUnit, and deploy and invoke it with the AWS CLI.

An AWS Lambda function in Java is a class that implements the RequestHandler<I, O> interface, which AWS Lambda calls with the parsed JSON input of each request and whose return value Lambda sends back as JSON. We package the class as a JAR file, upload it with the aws lambda create-function command, and AWS runs it on its servers only when a request comes in.

We use AWS Lambda for code that runs on demand or on an event, such as a small API endpoint, a job that resizes each image uploaded to S3, or a nightly cleanup task. We pay only for the requests and the milliseconds the code runs, and there is no server for us to patch or scale.

The following example builds a Java 25 function with Maven, deploys it with the AWS CLI and calls it once, with the result of each command as a comment.

mvn package                                     # target/split-bill-1.0.jar (handler class + dependencies)

aws lambda create-function --function-name split-bill \
  --runtime java25 \
  --handler com.howtodoinjava.lambda.SplitBillHandler \
  --role arn:aws:iam::123456789012:role/split-bill-role \
  --memory-size 512 --timeout 15 \
  --zip-file fileb://target/split-bill-1.0.jar  # creates the function in the current region

aws lambda invoke --function-name split-bill \
  --cli-binary-format raw-in-base64-out \
  --payload '{"total": 84.00, "people": 4, "tipPercent": 15}' \
  response.json                                 # "StatusCode": 200

cat response.json                               # {"tip":12.60,"perPerson":24.15}

Notice the –handler value. It is the full class name of our handler, and the –runtime java25 value picks the Java 25 managed runtime, so Lambda knows which JVM to start and which class to call.

Next, we see how Lambda runs a Java function, and then we write the handler, test it with JUnit, create the execution role and deploy the function. The FAQs at the end cover cold starts and running the function on our own machine.

1. How AWS Lambda Runs a Java Function

AWS Lambda is a serverless compute service, which means AWS runs our code in short-lived environments that it creates, scales and removes for us. A Lambda function is the deployed unit, which is our JAR file plus its settings, such as the runtime, the handler class, the memory size and the execution role.

When a request comes in, Lambda takes a free execution environment for the function, or creates a new one. A new environment starts the JVM, loads our JAR and creates the handler object, which is called the init phase. For each request, the Java runtime converts the JSON input to our input type, calls handleRequest(), and converts the return value back to JSON.

For example, a restaurant app has a “Split the bill” button. Guests press it a few hundred times on a Friday evening and almost never on a Monday morning. With a Lambda function, the app pays for the few hundred calls and nothing for the idle hours.

Flow of one invocation. The caller sends the JSON payload total 84.00, people 4, tipPercent 15 with aws lambda invoke. The Lambda service picks an execution environment with the java25 runtime, which deserializes the JSON into the Bill record, calls SplitBillHandler.handleRequest(bill, context), serializes the returned Share record to the JSON tip 12.60, perPerson 24.15, and returns it to the caller. Log lines go to the CloudWatch log group /aws/lambda/split-bill.
Lambda converts the JSON input to a Bill record, calls handleRequest() and converts the returned Share record back to JSON.

Each function has a handful of settings that we pass to create-function. Only the runtime, the handler, the role and the code are required, so the others keep their defaults if we leave them out.

SettingCLI optionOur valueDefault
Runtime–runtimejava25none (required)
Handler–handlercom.howtodoinjava.lambda.SplitBillHandlernone (required)
Execution role–roleARN of split-bill-rolenone (required)
Memory–memory-size512 MB128 MB
Timeout–timeout15 seconds3 seconds

Lambda gives a function CPU power in proportion to its memory, so a Java function with the default 128 MB starts slowly. We give it 512 MB and a 15-second timeout, which leaves room for the first call after a new environment starts.

Lambda supports the Java runtimes java25, java21 and java17 (all on Amazon Linux 2023) and older runtimes for Java 11 and Java 8. We pick java25 and compile with the same Java version.

2. Setting Up the Maven Project

A Java Lambda function needs only one library at runtime, aws-lambda-java-core, which contains the RequestHandler and Context interfaces. Lambda does not download dependencies for us, so the JAR we upload must contain every class the function uses. The Maven Shade plugin builds such a JAR (an uber JAR) in the package phase.

The following project uses Java 25, aws-lambda-java-core 1.4.0, maven-shade-plugin 3.6.2 and JUnit 6.1.3, the current versions on Maven Central. We need the AWS CLI version 2, configured with aws configure for an IAM user that can create roles and functions.

<properties>
  <maven.compiler.release>25</maven.compiler.release>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
  <dependency>
    <groupId>com.amazonaws</groupId>
    <artifactId>aws-lambda-java-core</artifactId>
    <version>1.4.0</version>
  </dependency>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>6.1.3</version>
    <scope>test</scope>
  </dependency>
</dependencies>

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <version>3.6.2</version>
      <configuration>
        <createDependencyReducedPom>false</createDependencyReducedPom>
      </configuration>
      <executions>
        <execution>
          <phase>package</phase>
          <goals>
            <goal>shade</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

The pom.xml also pins maven-compiler-plugin 3.15.0 and maven-surefire-plugin 3.6.0. When the function calls other AWS services, we add the matching AWS SDK for Java 2.x module, for example s3 or dynamodb, and the Shade plugin puts it into the same JAR.

3. Writing the Handler Class

The handler class implements RequestHandler<I, O>, where I is the input type and O is the output type. On Java 17 and newer runtimes, both types can be Java records, and the runtime maps the JSON field names to the record components. The second parameter of handleRequest() is a Context object, which gives us the request ID, the remaining time and a LambdaLogger.

The following example splits a restaurant bill. The input record Bill holds the total, the number of people and the tip percentage, and the output record Share holds the tip and the amount each person pays. We use BigDecimal for the money values, so 84.00 stays 84.00 and is not rounded like a double.

public class SplitBillHandler implements RequestHandler<SplitBillHandler.Bill, SplitBillHandler.Share> {

  public record Bill(BigDecimal total, int people, int tipPercent) {
  }

  public record Share(BigDecimal tip, BigDecimal perPerson) {
  }

  @Override
  public Share handleRequest(Bill bill, Context context) {
    LambdaLogger logger = context.getLogger();
    logger.log("Splitting " + bill);

    if (bill == null || bill.total() == null || bill.total().signum() < 0) {
      throw new IllegalArgumentException("total must be zero or more");
    }
    if (bill.people() < 1) {
      throw new IllegalArgumentException("people must be at least 1");
    }

    BigDecimal tip = bill.total()
        .multiply(BigDecimal.valueOf(bill.tipPercent()))
        .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
    BigDecimal perPerson = bill.total().add(tip)
        .divide(BigDecimal.valueOf(bill.people()), 2, RoundingMode.HALF_UP);

    return new Share(tip, perPerson);
  }
}

The two checks at the top matter because any client can send any JSON. A missing people field becomes 0, and the division would throw an ArithmeticException with the message “/ by zero”, which does not tell the caller which field is wrong. With the checks, the caller gets a clear message, as we will see in section 6.2.

The class has no static fields here, but in a real function we create SDK clients and database connection pools in static fields or in the constructor. Lambda runs that code once per execution environment, in the init phase, and reuses the objects for every request that the environment handles.

4. Testing the Handler With JUnit

The handler is a plain Java class, so we test it with JUnit before we deploy anything. The only part that needs the Lambda runtime is the Context parameter, and a small test class that implements Context replaces it.

class TestContext implements Context {

  private final LambdaLogger logger = new LambdaLogger() {
    @Override
    public void log(String message) {
      System.out.println(message);
    }

    @Override
    public void log(byte[] message) {
      System.out.println(new String(message));
    }
  };

  @Override
  public LambdaLogger getLogger() {
    return logger;
  }

  // the other Context methods return fixed test values, e.g. getAwsRequestId() returns "test-request"
}

The test calls handleRequest() the same way the Lambda runtime does. The first test checks the result of a valid bill, and the second test checks that zero people throw an IllegalArgumentException with our message.

private final SplitBillHandler handler = new SplitBillHandler();
private final TestContext context = new TestContext();

@Test
void splitsBillWithTip() {
  SplitBillHandler.Bill bill = new SplitBillHandler.Bill(new BigDecimal("84.00"), 4, 15);

  SplitBillHandler.Share share = handler.handleRequest(bill, context);

  assertEquals(new BigDecimal("12.60"), share.tip());           // 15% of 84.00
  assertEquals(new BigDecimal("24.15"), share.perPerson());     // (84.00 + 12.60) / 4
}

@Test
void rejectsZeroPeople() {
  SplitBillHandler.Bill bill = new SplitBillHandler.Bill(new BigDecimal("84.00"), 0, 15);

  IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
      () -> handler.handleRequest(bill, context));
  assertEquals("people must be at least 1", e.getMessage());
}

We run mvn package, which runs the tests before it builds the JAR. The log lines from LambdaLogger show up in the console because our TestContext prints them.

[INFO] Running com.howtodoinjava.lambda.SplitBillHandlerTest
Splitting Bill[total=84.00, people=4, tipPercent=15]
Splitting Bill[total=84.00, people=0, tipPercent=15]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- shade:3.6.2:shade (default) @ split-bill ---
[INFO] Replacing original artifact with shaded artifact.
[INFO] BUILD SUCCESS

The file target/split-bill-1.0.jar is the deployment package. It is about 16 KB, because it contains only our handler class, the two records and aws-lambda-java-core.

5. Deploying the Function to AWS Lambda

A deployment needs two things in AWS, namely an execution role that the function runs with and the function itself. We create both once with the AWS CLI, and later code changes need only one command.

5.1. Creating the Execution Role

Every Lambda function runs with an IAM role, called the execution role, which defines what the function may do in our AWS account. The role needs a trust policy that lets the Lambda service assume it.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

We create the role and attach the AWS managed policy AWSLambdaBasicExecutionRole, which allows the function to write its logs to Amazon CloudWatch Logs. The create-role command prints the role ARN, which we pass to create-function in the next step.

aws iam create-role --role-name split-bill-role \
  --assume-role-policy-document file://trust-policy.json

aws iam attach-role-policy --role-name split-bill-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

We give the role only the permissions the function needs. When the function reads from one S3 bucket, we add a policy for that bucket, not AmazonS3FullAccess.

5.2. Creating the Function

The create-function command uploads the JAR and creates the function in the region of our CLI profile. We pass the same values as in the intro, and the account ID in the role ARN is the one from the create-role output.

aws lambda create-function --function-name split-bill \
  --runtime java25 \
  --handler com.howtodoinjava.lambda.SplitBillHandler \
  --role arn:aws:iam::123456789012:role/split-bill-role \
  --memory-size 512 --timeout 15 \
  --zip-file fileb://target/split-bill-1.0.jar

For a class that implements RequestHandler, the handler value is the class name alone. For a class without the interface, we add the method name, as in com.howtodoinjava.lambda.SplitBillHandler::handleRequest. The handler documentation describes both forms.

The –zip-file option works for packages up to 50 MB. A larger JAR goes to an S3 bucket in the same region first, and we pass its location with –code S3Bucket=…,S3Key=….

5.3. Updating the Code

After a code change, we build the JAR again and upload it with update-function-code. The function keeps its name, role and settings.

mvn package
aws lambda update-function-code --function-name split-bill \
  --zip-file fileb://target/split-bill-1.0.jar

5.4. Using the AWS Console Instead

The AWS Console does the same steps in the browser, which works well for a first try. The console also creates a basic execution role for us.

  • Open the Lambda console and click Create function, then choose Author from scratch.
  • Enter the name split-bill, select the runtime Java 25, and keep the option to create a new role with basic Lambda permissions.
  • On the Code tab, choose Upload from, then .zip or .jar file, and upload split-bill-1.0.jar.
  • Under Runtime settings, click Edit and set the handler to com.howtodoinjava.lambda.SplitBillHandler.
  • Under Configuration, then General configuration, set the memory to 512 MB and the timeout to 15 seconds.

For anything beyond a first try, the CLI commands, or a tool such as AWS SAM or the AWS CDK, are the better choice, because we can repeat them and keep them in version control.

6. Invoking the Lambda Function

The aws lambda invoke command calls the function synchronously, so the CLI waits for the result and writes the response body to a file. With AWS CLI version 2, we add –cli-binary-format raw-in-base64-out, so the CLI sends the –payload text as plain JSON.

6.1. A Successful Call

We send a bill of 84.00 for four people with a 15 percent tip. The CLI prints the status of the call, and the file response.json contains the JSON of the returned Share record.

aws lambda invoke --function-name split-bill \
  --cli-binary-format raw-in-base64-out \
  --payload '{"total": 84.00, "people": 4, "tipPercent": 15}' \
  response.json

cat response.json
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
{"tip":12.60,"perPerson":24.15}

Notice that the JSON field names in the payload and in the response match the record component names. A field that the payload does not contain gets the default value of its type, so total becomes null and people becomes 0. Our checks in section 3 reject both values.

6.2. When the Function Throws an Exception

When handleRequest() throws an exception, Lambda returns an error object instead of the result. The status code is still 200, because Lambda did run the function, and the CLI output gets an extra FunctionError field.

aws lambda invoke --function-name split-bill \
  --cli-binary-format raw-in-base64-out \
  --payload '{"total": 84.00, "people": 0, "tipPercent": 15}' \
  response.json
{"errorMessage":"people must be at least 1","errorType":"java.lang.IllegalArgumentException","stackTrace":["com.howtodoinjava.lambda.SplitBillHandler.handleRequest(SplitBillHandler.java:27)","com.howtodoinjava.lambda.SplitBillHandler.handleRequest(SplitBillHandler.java:10)"]}

The errorType field holds the exception class, so a caller can tell bad input from a bug. An unchecked exception such as IllegalArgumentException is enough here, because handleRequest() does not declare checked exceptions.

6.3. Reading the Logs

Lambda sends everything the function writes with LambdaLogger, and with System.out, to the CloudWatch log group /aws/lambda/split-bill. Each call also adds a REPORT line with the duration, the billed duration and the memory used.

aws logs tail /aws/lambda/split-bill --follow

The REPORT line of the first call after a new environment starts also shows an Init Duration. For Java, the init phase can take a few seconds, because the JVM starts and loads our classes.

7. AWS Lambda Java FAQs

7.1. How Do I Reduce Cold Starts of a Java Lambda Function?

We turn on Lambda SnapStart, which supports Java 11 and newer runtimes. SnapStart takes a snapshot of the initialized execution environment when we publish a version, and new environments resume from that snapshot instead of starting the JVM and our init code again. For Java managed runtimes, SnapStart has no extra cost.

aws lambda update-function-configuration --function-name split-bill \
  --snap-start ApplyOn=PublishedVersions

aws lambda publish-version --function-name split-bill

SnapStart works only for published versions, not for $LATEST, so callers must invoke the version number or an alias that points to it. More memory helps too, because Lambda gives a function more CPU with more memory.

7.2. Can I Run a Java Lambda Function Locally?

Yes. The quickest check is a JUnit test that calls handleRequest(), as in section 4. To run the function in a Lambda-like environment with JSON input, we use sam local invoke from the AWS SAM CLI, which starts the function in a Docker container with the official Java runtime image.

7.3. Do I Need a Special Framework to Write a Lambda Function in Java?

No. A class that implements RequestHandler and the aws-lambda-java-core library are enough, as our example shows. Frameworks such as Spring Cloud Function or Quarkus add dependency injection and HTTP routing, but they also make the JAR larger and the init phase longer.

8. Conclusion

A Java Lambda function is a class that implements RequestHandler<I, O>. The Java runtime turns the JSON input into our input type, which can be a record on Java 17 and newer, calls handleRequest() and turns the result back into JSON.

We build the function with Maven and the Shade plugin into one JAR, create an execution role once, and deploy the JAR with aws lambda create-function. Later changes need only mvn package and update-function-code, and aws lambda invoke calls the function from the terminal.

Before deploying, we test the handler with JUnit and a small Context implementation, and we validate the input, so a bad request returns a clear errorMessage instead of an ArithmeticException.

9. References

Happy Learning !!

Leave a Comment

  1. Great Post. Simple Explanation.
    However, my lambda function is failing giving me following error:
    {
    “errorMessage”: “Class not found: LambdaFunctionHandler”,
    “errorType”: “java.lang.ClassNotFoundException”
    }
    Any suggestions?

  2. Great post. Just a note. To deploy the function I had to click on the java class that gave me the “Upload Function” option.

  3. Am not getting this option–> Upload Function to AWS Lambda option. Have followed all the steps

  4. Lamda function upload fails with ”
    An internal error occurred during: “Uploading function code to Lambda”.
    Could not initialize class com.amazonaws.util.Base64″
    Any inputs to fix the error?

  5. Thank you for providing good explanation. It would be great if you share any other server less examples.

  6. Thanks for providing the detail description. But I am facing some problems while taking HTTP protocol data( HTML form data ) as input to lambda function. Can you help me on this issue?

  7. Thanks for the post. I love this blog.
    Coming to the post this is very simple.
    If you could have shown/written this code with some more realistic configuration like with a service layer and a dao layer then that makes this blog much different than the others and it will be really helpful for us. I hope you understand what I am saying.

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.