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

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

  1. 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

  1. In the dashboard, select the Create Database option.

    Create a new database

  2. Enter a name for the database.

    Name your database

  3. 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.

  1. Select the database you created.

    database list

  2. 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.

  1. Select the Category collection under your database.

  2. Click the Schema tab to view the collection’s schema.

    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:

  1. Use fauna cloud-login to log in to Fauna:

    fauna cloud-login
  2. 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, use cloud-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.

  1. Run the following command in your terminal:

    fauna shell <your-database-name>
  2. You can now run FQL queries directly from your terminal. Run the following command to list all the collections in the database:

    Collection.all()
  3. Run the following command to create a new document in the Category collection:

    Category.create({name: "Electronics", description: "Electronic gadgets"})

Set up the project

  1. 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>
  2. Install the dependencies:

    npm install

Configure Fauna

  1. Log in to Fauna using the Fauna CLI:

    fauna cloud-login
  2. When prompted, enter your email address and password.

  3. Use the Fauna CLI to create a new database:

    fauna create-database --environment='' <database-name>
  4. Create a new key for the database. You can use the server role for this key:

    fauna create-key --role='server' <database-name>
  5. 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.

Indexes

In Fauna, you use indexes for quick and efficient queries.

Indexes are used to query documents based on specific fields. In the Category collection, we have an index named byName() that indexes the name field.

collection Category {
  // ... rest of the schema
  index byName {
    terms [.name]
  }
}

Configure Fauna Project

  1. Run the following command inside your project directory to configure a new project:

    fauna project init
  2. When prompted, enter the project name and select the database you created earlier.

  3. Publish the schema to the database:

    fauna schema push
  4. When prompted, accept and stage the schema.

  5. Check the status of the staged schema:

    fauna schema status
  6. 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

  1. Open the Fauna shell in your terminal.

    fauna shell --database=<database-name>
  2. Type .editor and press Enter to open the editor mode in the shell.

  3. Run the following query to get all the documents in the Category collection:

    Category.all() {
      name,
      description
    }
  4. Note that you can define the fields you want to return in the query. In this case, we are returning the name and description fields for each document in the Category collection.

  5. 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.

Update Documents

Update the stock field of a document in the Product collection:

let product = Product.byName("Laptop").first()
product!.update({stock: 50})

Delete Documents

Delete a document in the Product collection:

let product = Product.byName("Laptop").first()
product!.delete()

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 many Products.

  • A Product belongs to one Category.

  • A Customer can have many Orders.

  • An Order can have many OrderItems.

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!