A fully serverless REST API built on AWS that performs Create, Read, Update, and Delete (CRUD) operations on a DynamoDB database. The project uses a Coffee Shop Menu as the data model — each item in the database represents a coffee product with an ID, name, price, and availability status.
#server Architure
Client / Browser
│
▼
API Gateway ← Receives HTTP requests, routes them to the right Lambda
│
▼
Lambda Functions ← Runs your business logic (Node.js / ES Modules)
│
▼
DynamoDB ← NoSQL database that stores the coffee items
All infrastructure is serverless — there are no servers to manage. AWS spins up the Lambda functions on demand and shuts them down when done. You only pay for what you use.
| Service | Purpose |
|---|---|
| AWS Lambda | Runs the backend code |
| AWS API Gateway | Exposes HTTP endpoints (GET, POST, PUT, DELETE) |
| AWS DynamoDB | NoSQL database to store data |
| AWS Lambda Layers | Shared code/dependencies across all Lambda functions |
| AWS CloudFront + S3 | Hosts and serves the frontend (optional) |
| AWS IAM | Controls permissions — what Lambda is allowed to access |
| Node.js (ESM) | Runtime language for Lambda functions |
| @aws-sdk/lib-dynamodb | AWS SDK — high-level DynamoDB Document Client |
AWS-CRUD-Serverless/
│
├── Lambda Functions/ # Core backend — 4 Lambda functions
│ ├── post/
│ │ └── index.mjs # CREATE a coffee item
│ ├── get/
│ │ └── index.mjs # READ one or all coffee items
│ ├── update/
│ │ └── index.mjs # UPDATE an existing coffee item
│ └── delete/
│ └── index.mjs # DELETE a coffee item
│
├── Layers/ # Shared Lambda Layer
│ ├── nodejs/
│ │ ├── utils.mjs # Shared DynamoDB client + helper functions
│ │ └── package.json # Layer dependencies (@aws-sdk)
│ ├── LambdaFunctionsWithLayer/ # Lambda versions that use the Layer
│ │ ├── post/index.mjs
│ │ ├── get/index.mjs
│ │ ├── update/index.mjs
│ │ └── delete/index.mjs
│ └── create_zip.sh # Script to zip functions for AWS upload
│
├── policy/
│ ├── Lambda IAM Role Policy.txt # Permissions for Lambda to access DynamoDB
│ └── S3 Bucket Policy.txt # Permissions for CloudFront to serve frontend
│
├── CommonJS-LambdaCode/ # Alternate CommonJS version (require/module.exports)
│ # Functionally identical — older Node.js syntax
│
└── README.md
Each item stored in DynamoDB has the following structure:
{
"coffeeId": "coff001",
"name": "Cappuccino",
"price": 4.5,
"available": true
}| Field | Type | Required | Description |
|---|---|---|---|
coffeeId |
String | ✅ | Primary key — must be unique |
name |
String | ✅ | Name of the coffee |
price |
Number | ✅ | Price of the coffee |
available |
Boolean | ✅ | Whether it's currently available |
POST /coffee
Request Body:
{
"coffeeId": "coff001",
"name": "Cappuccino",
"price": 4.5,
"available": true
}Responses:
201— Item created successfully409— Item already exists (duplicatecoffeeId)409— Missing required fields500— Internal server error
GET /coffee → Returns ALL items
GET /coffee/{id} → Returns ONE item by coffeeId
Responses:
200— Success with item(s)500— Internal server error
PUT /coffee/{id}
Request Body (send only the fields you want to update):
{
"price": 5.0,
"available": false
}Responses:
200— Updated successfully, returns new values400— MissingcoffeeIdor nothing to update404— Item does not exist500— Internal server error
DELETE /coffee/{id}
Responses:
200— Deleted successfully, returns deleted item data400— MissingcoffeeId404— Item does not exist500— Internal server error
Every Lambda function follows the same pattern:
export const handler = async (event) => {
// 1. Extract data from the incoming request (event)
// 2. Validate the input
// 3. Build a DynamoDB command
// 4. Execute the command
// 5. Return an HTTP response
}const command = new PutCommand({
TableName: tableName,
Item: { coffeeId, name, price, available },
ConditionExpression: "attribute_not_exists(coffeeId)", // Prevents duplicates
});- Uses
PutCommandto insert a new item ConditionExpression: "attribute_not_exists(coffeeId)"— DynamoDB will reject the write if an item with thatcoffeeIdalready exists. This prevents accidental overwrites.
if (id) {
command = new GetCommand({ Key: { coffeeId: id } }); // Fetch one
} else {
command = new ScanCommand({ TableName: tableName }); // Fetch all
}- Smart routing in a single function — checks if an
idwas passed in the URL path GetCommand→ fetches a specific item by primary key (fast, efficient)ScanCommand→ reads the entire table (fine for small datasets)
const command = new UpdateCommand({
UpdateExpression: "SET #name = :name, price = :price",
ConditionExpression: "attribute_exists(coffeeId)", // Item must exist
ReturnValues: "ALL_NEW", // Returns the updated item in the response
});- Only updates the fields you send — partial updates are supported
nameis a reserved keyword in DynamoDB, so it usesExpressionAttributeNamesto alias it as#nameReturnValues: "ALL_NEW"returns the full updated item so the client sees the latest state
const command = new DeleteCommand({
Key: { coffeeId },
ConditionExpression: "attribute_exists(coffeeId)", // Item must exist
ReturnValues: "ALL_OLD", // Returns the deleted item in the response
});ConditionExpressionensures you get a404if item doesn't exist, rather than a silent successReturnValues: "ALL_OLD"returns the data of the item that was just deleted
Without Layers, every Lambda function had to repeat this setup code:
// Repeated in ALL 4 functions — bad practice
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const createResponse = (statusCode, body) => { ... };With Layers, this shared code lives in one place — Layers/nodejs/utils.mjs — and each function simply imports it:
import { docClient, createResponse, PutCommand } from '/opt/nodejs/utils';AWS automatically mounts the Layer at /opt/nodejs/ inside the Lambda runtime. Benefits:
- No code duplication
- Update shared logic in one place
- Smaller individual Lambda package sizes
Lambda functions need explicit permission to interact with AWS services. Without this policy, every DynamoDB call would be denied.
policy/Lambda IAM Role Policy.txt grants Lambda permission to:
| Permission | Used By |
|---|---|
dynamodb:PutItem |
POST function |
dynamodb:GetItem |
GET function (single item) |
dynamodb:Scan |
GET function (all items) |
dynamodb:UpdateItem |
UPDATE function |
dynamodb:DeleteItem |
DELETE function |
logs:CreateLogGroup/Stream/PutLogEvents |
All functions (CloudWatch logging) |
policy/S3 Bucket Policy.txt grants CloudFront permission to serve files from the S3 bucket that hosts the frontend. Direct public access to S3 is blocked — only CloudFront can read it, improving security.
This project uses DynamoDB (NoSQL) instead of a traditional SQL database (like MySQL) for these reasons:
- Serverless-native — DynamoDB scales automatically with no configuration
- No connection pooling issues — Lambda functions are stateless and short-lived; SQL databases struggle with hundreds of short-lived connections
- Pay per request — no idle database cost
- Single-digit millisecond latency — extremely fast for key-based lookups
The repo contains two versions of the same code:
| ESM (Modern) | CommonJS (Older) | |
|---|---|---|
| Syntax | import { x } from 'y' |
const { x } = require('y') |
| Export | export const fn = ... |
module.exports = { fn } |
| File extension | .mjs |
.js |
| Use this? | ✅ Yes — this is the main version | ❌ Reference only |
The CommonJS-LambdaCode/ folder exists only as an alternative for older Node.js setups. The root Lambda Functions/ folder (ESM) is the primary version.
- AWS account
- AWS CLI installed and configured
- Node.js installed
- Go to AWS Console → DynamoDB → Create Table
- Table name:
mytestCoffeeTable - Partition key:
coffeeId(String)
- Go to IAM → Roles → Create Role
- Select: AWS Service → Lambda
- Attach the policy from
policy/Lambda IAM Role Policy.txt
cd Layers
bash create_zip.sh- Go to AWS Console → Lambda → Layers → Create Layer
- Upload
layer.zip - Runtime: Node.js 18.x or above
For each function (post, get, update, delete):
- Go to Lambda → Create Function
- Runtime: Node.js 18.x
- Upload the respective
.zipfile - Attach the IAM role created in Step 2
- Attach the Layer created in Step 3
- Add environment variable:
tableName = mytestCoffeeTable
- Go to API Gateway → Create API → REST API
- Create resource
/coffeewith methods: GET, POST - Create resource
/coffee/{id}with methods: GET, PUT, DELETE - Link each method to its corresponding Lambda function
- Deploy the API to a stage (e.g.,
prod)
| Variable | Default | Description |
|---|---|---|
tableName |
mytestCoffeeTable |
DynamoDB table name |
Set this in each Lambda function's configuration on AWS.
{
"@aws-sdk/client-dynamodb": "^3.777.0",
"@aws-sdk/lib-dynamodb": "^3.778.0"
}@aws-sdk/client-dynamodb— Low-level DynamoDB client (used to initialize the connection)@aws-sdk/lib-dynamodb— High-level Document Client (handles data type conversion automatically)