Workshop: Build a serverless application with AWS Lambda and Fauna
In this workshop we’ll explore Fauna: what it is, how to use it, and the problems it can solve. We’ll demonstrate these through a sample app, covering Fauna’s key features and benefits.
What is Fauna?
Fauna is a serverless, document-relational database for modern apps. It combines the flexibility of document storage with relational features, like strong consistency and joins. Fauna is truly serverless, eliminating the need for provisioning, scaling, or managing infrastructure.
Why use Fauna?
Fauna is ideal for serverless environments like AWS Lambda. Fauna Query Language (FQL) is powerful and easy to learn, enabling developers to write clear queries and handle complex transactions.
Core Technology: Distributed Transaction Engine (DTE)
In Fauna, every query is a transaction, ensuring ACID compliance across all operations, even in globally distributed Region Groups. It synchronizes read/write operations across multiple regions, making Fauna a reliable choice for distributed systems and compliance needs.
Security and multi-tenancy
Fauna supports both role-based access control (RBAC) and attribute-based access control (ABAC), allowing for fine-tuned permissions based on real-time context. Fauna also integrates with third-party identity providers, such as AWS Cognito and Auth0.
Fauna’s hierarchical database model makes it easy to create isolated databases for multi-tenant applications. Each Fauna database can have many child databases. You can use child databases as tenants for your application. All databases, including child databases, are instantly allocated without provisioning or warmup, and each database is logically isolated from its peers with separate access controls.
You can learn more about Fauna’s features and benefits in the Why Fauna article.
Prerequisites
-
Node.js v20.x or later
-
An AWS account
-
Some familiarity with AWS Lambda functions and the AWS CDK
Learning Objectives
-
Basics of Fauna
-
How to create a serverless application with AWS Lambda and Fauna
-
How to use the Fauna JavaScript driver in a serverless application
-
How to deploy a serverless application with the AWS CDK
Fauna basics
-
Log in to the Fauna Dashboard. You can sign up for a free account at https://dashboard.fauna.com/register.
Create a new database using the Fauna dashboard
-
In the dashboard, select the Create Database option.
-
Enter a name for the database.
-
Select a Region Group for the database and click Create.
Region Groups give you control over where your data resides. Region groups make it possible to comply with data locality legislation. Fauna ensures that data is strongly consistent across all replicas in a Region Group.
Region Groups solve most of the challenges related to building and scaling a database. For example, you don’t need to handle sharding, replication, data resiliency, or failover management.
Create a collection
In Fauna, you store data as JSON-like documents in collections. Each collection can have its own configuration, indexes, and schema.
-
Select the database you created.
-
Select the Shell tab and run the following Fauna Query Language (FQL) query to create a new collection:
Collection.create({ name: "Category" })
{ name: "Category", coll: Collection, ts: Time("2024-11-03T19:06:12.810Z"), history_days: 0, indexes: {}, constraints: [] }
You can also create collections using the Dashboard UI. |
The query above creates the Category
collection.
We’ll explore FQL in more detail in the FQL basics section of the workshop.
Run a query
Now that you have a collection, run a query to list all collections in the database:
Collection.all()
{
data: [
{
name: "Category",
coll: Collection,
ts: Time("2024-11-03T19:06:12.810Z"),
history_days: 0,
indexes: {},
constraints: []
}
]
}
Fauna schema
You can explore your database schema from the dashboard. Fauna supports schema for collections, functions, roles, and more.
-
Select the
Category
collection under your database. -
Click the Schema tab to view the collection’s schema.
The schema is written in ^FSL (Fauna Schema Language).
You can use FSL to define your database schema and enforce constraints. We will explore FSL in more detail later in the workshop.
Fauna CLI
The Fauna CLI is a command-line tool that lets you interact with Fauna from your terminal. You can use it to manage databases, schema, and more.
Connect to your database using the Fauna CLI:
-
Use
fauna cloud-login
to log in to Fauna:fauna cloud-login
-
When prompted, enter:
-
Endpoint name:
cloud
(Press Enter)An endpoint defines the settings the CLI uses to run API requests against a Fauna account or database. See Endpoints.
-
Email address: The email address for your Fauna account.
-
Password: The password for your Fauna account.
-
Which endpoint would you like to set as default? The
cloud-*
endpoint for your preferred Region Group. For example, to use the US Region Group, usecloud-us
.
-
fauna cloud-login
requires an email and password login.
If you log in to Fauna using GitHub or Netlify, you can enable email and
password login using the Forgot
Password workflow.
-
Run the following command in your terminal:
fauna shell <your-database-name>
-
You can now run FQL queries directly from your terminal. Run the following command to list all the collections in the database:
Collection.all()
-
Run the following command to create a new document in the
Category
collection:Category.create({name: "Electronics", description: "Electronic gadgets"})
Set up the project
-
A sample project is provided to help you get started. Clone the repository from GitHub:
git clone https://github.com/fauna-labs/faunaproject cd <project-name>
-
Install the dependencies:
npm install
Configure Fauna
-
Log in to Fauna using the Fauna CLI:
fauna cloud-login
-
When prompted, enter your email address and password.
-
Use the Fauna CLI to create a new database:
fauna create-database --environment='' <database-name>
-
Create a new key for the database. You can use the
server
role for this key:fauna create-key --role='server' <database-name>
-
Copy the key generated. You’ll need it to connect to the database.
Connect to Fauna
You can connect to Fauna using the Fauna JavaScript driver.
The following code snippet demonstrates how to connect to Fauna using the driver and run a query:
import { Client, fql, FaunaError } from "fauna";
const client = new Client({
secret: <YOUR_FAUNA_SECRET>,
});
try {
// Build a query using the `fql` method
const collectionQuery = fql`Collection.all()`;
// Run the query
const response = await client.query(collectionQuery);
// Run the query
const response = await client.query(documentQuery);
console.log(response);
} catch (error) {
if (error instanceof FaunaError) {
console.log(error);
}
} finally {
// Clean up any remaining resources
client.close();
}
Replace <YOUR_FAUNA_SECRET>
with the key secret you generated earlier.
In the sample code, you are provided with a function in
lambda/fauna-client.ts
file that connects and runs a query.
Fauna schema files
In the starter code, you’ll find a schema
folder. This folder contains the FSL
files that define the database schema. These schema files are used to create
collections and functions in Fauna. You can think of them as infrastructure as
code for your database. Let’s explore the schema
folder:
schema/
├── Category.fsl
├── Customer.fsl
├── Order.fsl
├── OrderItem.fsl
├── Product.fsl
└── functions.fsl
The schema folder contains .fsl
files. Each file defines a collection or
functions. For example, the Category.fsl
file defines the Category
collection. Let’s explore at the Category.fsl
file:
collection Category {
name: String
description: String
values: { *: Any}!
*: Any
compute products: Set<Product> = (category => Product.byCategory(category))
unique [.name]
index byName {
terms [.name]
}
}
In the following sections, we’ll break down the schema definitions.
Field definitions
Field definitions are like columns in a table. In the Category
collection, we
have two fields: name
and description
. Both are of type String
.
Schemaless document type
Collections with a schemaless or permissive document type allow ad hoc fields in collection documents. This gives you the flexibility to store any type of data in the collection. You can use this flexibility to rapidly iterate on your data model as your application evolves.
The *: Any
wildcard definition allows any field in Category
documents. This
is useful when you want to store data that doesn’t fit a strict schema.
Unique constraint
Unique constraints ensure a field value or a combination of field values is unique for each document in a collection. Fauna rejects write operations that don’t meet the constraint.
The name
field is unique in the Category
collection. This means that no two
documents in the Category
collection can have the same name.
You can find another example of a unique constraint in the Product
collection
schema:
collection Products {
// ... rest of the schema
// In this example, the `name`, `description`, and `price`
// fields must be unique for each
// document in the `Product` collection.
unique [.name, .description, .price]
}
Check constraints
Check constraints ensure document field values meet a pre-defined rule. For
example, only allow writes to the Product
collection if the value of the
product’s stock
field is greater than zero. Inside the parenthesis of the
constraint is an FQL predicate that evaluates to true
, false
, or null
(equivalent to false
). The predicate can query collections, use functions,
etc.
// Product.fsl
collection Product {
// .... rest of the schema
check stockIsValid (product => product.stock >= 0)
}
Computed fields
Computed fields are derived field values. They let you create new fields based on existing data and calculations that are computed when the document is read.
The compute
keyword is used to define computed fields. In the Category
collection, the products
field is a computed field. It returns a set of
products that belong to the category.
collection Category {
// ... rest of the schema
compute products: Set<Product> = (category => Product.byCategory(category))
}
We will discuss computed fields in more detail in the data relationships section.
Configure Fauna Project
-
Run the following command inside your project directory to configure a new project:
fauna project init
-
When prompted, enter the project name and select the database you created earlier.
-
Publish the schema to the database:
fauna schema push
-
When prompted, accept and stage the schema.
-
Check the status of the staged schema:
fauna schema status
-
When the status is
ready
, commit the staged schema to the database:fauna schema commit
The above command will push the schema files to the database and create the collections and functions defined in the schema.
Fauna Query Language (FQL) basics
FQL is Fauna’s native query language. In this section, we’ll briefly cover the basics of FQL. Connect to your Fauna database using the Fauna shell and run the following queries:
Create Documents
Create a new document in the Product
collection:
Product.create({
name: "Laptop",
description: "A high-performance laptop",
price: 1000,
stock: 100,
category: Category.byName("Electronics").first()
})
Read Documents
-
Open the Fauna shell in your terminal.
fauna shell --database=<database-name>
-
Type .editor and press Enter to open the editor mode in the shell.
-
Run the following query to get all the documents in the
Category
collection:Category.all() { name, description }
-
Note that you can define the fields you want to return in the query. In this case, we are returning the
name
anddescription
fields for each document in theCategory
collection. -
You can also retrieve a single document by its id and set it to a variable:
let cat = Category.byId("CATEGORY_DOCUMENT_ID") cat
Query Documents by Index
For efficient querying, you can use indexes in Fauna. The following query
retrieves documents from the Category
collection using the byName
index:
Category.byName("Electronics")
Learn more about Index.
Data Relationships
Fauna gives you the flexibility of a document database with the power of relational databases. You can define relationships such as one-to-one, one-to-many, and many-to-many between documents.
In the sample project, we have the following relationships between collections:
-
A
Category
can have manyProducts
. -
A
Product
belongs to oneCategory
. -
A
Customer
can have manyOrders
. -
An
Order
can have manyOrderItems
.
These relationships are defined in the schema files. Let’s explore the
Product.fsl
file:
collection Product {
// ... rest of the schema code
category: Ref<Category>
stock: Int
// ... rest of the schema code
}
In the Product
collection, the category
field is a reference to the
Category
collection. This establishes a one-to-many relationship between
Category
and Product
.
The following code snippet demonstrates how to create a new product and associate it with a category:
Product.create({
name: "Laptop",
description: "A high-performance laptop",
price: 1000,
stock: 100,
category: Category.byName("Electronics").first()
})
In the code above, we create a new product named Laptop
and associate it with
the Electronics
category.
To query all products in a category run the following query:
Product.byCategory(Category.byName("Electronics").first())
In the sample application code, you will find well-commented code for various relationships between collections. Learn more about Data Relationships.
Balancing between normalization and denormalization
Balancing normalization and denormalization in Fauna depends on understanding your application’s access patterns, update frequency, and performance requirements. Below is a quick guideline to help make the right choice:
Normalize if:
-
Data is updated frequently and needs consistency.
-
You are working with large or complex datasets.
-
Relationships are dynamic or require transactional guarantees.
Denormalize if:
-
The application is read-heavy, and data is accessed together frequently.
-
The embedded data is relatively static and fits within Fauna’s document size limits.
Explore the sample app
The sample app provided uses AWS Lambda functions as REST APIs to interact with the Fauna database. The app uses Fauna JavaScript driver to connect to the database and perform CRUD operations.
Under the lambda
folder, you’ll find well-documented code for each Lambda
function. The main application logic is in these files. These lambda functions
are triggered by API Gateway endpoints.
Application logic in AWS Lambda functions
Let’s explore the code in lambda/getProducts.ts
file. This Lambda function is
executed when the /products
endpoint is called.
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { faunaClient } from './fauna-client'; // Adjust this import based on your project's structure
import { fql, Page } from 'fauna';
import { Product } from './models/products.model'; // Adjust this import based on your project's structure
export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
const queryParams = event.queryStringParameters || {};
const { category, nextToken = undefined, pageSize = '10' } = queryParams;
const pageSizeNumber = Number(pageSize);
try {
const queryFragment =
category === undefined
? fql`Product.sortedByCategory().pageSize(${pageSizeNumber})`
: fql`Product.byCategory(Category.byName(${category}).first()).pageSize(${pageSizeNumber})`;
const query = fql`
${queryFragment}
.map(product => {
let product: Any = product;
let category: Any = product.category;
{
id: product.id,
name: product.name,
price: product.price,
description: product.description,
stock: product.stock,
category: { id: category.id, name: category.name, description: category.description },
}
})
`;
const { data: page } = await faunaClient.query<Page<Product>>(
nextToken ? fql`Set.paginate(${nextToken})` : query
);
return {
statusCode: 200,
body: JSON.stringify({ results: page.data, nextToken: page.after }),
};
} catch (error: any) {
console.error('Error fetching products:', error);
return {
statusCode: 500,
body: JSON.stringify({ message: 'Internal server error' }),
};
}
};
This code defines an AWS Lambda handler function that fetches a paginated list of products from a Fauna database. It checks for optional query parameters:
-
Category: If provided, only products in that category are fetched; otherwise, all products are retrieved.
-
nextToken
: Used to get the next page of results if there are more than the page size. -
pageSize
: Sets the number of products per page (default is 10).
The queryFragment
builds a Fauna query based on whether a category is
specified. The final query maps each product to include detailed information
(id, name, price, description, stock, and category info). The query result
returns a JSON response with the products and an optional nextToken
for
pagination.
If an error occurs, it logs the error and returns a 500 status with an error message.
Pagination
The Lambda function uses Fauna’s Set.paginate()
function to handle pagination.
The nextToken
query parameter is used to fetch the next page of results. The
pageSize
parameter sets the number of products per page.
Set.paginate()
provides more
information on pagination.
You can explore the code for the other Lambda functions in the lambda
directory. The functions handle creating, updating, and deleting products,
fetching products by price, and managing customer carts.
The code is well-documented and easy to follow.
Infrastructure as code with AWS CDK
The sample project uses AWS CDK to set up the infrastructure for the Lambda
functions and API Gateway. The lib/faunaproject-stack.ts
file contains the CDK
stack definition.
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as dotenv from 'dotenv';
dotenv.config();
export class NewprojectStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Define the getProducts Lambda function
const getProductsLambda = new lambda.Function(this, 'GetProductsFunction', {
functionName: 'GetProducts',
runtime: lambda.Runtime.NODEJS_20_X,
code: lambda.Code.fromAsset('lambda'),
handler: 'getProducts.handler',
environment: {
FAUNA_SECRET: process.env.FAUNA_SECRET || '',
},
});
// Define the createProduct Lambda function
const createProductLambda = new lambda.Function(this, 'CreateProductFunction', {
functionName: 'CreateProduct',
runtime: lambda.Runtime.NODEJS_20_X,
code: lambda.Code.fromAsset('lambda'),
handler: 'createProduct.handler',
environment: {
FAUNA_SECRET: process.env.FAUNA_SECRET || '', // Add necessary environment variables
},
});
// Define the updateProduct Lambda function
const updateProductLambda = new lambda.Function(this, 'UpdateProductFunction', {
functionName: 'UpdateProduct',
runtime: lambda.Runtime.NODEJS_20_X,
code: lambda.Code.fromAsset('lambda'),
handler: 'updateProduct.handler',
environment: {
FAUNA_SECRET: process.env.FAUNA_SECRET || '', // Add necessary environment variables
},
});
// Define the getProductsByPrice Lambda function
const getProductsByPriceLambda = new lambda.Function(this, 'GetProductsByPriceFunction', {
functionName: 'GetProductsByPrice',
runtime: lambda.Runtime.NODEJS_20_X,
code: lambda.Code.fromAsset('lambda'),
handler: 'getProductsByPrice.handler',
environment: {
FAUNA_SECRET: process.env.FAUNA_SECRET || '', // Add necessary environment variables
},
});
// Define the getCustomerCart Lambda function
const getCustomerCartLambda = new lambda.Function(this, 'GetCustomerCartFunction', {
functionName: 'GetCustomerCart',
runtime: lambda.Runtime.NODEJS_20_X,
code: lambda.Code.fromAsset('lambda'),
handler: 'getCustomerCart.handler',
environment: {
FAUNA_SECRET: process.env.FAUNA_SECRET || '',// Add necessary environment variables
},
});
// Define the createOrUpdateCart Lambda function
const createOrUpdateCartLambda = new lambda.Function(this, 'CreateOrUpdateCartFunction', {
functionName: 'CreateOrUpdateCart',
runtime: lambda.Runtime.NODEJS_20_X,
code: lambda.Code.fromAsset('lambda'),
handler: 'createOrUpdateCart.handler',
environment: {
FAUNA_SECRET: process.env.FAUNA_SECRET || '',// Add necessary environment variables
},
});
// Create API Gateway
const api = new apigateway.RestApi(this, 'CrudApi', {
restApiName: 'Fauna Workshop Service',
description: 'This service handles CRUD operations.',
});
// GET /products - Get all products
const products = api.root.addResource('products');
const getProductsIntegration = new apigateway.LambdaIntegration(getProductsLambda);
products.addMethod('GET', getProductsIntegration);
// POST /products - Create a new product
const createProductIntegration = new apigateway.LambdaIntegration(createProductLambda);
products.addMethod('POST', createProductIntegration);
// PATCH /products/{id} route for PATCH
const product = products.addResource('{id}');
const updateProductIntegration = new apigateway.LambdaIntegration(updateProductLambda);
product.addMethod('PATCH', updateProductIntegration);
// GET /products/by-price - Get products by price range
const byPrice = products.addResource('by-price');
const getProductsByPriceIntegration = new apigateway.LambdaIntegration(getProductsByPriceLambda);
byPrice.addMethod('GET', getProductsByPriceIntegration);
// GET /customers/{id}/cart - Get customer's cart
const customers = api.root.addResource('customers');
const customer = customers.addResource('{id}');
const cart = customer.addResource('cart');
const getCustomerCartIntegration = new apigateway.LambdaIntegration(getCustomerCartLambda);
cart.addMethod('GET', getCustomerCartIntegration);
// /customers/{id}/cart route for POST
const createOrUpdateCartIntegration = new apigateway.LambdaIntegration(createOrUpdateCartLambda);
cart.addMethod('POST', createOrUpdateCartIntegration);
/** END */
}
}
This code defines an AWS CDK stack that sets up an API with Lambda functions to handle CRUD operations for products and customer carts in a Fauna database.
Six Lambda functions are created for specific operations:
-
getProductsLambda
: Fetches all products. -
createProductLambda
: Creates a new product. -
updateProductLambda
: Updates an existing product by ID. -
getProductsByPriceLambda
: Gets products within a specified price range. -
getCustomerCartLambda
: Retrieves a customer’s cart by ID. -
createOrUpdateCartLambda
: Adds or updates a customer’s cart.
The code also creates an API Gateway to provide HTTP endpoints for the Lambda functions
-
GET /products
: Retrieves all products. -
POST /products
: Creates a new product. -
PATCH /products/{id}
: Updates a product by ID. -
GET /products/by-price
: Fetches products by price. -
GET /customers/{id}/cart
: Gets a customer’s cart by ID. -
POST /customers/{id}/cart
: Creates or updates a customer’s cart.
It loads environment variables (e.g., FAUNA_SECRET
) from the Lambda functions'
environment variables. Use AWS Secrets Manager or Parameter Store to securely
store and manage secrets for production applications.
Deploy the project
The project uses AWS CDK to deploy the Lambda functions and API Gateway. You can deploy the project using the following command:
cdk deploy
To delete the AWS resources created by the project, run:
cdk destroy
Is this article helpful?
Tell Fauna how the article can be improved:
Visit Fauna's forums
or email docs@fauna.com
Thank you for your feedback!