This Scala3 library provides a custom AWS Lambda runtime for building functions using Scala3.
Read more about AWS Lambda:
- https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html
- https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html
- https://docs.aws.amazon.com/lambda/latest/dg/runtimes-walkthrough.html
- Dependencies
- Usage
- Handler API
- Main method
- Custom runtime lifecycle
- Lambda template g8
- Lambda deployment
- Java21 runtime compatibility
- Logging
- Testing
- Running custom runtime locally
- Lambda examples
- Project content
- Scala >= 3.3.5
- com.amazonaws aws-lambda-java-core 1.2.3
Use with SBT
libraryDependencies += "org.encalmo" %% "scala-aws-lambda-runtime" % "0.9.11"
or with SCALA-CLI
//> using dep org.encalmo::scala-aws-lambda-runtime:0.9.11
The contract for lambda handler is defined in the EventHandler trait as:
/** Custom context initializez by the application. */
type ApplicationContext
/** Initialize your implicit ApplicationContext here based on the lambda environment.
*
* This context can be anything you want to initialize ONCE per lambda run, e.g. AWS client, etc.
*/
def initialize(using LambdaEnvironment): ApplicationContext
/** Provide your lambda business logic here.
*
* @param input
* event sent to the lambda
* @return
* lambda output string
*/
def handleRequest(input: String)(using LambdaContext, ApplicationContext): String
The initialize
method is invoked only once per lambda execution environment and produces an instance of the ApplicationContext type. This value is later passed to each invocation of the handleRequest
. This is your dependecy injection moment.
The handlerRequest
is the method executed on each lambda invocation. It takes an input string representing lambda event, and returns a string passed back to lambda caller. This is accompanied by two implicit arguments: LambdaContext and ApplicationContext.
Abstract type ApplicationContext represents anything you want to initialize only once and re-use between request handler invocations. This can be a type alias, a case class, a tuple, a named tuple or a map, you name it:
case class Config(greeting: String)
type ApplicationContext = Config
or
type ApplicationContext = (Config, AwsClient)
or
type ApplicationContext = (config: Config, awsClient: AwsClient)
in case application context is not needed one can declare always
type ApplicationContext = Unit
LambdaContext class provides access to both LambdaEnvironment instance and current lambda invocation properties.
LambdaEnvironment class represents properties of the lambda execution environment and custom runtime. Since those properties might be simulated in the tests and in the local run, it is recommended to use those methods over reading from system variable's directly.
Each lambda is compiled into a standalone application binary using GraalVM. The entry point is a main method defined on the lambda's companion object, e.g.
object ExampleLambda {
@static def main(args: Array[String]): Unit = new ExampleLambda().run()
}
The name of the main class must be declared in your build for graalvm to work properly, e.g.
//> using mainClass org.encalmo.lambda.example.ExampleLambda
Custom runtime embeded in the LambdaRuntime trait does NOT start immediately when lambda instance is initialized.
Instead, runtime instance must be initialized explicitly by invoking run()
method. This design allows us to test lambda without http overhead, or to even run lambda using other runtimes (like built-in AWS java runtimes).
Under the cover we run three other methods:
def run =
initializeLambdaRuntime()
.start()
.waitUntilInterrupted()
initializeLambdaRuntime
is responsible for creation of the new instance of the runtime and initialization of the both lambda environment and application context,start
is just what it says on the tin; it starts the actual thingy,waitUntilInterrupted()
keeps the main loop running and waits for the termination by AWS Lambda environment.
It is possible to pause the runtime by calling pause()
and shutdown it completely by calling shutdown()
.
For the convenience of creating a new lambda project there is a template in g8 format. One has to run:
sbt new encalmo/scala-aws-lambda-seed.g8 --branch main --lambdaName="ExampleLambda" --package="org.encalmo.lambda.example" --awsAccountId="047719648492" --awsRegion="eu-central-1" -o scala-aws-lambda-seed
where:
lambdaName
- the name of the lambda functionpackage
- the name of the main lambda packageawsAccountId
- required for Github Actions and tests configawsRegion
- required for Github Actions and tests config
Deployment of the lambda function using custom runtime requires the following steps:
- compilation by GraalVM to produce
bootstrap
executable, - packaging into a ZIP archive,
- uploading the package into AWS Lambda either manually or using the Lambda API
The example Github Action to automate those steps is included in the g8 template, and in the lambda examples. See buildAndDeployLambda.yaml.
Action does the following steps:
- setup scala environment
- setup AWS credentials, requires a role with a github identity provider trust setup
- execute one of two possible scripts depending on the runtime choice, either
- build deployment package, either
function.zip
orassembly.jar
- deploy the package with the help of deployLambda.sc script.
Custom runtime implements additionally RequestStreamHandler
interface from AWS Lambda SDK to make it possible to deploy packaged fatjar using a standard java21
runtime, without graalvm precompilation.
Custom runtime provides built-in support for making your logging experience both simple and modern. All the system output produced during the invocation of your function can be captured and nicely formatted in a CloudWatch friendly JSON format. Each invocation of the function will result in only three log entries:
REQUEST
entry consists of an input request
field and the lambda execution metadata where id
is a simple counter of same-environment invocations.
{
"log": "REQUEST",
"lambda": "ExampleLambda",
"id": 4,
"request": "\"Scalar 2025\"",
"lambdaVersion": "$LATEST",
"lambdaRequestId": "ba2fdf26-c84e-46fb-8ece-7568362ffd83",
"timestamp": "1743000856266",
"datetime": "2025-03-26T14:54:16.266789Z[UTC]",
"maxMemory": 67108864,
"totalMemory": 67108864,
"freeMemory": 64487424
}
LOGS
entry contains an array of all system ouput lines produced during single function invocation:
{
"log": "LOGS",
"lambda": "ExampleLambda",
"id": 4,
"logs": [
"+000000: Sending greeting: Hello \"Scalar 2025\"!",
"+000027: How are you doing today?"
],
"lambdaVersion": "$LATEST",
"lambdaRequestId": "ba2fdf26-c84e-46fb-8ece-7568362ffd83"
}
RESPONSE
entry consists of an output response
field, optionally repeated request
field, lambda execution metadata and embeded metrics (e.g. duration).
{
"log": "RESPONSE",
"lambda": "ExampleLambda",
"id": 4,
"request": "\"Scalar 2025\"",
"response": {
"message": "Hello \"Scalar 2025\"!"
},
"lambdaVersion": "$LATEST",
"lambdaRequestId": "ba2fdf26-c84e-46fb-8ece-7568362ffd83",
"timestamp": "1743000856304",
"datetime": "2025-03-26T14:54:16.304103Z[UTC]",
"duration": 38,
"maxMemory": 67108864,
"totalMemory": 67108864,
"freeMemory": 64487424,
"_aws": {
"Timestamp": 1743000856304,
"CloudWatchMetrics": [
{
"Namespace": "lambda-ExampleLambda-metrics",
"Dimensions": [
[
"lambdaVersion"
]
],
"Metrics": [
{
"Name": "duration",
"Unit": "Milliseconds",
"StorageResolution": 60
}
]
}
]
}
}
Logging support is configured via environment variables:
key | values | description |
---|---|---|
LAMBDA_RUNTIME_DEBUG_MODE | ON or OFF |
enables logging of request, response and invocation log |
LAMBDA_RUNTIME_TRACE_MODE | ON or OFF |
enables logging of runtime internal events |
ANSI_COLORS_MODE | ON or OFF |
whether to filter out or not ansi color sequences |
LAMBDA_RUNTIME_LOG_TYPE | STRUCTURED or PLAIN |
whether to output log events as JSON or plain text |
LAMBDA_RUNTIME_LOG_FORMAT | JSON_ARRAY or JSON_STRING |
whether to combine log events between request and response as an array of strings or a single string. |
LAMBDA_RUNTIME_LOG_RESPONSE_INCLUDE_REQUEST | ON or OFF |
when ON request input will be logged two times, first as a REQUEST event, then again repeated in a RESPONSE event in order to facilitate CloudWatch query filtering on both input and output fields at the same time. |
Custom runtime supports unit testing out-of the box via dedicated method, reducing the need for an HTTP server-client setup:
def test(input: String, overrides: Map[String, String] = Map.empty): String
where overrides
is a map of environment variables overrides.
Unit testing your function can be as easy as writing:
val output = myFunction().test(input = "Hello!")
In case you want to invoke your function manually in a local environment, it is possible to start your function via a simple AWS Lambda execution environment simulator implemented in scala-aws-lambda-local-host.
Run:
scala run --dependency=org.encalmo::scala-aws-lambda-local-host:0.9.1 \
--main-class org.encalmo.lambda.host.LocalLambdaHost \
-- \
--mode=browser \
--lambda-script="scala run --main-class org.encalmo.lambda.example.ExampleLambda2 ." \
--lambda-name=ExampleLambda
where:
lambda-script
is a command to start your function.mode
can be eitherbrowser
orterminal
See an example lambda implemented in TestEchoLambda.
See: https://github.com/encalmo/scala-aws-lambda-example/blob/main/ExampleLambda0.scala
import org.encalmo.lambda.{LambdaContext, SimpleLambdaRuntime}
import scala.annotation.static
object ExampleLambda0 {
/* Custom runtime entry point */
@static def main(args: Array[String]): Unit = new ExampleLambda0().run()
}
class ExampleLambda0 extends SimpleLambdaRuntime {
/* Here comes the real job of processing input event and rendering some output. */
override def handleRequest(input: String)(using LambdaContext, ApplicationContext): String = {
input
}
}
See: https://github.com/encalmo/scala-aws-lambda-example/blob/main/ExampleLambda1.scala
import org.encalmo.lambda.{LambdaContext,LambdaEnvironment,LambdaRuntime}
import scala.annotation.static
import org.encalmo.utils.JsonUtils.*
object ExampleLambda1 {
/* Custom runtime entry point */
@static def main(args: Array[String]): Unit = new ExampleLambda1().run()
case class Config(greeting: String) derives upickle.default.ReadWriter
case class Response(message: String) derives upickle.default.ReadWriter
}
class ExampleLambda1 extends LambdaRuntime {
import ExampleLambda1.*
/* Config is our application context initialized once. */
type ApplicationContext = Config
/* Here we build our config instance by reading lambda environment variable defining greeting template. */
override def initialize(using environment: LambdaEnvironment): Config = {
val greeting = environment
.maybeGetProperty("LAMBDA_GREETING")
.getOrElse("Hello <input>!")
environment.info(
s"Initializing ${environment.getFunctionName()} with a greeting $greeting"
)
Config(greeting)
}
/* Here comes the real job of processing input event and rendering some output. */
override inline def handleRequest(input: String)(using lambdaContext: LambdaContext, config: Config): String = {
val response = Response(config.greeting.replace("<input>", input))
response.writeAsString
}
}
See: https://github.com/encalmo/scala-aws-lambda-example/blob/main/ExampleLambda2.scala
This example is similar to previous one with exception of ApplicationContent defined as a named tuple of two contextual objects: onfig and awsClient.
The greeting template comes not from lambda environment variables directly but from AWS SecretsManager resource(s) defined in an environment variable named ENVIRONMENT_SECRETS
.
Lambda constructor takes an optional AwsClient instance to allow testing using AwsClient's stubs.
AwsClient class comes from scala-aws-client and provides connectivity to the AWS services.
Secondary lambda class constructor is required if alternative deployment using java21
runtime is required.
class ExampleLambda2(maybeAwsClient: Option[AwsClient]) extends LambdaRuntime {
// required for java runtime handler example
final def this() = this(None)
import ExampleLambda2.*
type ApplicationContext = (config: Config, awsClient: AwsClient)
override def initialize(using environment: LambdaEnvironment): ApplicationContext = {
val awsClient = maybeAwsClient
.getOrElse(AwsClient.initializeWithProperties(environment.maybeGetProperty))
val secrets = LambdaSecrets.retrieveSecrets(environment.maybeGetProperty)
val greeting = secrets
.get("SECRET_LAMBDA_GREETING")
.getOrElse("Hello <input>!")
environment.info(
s"Initializing ${environment.getFunctionName()} with a greeting $greeting"
)
val config = Config(greeting)
(config, awsClient)
}
override inline def handleRequest(
input: String
)(using lambdaConfig: LambdaContext, context: ApplicationContext): String = {
val greeting = context.config.greeting.replace("<input>", input)
println(s"Sending greeting: $greeting")
Response(greeting).writeAsString
}
}
├── .github
│ └── workflows
│ ├── pages.yaml
│ ├── release.yaml
│ └── test.yaml
│
├── .gitignore
├── .scalafmt.conf
├── AnsiColor.scala
├── EventHandler.scala
├── EventHandlerTag.scala
├── LambdaContext.scala
├── LambdaEnvironment.scala
├── LambdaRuntime.scala
├── LambdaRuntime.test.scala
├── LambdaServiceFixture.test.scala
├── LICENSE
├── Loggers.scala
├── project.scala
├── README.md
├── test.sh
└── TestEchoLambda.scala