AWS Compute Blog
Best practices for working with the Apache Velocity Template Language in Amazon API Gateway
This post is written by Ben Freiberg, Senior Solutions Architect, and Markus Ziller, Senior Solutions Architect.
One of the most common serverless patterns are APIs built with Amazon API Gateway and AWS Lambda. This approach is supported by many different frameworks across many languages. However, direct integration with AWS can enable customers to increase the cost-efficiency and resiliency of their serverless architecture. This blog post discusses best practices for using Apache Velocity Templates for direct service integration in API Gateway.
Deciding between integration via Velocity templates and Lambda functions
Many use cases of Velocity templates in API Gateway can also be solved with Lambda. With Lambda, the complexity of integrating with different backends is moved from the Velocity templating language (VTL) to the programming language. This allows customers to use existing frameworks and methodologies from the ecosystem of their preferred programming language.
However, many customers choose serverless on AWS to build lean architectures and using additional services such as Lambda functions can add complexity to your application. There are different considerations that customers can use to assess the trade-offs between the two approaches.
Developer experience
Apache Velocity has a number of operators that can be used when an expression is evaluated, most prominently in #if
and #set
directives. These operators allow you to implement complex transformations and business logic in your Velocity templates.
However, this adds complexity to multiple aspects of the development workflow:
- Testing: Testing Velocity templates is possible but the tools and methodologies are less mature than for traditional programming languages used in Lambda functions.
- Libraries: API Gateway offers utility functions for VTL that simplify common use cases such as data transformation. Other functionality commonly offered by programming language libraries (for example, Python Standard Library) might not be available in your template.
- Logging: It is not possible to log information to Amazon CloudWatch from a Velocity template, so there is no option to retain this information.
- Tracing: API Gateway supports request tracing via AWS X-Ray for native integrations with services such as Amazon DynamoDB.
You should use VTL for data mapping and transformations rather than complex business logic. There are exceptions but the drawbacks of using VTL for other use cases often outweigh the benefits.
API lifecycle
The API lifecycle is an important aspect to consider when deciding on Velocity or Lambda. In early stages, requirements are typically not well defined and can change rapidly while exploring the solution space. This often happens when integrating with databases such as Amazon DynamoDB and finding out the best way to organize data on the persistence layer.
For DynamoDB, this often means changes to attributes, data types, or primary keys. In such cases, it is a sensible decision to start with Lambda. Writing code in a programming language can give developers more leeway and flexibility when incorporating changes. This shortens the feedback loop for changes and can improve the developer experience.
When an API matures and is run in production, changes typically become less frequent and stability increases. At this point, it can make sense to evaluate if the Lambda function can be replaced by moving logic into Velocity templates. Especially for busy APIs, the one-time effort of moving Lambda logic to Velocity templates can pay off in the long run as it removes the cost of Lambda invocations.
Latency
In web applications, a major factor of user perceived performance is the time it takes for a page to load. In modern single page applications, this often means multiple requests to backend APIs. API Gateway offers features to minimize the latency for calls on the API layer. With Lambda for service integration, an additional component is added into the execution flow of the request, which inevitably introduces additional latency.
The degree of that additional latency depends on the specifics of the workload, and often is as low as a few milliseconds.
The following example measures no meaningful difference in latency other than cold starts of the execution environments for a basic CRUD API with a Node.js Lambda function that queries DynamoDB. I observe similar results for Go and Python.
Concurrency and scalability
Concurrency and scalability of an API changes when having an additional Lambda function in the execution path of the request. This is due to different Service Quotas and general scaling behaviors in different services.
For API Gateway, the current default quota is 10,000 requests per second (RPS) with an additional burst capacity provided by the token bucket algorithm, using a maximum bucket capacity of 5,000 requests. API Gateway quotas are independent of Region, while Lambda default concurrency limits depend on the Region.
After the initial burst, your functions’ concurrency can scale by an additional 500 instances each minute. This continues until there are enough instances to serve all requests, or until a concurrency limit is reached. For more details on this topic, refer to Understanding AWS Lambda scaling and throughput.
If your workload experiences sharp spikes of traffic, a direct integration with your persistence layer can lead to a better ability to handle such spikes without throttling user requests. Especially for Regions with an initial burst capacity of 1000 or 500, this can help avoid throttling and provide a more consistent user experience.
Best practices
Organize your project for tooling support
When VTL is used in Infrastructure as Code (IaC) artifacts such as AWS CloudFormation templates, it must be embedded into the IaC document as a string.
This approach has three main disadvantages:
- Especially with multi-line Velocity templates, this leads to IaC definitions that are difficult to read or write.
- Tools such as IDEs or Linters do not work with string representations of Velocity templates.
- The templates cannot be easily used outside of the IaC definition, such as for local testing.
Each aspect impacts developer productivity and make the implementation more prone to errors.
You should decouple the definition of Velocity templates from the definition of IaC templates wherever possible. For the CDK, the implementation requires only a few lines of code.
// The following code is licensed under MIT-0
import { readFileSync } from 'fs';
import * as path from 'path';
const getUserIntegrationWithVTL = new AwsIntegration({
service: 'dynamodb',
integrationHttpMethod: HttpMethods.POST,
action: 'GetItem',
options: {
// Omitted for brevity
requestTemplates: {
'application/json': readFileSync(path.join('path', 'to', 'vtl', 'request.vm'), 'utf8').toString(),
},
integrationResponses: [
{
statusCode: '200',
responseParameters: {
'method.response.header.access-control-allow-origin': "'*'",
},
responseTemplates: {
'application/json': readFileSync(path.join('path', 'to', 'vtl', 'request.vm'), 'utf8').toString(),
},
},
],
},
});
Another advantage of this approach is that it forces you to externalize variables in your templates. When defining Velocity templates inside of IaC documents, it is possible to refer to other resources in the same IaC document and set this value in the Velocity template through string concatenation. However, this hardcodes the value into the template as opposed to the recommended way of using Stage Variables.
Test Velocity templates locally
A frequent challenge that customers face with Velocity templates is how to shorten the feedback loop when implementing a template. A common workflow to test changes to templates is:
- Make changes to the template.
- Deploy the stack.
- Test the API endpoint.
- Evaluate the results or check logs for errors.
- Complete or return to step 1.
Depending on the duration of the stack deployment, this can often lead to feedback loops of several minutes. Although the test ecosystem for Velocity is far from being as extensive as it is for mainstream programming languages, there are still ways to improve the developer experience when writing VTL.
Local Velocity rendering engine with AWS SDK
When API Gateway receives a request that has an AWS integration target, the following things happen:
- Retrieve request context: API Gateway retrieves request parameters and stage variables.
- Make request: body: API Gateway uses the template and variables from 1 to render a JSON document.
- Send request: API Gateway makes an API call to the respective AWS Service. It abstracts Authorization (via it’s IAM Role), Encoding and other aspects of the request away so that only the request body needs to be provided by API Gateway
- Retrieve response: API Gateway retrieves a JSON response from the API call.
- Make response body: If the call was successful the JSON response is used as input to render the response template. The result will then be sent back to the client that initiated the request to the API Gateway
To simplify our developing workflow, you can locally replicate the above flow with the AWS SDK and a Velocity rendering engine of your choice.
I recommend using Node.js for two reasons:
- The
velocityjs
library is a lightweight but powerful Velocity render engine - The client methods (e.g.
dynamoDbClient.query(jsonBody)
) of the AWS SDK for JavaScript generally expect the same JSON body like the AWS REST API does. For most use cases, no transformation (e.g. camel case to Pascal case) is thus needed
The following snippet shows how to test Velocity templates for request and response of a DynamoDB Service Integration. It loads templates from files and renders them with context and parameters.
// The following code is licensed under MIT-0
const fs = require('fs')
const Velocity = require('velocityjs');
const AWS = require('@aws-sdk/client-dynamodb');
const ddb = new AWS.DynamoDB()
const requestTemplate = fs.readFileSync('path/to/vtl/request.vm', 'utf8')
const responseTemplate = fs.readFileSync(''path/to/vtl/response.vm', 'utf8')
async function testDynamoDbIntegration() {
const requestTemplateAsString = Velocity.render(requestTemplate, {
// Mocks the variables provided by API Gateway
context: {
arguments: {
tableName: 'MyTable'
}
},
input: {
params: function() {
return 'someId123'
},
},
});
print(requestTemplateAsString)
const sdkJsonRequestBody = JSON.parse(requestTemplateAsString)
const item = await ddb.query(sdkJsonRequestBody)
const response = Velocity.render(responseTemplate, {
input: {
path: function() {
return {
Items: item.Items
}
},
},
})
const jsonResponse = JSON.parse(response)
}
This approach does not cover all use cases and ultimately must be validated by a deployment of the template. However, it helps to reduce the length of one feedback loop from minutes to a few seconds and allows for faster iterations in the development of Velocity templates.
Conclusion
This blog post discusses considerations and best practices for working with Velocity Templates in API Gateway. Developer experience, latency, API lifecycle, cost, and scalability are key factors when choosing between Lambda and VTL. For most use cases, we recommend Lambda as a starting point and VTL as an optimization step.
Setting up a local test environment for VTL helps shorten the feedback loop significantly and increase developer productivity. The AWS CDK is the recommended IaC framework for working with VTL projects, since it enables you to efficiently organize your infrastructure as code project for tooling support.
For more serverless learning resources, visit Serverless Land.