Building an application often means dealing with servers – setting them up, keeping them running, and making sure they can handle traffic spikes. That can be quite problematic in many cases. This is where the AWS serverless stack can help the developers. It lets you create powerful backends for your applications without managing any servers. This combination of AWS services - API Gateway, Lambda and DynamoDB - allows you to build robust APIs that handle CRUD (Create, Read, Update, Delete) operations effortlessly.
In this article I'll walk through the process of creating a three-tier serverless application:
- Data Tier: DynamoDB stores and retrieves data.
- Logic Tier: Lambda functions process business logic and CRUD operations.
- Presentation Tier: API Gateway handles HTTP requests and responses for REST API.
Though here I'll cover each of these tiers, but I will keep more focus on providing detailed insights into the Presentation Tier.
For a detailed insight into Data Tier implementation, I suggest reading this article.
You may download complete source code from my github repository,
Data Tier
First, I'll create the data tier of our application by automating the creation of a DynamoDB table using a CloudFormation template. In many software environments, multiple applications and services share same database table. Deploying and undeploying these tables alongside other resources can disrupt dependent services. Although not a standard practice, but I prefer creating the database table in a separate CloudFormation template rather than in the main application template that deploys resources like Lambdas, SNS, and SQS.
Data Tier - Cloudformation Template
Find the full cloudformation template file here.
In the Data tier CloudFormation template, I create a DynamoDB table with a single attribute, id
, as the partition key. The read and write capacity is set to 5 per second in ProvisionedThroughput
, but you can adjust these values as needed. The template outputs the table’s Name (DynamoTableName
) and ARN (DynamoARN
), which we will use in logic tier cloudformation template.
We deploy the data tier resources by following AWS CLI command,
aws cloudformation deploy \
--template-file ./cf-dynamo.json \
--stack-name my-demo-dynamo-stack \
--capabilities CAPABILITY_IAM \
--parameter-overrides pTableName="my-demo-dynamo-table" \
--region <aws-region>
Note: I pass the table name (pTableName
) as a parameter to cloudformation. Also, replace <aws-region>by your aws region name, such as eu-west-1 etc
Cloudformation stack is successfully deployed and created dynamoDB table, my-demo-dynamo-table
Data Tier - Implementation
As we have deployed a dynamoDB table, now we write the data tier code in Go language. I create an DBHandler
interface in a new file dbHandler.go
, which expose CRUD methods as,
package dynamo_db
import "github.com/azam-akram/aws-apigateway-lambda-demo-go/model"
type DBHandler interface {
Save(book *model.MyBook) error
Update(book *model.MyBook) error
UpdateAttributeByID(id, key, value string) error
GetByID(id string) (*model.MyBook, error)
DeleteByID(id string) error
}
DBHandler
exposes required CRUD methods to Create (save()
), Read (GetByID()
), Update (UpdateAttributeByID()
) and Delete (DeleteByID()
)
Let's add their implementations in a seprate file dynamoHandler.go
,
package dynamo_db
import (
"log"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/dynamodb"
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/azam-akram/aws-apigateway-lambda-demo-go/model"
)
var handler DBHandler
type DynamoHandler struct {
tableName string
dynamoDBAPI dynamodbiface.DynamoDBAPI
}
func NewDynamoHandler(tName string) DBHandler {
if handler == nil {
handler = &DynamoHandler{
tableName: tName,
dynamoDBAPI: getDynamoInterface(),
}
}
return handler
}
func getDynamoInterface() dynamodbiface.DynamoDBAPI {
dynamoSession := session.Must(session.NewSessionWithOptions(session.Options{
SharedConfigState: session.SharedConfigEnable,
}))
dynamoInstance := dynamodb.New(dynamoSession)
return dynamodbiface.DynamoDBAPI(dynamoInstance)
}
func convertToDBRecord(book *model.MyBook) map[string]*dynamodb.AttributeValue {
item := map[string]*dynamodb.AttributeValue{
"id": {S: &book.ID},
"title": {S: &book.Title},
"author": {S: &book.Author},
}
return item
}
func (h *DynamoHandler) Save(book *model.MyBook) error {
input := &dynamodb.PutItemInput{
Item: convertToDBRecord(book),
TableName: aws.String(h.tableName),
}
savedItem, err := h.dynamoDBAPI.PutItem(input)
if err != nil {
log.Fatal("Failed to save Item: ", err.Error())
return err
}
log.Println("Item saved in db: ", savedItem)
return nil
}
func (h *DynamoHandler) Update(book *model.MyBook) error {
item, err := dynamodbattribute.MarshalMap(book)
if err != nil {
return err
}
input := &dynamodb.PutItemInput{
Item: item,
TableName: aws.String(h.tableName),
}
updatedItem, err := h.dynamoDBAPI.PutItem(input)
if err != nil {
return err
}
log.Println("Item updated in db: ", updatedItem)
return nil
}
func (h *DynamoHandler) UpdateAttributeByID(id, key, value string) error {
input := dynamodb.UpdateItemInput{
ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
":val": {
S: aws.String(value),
},
},
Key: map[string]*dynamodb.AttributeValue{
"id": {
S: aws.String(id),
},
},
TableName: aws.String(h.tableName),
UpdateExpression: aws.String("set " + key + " = :val"),
}
output, err := h.dynamoDBAPI.UpdateItem(&input)
if err != nil {
return err
}
log.Println("Item updated in db: ", output)
return nil
}
func (h *DynamoHandler) GetByID(id string) (*model.MyBook, error) {
input := &dynamodb.GetItemInput{
TableName: aws.String(h.tableName),
Key: map[string]*dynamodb.AttributeValue{
"id": {S: aws.String(id)},
},
}
item, err := h.dynamoDBAPI.GetItem(input)
if err != nil {
return nil, err
}
if item.Item == nil {
log.Fatal("Can't get item by id = ", id)
return nil, nil
}
var book model.MyBook
err = dynamodbattribute.UnmarshalMap(item.Item, &book)
if err != nil {
return nil, err
}
return &book, nil
}
func (h *DynamoHandler) DeleteByID(id string) error {
input := &dynamodb.DeleteItemInput{
TableName: aws.String(h.tableName),
Key: map[string]*dynamodb.AttributeValue{
"id": {S: aws.String(id)},
},
}
_, err := h.dynamoDBAPI.DeleteItem(input)
if err != nil {
log.Fatal("Can't delete item by id = ", id)
return err
}
return nil
}
NewDynamoHandler: Creates a new DynamoDB handler with the given table name if one does not already exist.
getDynamoInterface: Initializes and returns a new DynamoDB API session interface.
convertToDBRecord: Converts a book model into a DynamoDB-compatible record format.
Save: Saves a new book record to the DynamoDB table.
Update: Updates an existing book record in the DynamoDB table.
UpdateAttributeByID: Updates a specific attribute of a book identified by its ID.
GetByID: Retrieves a book record from the DynamoDB table by its ID.
DeleteByID: Deletes a book record from the DynamoDB table by its ID.
Logic Tier
In this section, we will develop the Logic Tier by writing the Lambda function code to handle HTTP requests for the CRUD API. This layer acts as an intermediary between the presentation and data tiers, managing the application’s business logic and ensuring smooth communication between the user interface and the database.
We begin by creating a Go module using go mod init
, which generates a go.mod
file in the project directory as follows:
module github.com/azam-akram/aws-apigateway-lambda-demo-go
go 1.22
require (
github.com/aws/aws-lambda-go v1.47.0
github.com/aws/aws-sdk-go v1.51.21
)
require github.com/jmespath/go-jmespath v0.4.0 // indirect
Next, in the main.go
file, we will define how our Lambda function handles Create, Read, Update, and Delete (CRUD) operations. This will ensure that our API is fully functional and can interact with the data tier we set up earlier. Add the following code to the main.go
file:
package main
import (
"context"
"encoding/json"
"net/http"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
"github.com/azam-akram/aws-apigateway-lambda-demo-go/dynamo_db"
"github.com/azam-akram/aws-apigateway-lambda-demo-go/model"
)
func HandleRequest(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
dynamoHandler := dynamo_db.NewDynamoHandler("my-demo-dynamo-table")
switch req.HTTPMethod {
case "POST":
var book model.MyBook
if err := json.Unmarshal([]byte(req.Body), &book); err != nil {
return events.APIGatewayProxyResponse{StatusCode: http.StatusBadRequest}, err
}
if err := dynamoHandler.Save(&book); err != nil {
return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err
}
return events.APIGatewayProxyResponse{StatusCode: http.StatusOK, Body: req.Body}, nil
case "GET":
id := req.QueryStringParameters["id"]
book, err := dynamoHandler.GetByID(id)
if err != nil {
return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err
}
if book == nil {
return events.APIGatewayProxyResponse{StatusCode: http.StatusNotFound}, nil
}
body, err := json.Marshal(book)
if err != nil {
return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err
}
return events.APIGatewayProxyResponse{StatusCode: http.StatusOK, Body: string(body)}, nil
case "PUT":
var book model.MyBook
if err := json.Unmarshal([]byte(req.Body), &book); err != nil {
return events.APIGatewayProxyResponse{StatusCode: http.StatusBadRequest}, err
}
if err := dynamoHandler.Update(&book); err != nil {
return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err
}
return events.APIGatewayProxyResponse{StatusCode: http.StatusOK, Body: req.Body}, nil
case "DELETE":
id := req.QueryStringParameters["id"]
if err := dynamoHandler.DeleteByID(id); err != nil {
return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err
}
return events.APIGatewayProxyResponse{StatusCode: http.StatusOK}, nil
default:
return events.APIGatewayProxyResponse{StatusCode: http.StatusMethodNotAllowed}, nil
}
}
func main() {
lambda.Start(HandleRequest)
}
We also need to define a data structure, MyBook
struct to unmarshal json payload in HTTP request, let's create a new file `model.go`,
package model
type MyBook struct {
ID string `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Author string `json:"author,omitempty"`
}
Resolve the dependencies by running,
go mod tidy
go mod vendor
Build Go Project
Set environment variable,
set GOOS=linux GOARCH=amd64 CGO_ENABLED=0
Setting GOOS to Linux ensures that the compiled executable is compatible with the Go runtime, even if you compile it in a non-Linux environment[1]. Now build the application,
GOOS=linux go build main.go
Create Lambda Function Archive
To deploy the Lambda function, we will first package the function’s code into a zip file and upload it to an S3 bucket. This enables the CloudFormation stack to retrieve and deploy the function from the specified S3 location.
We install build-lambda-zip
to create an archive file,
go install github.com/aws/aws-lambda-go/cmd/build-lambda-zip@latest
Add path (%USERPROFILE%\Go\bin
) to build-lambda-zip in GOPATH
.
Create a .zip file to include an executable,
build-lambda-zip.exe -o aws-lambda-demo-go.zip main
import aws-lambda-demo-go.zip
file to S3 bucket,
aws s3 cp aws-api-lambda-dynamo-go.zip s3://<<you-bucket-name>>/demo-lambda/aws-api-lambda-dynamo-go.zip
You can read detailed steps in thie article.
Presentation Tier
With the Data and Logic tiers now completed, we are ready to move on to the Presentation tier. This involves creating and deploying an API Gateway that will expose our CRUD API methods: POST, GET, PUT, and DELETE. The API Gateway will serve as the entry point for client requests, routing them to the our Lambda functions that handle the underlying business logic and data interactions. By setting up the API Gateway, we'll provide a robust interface for clients to interact with our application to retrieve data from DynamoDB.
Logic and Presentation Tiers Cloudformation Template
We will automate the Logic and Prsentation Tiers deployment using same CloudFormation template. In this template, we create all the required resources, including Lambda functions, the roles they assume, and the policies they need. It also deploys an API with various methods like GET, POST, PUT, and DELETE.
Please find the full cloudformation template file here.
Let's dissect the main components of the CloudFormation template file.
- lfnMyDemoLambda: Defines a Lambda function that uses the provided custom runtime and handler, and sets its execution role, timeout, and code location in S3.
- lfnLambdaRole: Creates an IAM Role for the Lambda function, allowing it to assume the role and granting permissions to interact with CloudWatch Logs and DynamoDB.
- ApiGatewayRestApi: Creates a new API Gateway REST API named "MyDemoApi".
- ApiGatewayResourceBooks:Defines a new API Gateway resource at the "/books" path under the created API.
- ApiGatewayMethodBooksPost: Adds a POST method to the "/books" resource, integrated with the Lambda function using AWS_PROXY.
- ApiGatewayMethodBooksGet: Adds a GET method to the "/books" resource, integrated with the Lambda function using AWS_PROXY.
- ApiGatewayMethodBooksPut: Adds a PUT method to the "/books" resource, integrated with the Lambda function using AWS_PROXY.
- ApiGatewayMethodBooksDelete: Adds a DELETE method to the "/books" resource, integrated with the Lambda function using AWS_PROXY.
- ApiGatewayDeployment: Deploys the API Gateway with all the defined methods to the "prod" stage.
- LambdaInvokePermission: Grants API Gateway permission to invoke the Lambda function.
- Outputs.ApiUrl: Provides the endpoint URL for the deployed API in the "prod" stage.
We are now ready to deploy the required resources using a CloudFormation stack. You can create the stack by running the following AWS CLI command:
aws cloudformation deploy \
--region <<your_region_name>> \
--template-file ./deploy/cf.json \
--stack-name apigateway-lambda-dynamo-stack \
--capabilities CAPABILITY_IAM \
--parameter-overrides \
pLambdaCodeBucket=<<your_s3_bucket_name>> \
pLambdaCodeS3KeyPath=demo-lambda/aws-api-lambda-dynamo-go.zip \
pDynamoARN=<<your_dynamo_table_arn>> \
pApiName=my-bookstore-api \
pStageName=v1
Note: replace <<region-name
>> by your region name, <<your_s3_bucket_name
>> by your s3 bucket name and <<your_dynamo_table_arn
>> by your dynamo table ARN
Test
With API Gateway, Lambda function, and DynamoDB deployed, we're now ready to test our API. Let's proceed by sending individual POST, GET, PUT, and DELETE HTTP requests.
POST:
curl -X POST https://<<api-url>>/prod/books \
-H "Content-Type: application/json" \
-d '{
"ID": "1",
"Title": "My Great Go book",
"Author": "Azam Akram"
}'
GET:
curl -X GET "https://<<api-url>>/prod/books?id=1"
PUT:
curl -X PUT https://<<api-url>>/prod/books \
-H "Content-Type: application/json" \
-d '{
"ID": "1",
"Title": "My Good Go book",
"Author": "Azam Akram"
}'
DELETE:
curl -X DELETE "https://<<api-url>>/prod/books?id=1"
You can see the response of each request here,
and see cloudwatch logs,
Conclusion
We have successfully built a serverless application using AWS services: API Gateway, Lambda, and DynamoDB. By implementing a three-tier architecture - where API Gateway serves as the presentation layer, Lambda functions handle the business logic in the logic tier, and DynamoDB stores data in the data tier. Leveraging CloudFormation for infrastructure management ensured consistency and repeatability in deployment.