2025-12-06
Testing Serverless Applications: A Practical Strategy Guide
Learn how to build a comprehensive testing strategy for AWS Lambda, API Gateway, DynamoDB, and Step Functions with practical patterns for fast feedback and production reliability.
The Testing Challenge
Serverless applications compress the deploy cycle to minutes, which changes the economics of testing: the bug that used to be caught by a long release process now reaches production before a human reviews it. The testing strategy has to catch what the deploy cadence no longer will. At the same time, serverless architecture itself breaks local-first testing; Lambda cold starts, IAM permissions, event schemas, and the boundaries between managed services all behave differently locally than in a live account.
This post covers a testing strategy for serverless applications on AWS. It covers the layered approach (unit, integration with LocalStack versus a live account, contract, end-to-end), the specific failure modes that mocks hide, and the CI patterns that keep the test suite proportional to the deploy speed.
The false confidence problem: Tests pass locally with mocked AWS SDK calls, then production fails due to IAM permissions or incorrect event structures.
The slow feedback loop: Waiting several minutes for CloudFormation deployments just to test a simple Lambda change.
The LocalStack gap: Tests pass against LocalStack but fail in real AWS due to API differences.
The async complexity: EventBridge and Step Functions introduce asynchronous behavior that’s difficult to test reliably.
This post shares practical patterns for testing serverless applications that balance fast feedback with production confidence.
The Serverless Testing Pyramid
The traditional testing pyramid applies to serverless, but with adjusted proportions and techniques:
Unit tests (70%): Fast, no AWS calls, test business logic in isolation. Run on every commit.
Integration tests (20%): Test service integrations with real or local AWS services. Run on pull requests.
E2E tests (10%): Full workflow testing in cloud environment. Run on main branch deployments.
Here’s what works in practice:
When to Use Each Testing Level
Unit tests catch:
- Business logic errors
- Input validation issues
- Data transformation bugs
- Error handling gaps
Integration tests catch:
- IAM permission issues
- Service configuration problems
- Event structure mismatches
- Timeout scenarios
E2E tests catch:
- Multi-service orchestration issues
- Cross-account routing problems
- Production configuration drift
- Real-world performance issues
Unit Testing Lambda Functions
The key to effective unit testing is separating your handler from business logic:
// Bad: Everything in the handler
export const handler = async (event: APIGatewayProxyEvent) => {
// Business logic mixed with handler code
const body = JSON.parse(event.body || '{}');
const discount = body.amount > 100 ? 0.1 : 0;
const total = body.amount * (1 - discount);
return {
statusCode: 200,
body: JSON.stringify({ total })
};
};
// Good: Separate concerns
export const calculateTotal = (amount: number): number => {
const discount = amount > 100 ? 0.1 : 0;
return amount * (1 - discount);
};
export const handler = async (event: APIGatewayProxyEvent) => {
const body = JSON.parse(event.body || '{}');
const total = calculateTotal(body.amount);
return {
statusCode: 200,
body: JSON.stringify({ total })
};
};
Now you can test calculateTotal without mocking API Gateway events.
Testing with AWS SDK v3
Here’s a practical example testing a Lambda that reads from DynamoDB:
// user-handler.ts
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
const client = new DynamoDBClient({});
const ddb = DynamoDBDocumentClient.from(client);
export const getUser = async (userId: string) => {
const result = await ddb.send(new GetCommand({
TableName: process.env.TABLE_NAME,
Key: { PK: `USER#${userId}` }
}));
if (!result.Item) {
throw new Error('User not found');
}
return result.Item;
};
export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
try {
const userId = event.pathParameters?.id;
if (!userId) {
return { statusCode: 400, body: JSON.stringify({ error: 'Missing user ID' }) };
}
const user = await getUser(userId);
return {
statusCode: 200,
body: JSON.stringify(user)
};
} catch (error) {
return {
statusCode: 404,
body: JSON.stringify({ error: (error as Error).message })
};
}
};
Test this with aws-sdk-client-mock:
// user-handler.test.ts
import { mockClient } from 'aws-sdk-client-mock';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
import { APIGatewayProxyEvent } from 'aws-lambda';
import { handler, getUser } from './user-handler';
const ddbMock = mockClient(DynamoDBDocumentClient);
beforeEach(() => {
ddbMock.reset();
process.env.TABLE_NAME = 'users';
});
describe('getUser', () => {
it('returns user when found', async () => {
ddbMock.on(GetCommand).resolves({
Item: { PK: 'USER#123', name: 'John', email: '[email protected]' }
});
const user = await getUser('123');
expect(user).toEqual({
PK: 'USER#123',
name: 'John',
email: '[email protected]'
});
});
it('throws when user not found', async () => {
ddbMock.on(GetCommand).resolves({ Item: undefined });
await expect(getUser('999')).rejects.toThrow('User not found');
});
it('calls DynamoDB with correct parameters', async () => {
ddbMock.on(GetCommand).resolves({ Item: { PK: 'USER#123' } });
await getUser('123');
expect(ddbMock.calls()[0].args[0].input).toEqual({
TableName: 'users',
Key: { PK: 'USER#123' }
});
});
});
describe('handler', () => {
it('returns 400 when userId is missing', async () => {
const event = createApiGatewayEvent({ pathParameters: null });
const result = await handler(event);
expect(result.statusCode).toBe(400);
expect(JSON.parse(result.body)).toEqual({ error: 'Missing user ID' });
});
it('returns user when found', async () => {
ddbMock.on(GetCommand).resolves({
Item: { PK: 'USER#123', name: 'John' }
});
const event = createApiGatewayEvent({ pathParameters: { id: '123' } });
const result = await handler(event);
expect(result.statusCode).toBe(200);
expect(JSON.parse(result.body)).toEqual({ PK: 'USER#123', name: 'John' });
});
});
// Helper function for creating test events
function createApiGatewayEvent(overrides?: Partial<APIGatewayProxyEvent>): APIGatewayProxyEvent {
return {
body: null,
headers: {},
multiValueHeaders: {},
httpMethod: 'GET',
isBase64Encoded: false,
path: '/users',
pathParameters: null,
queryStringParameters: null,
multiValueQueryStringParameters: null,
stageVariables: null,
requestContext: {
accountId: '123456789012',
apiId: 'test-api',
protocol: 'HTTP/1.1',
httpMethod: 'GET',
path: '/users',
stage: 'test',
requestId: 'test-request',
requestTimeEpoch: Date.now(),
resourceId: 'test-resource',
resourcePath: '/users',
identity: {
sourceIp: '127.0.0.1',
userAgent: 'test-agent',
accessKey: null,
accountId: null,
apiKey: null,
apiKeyId: null,
caller: null,
clientCert: null,
cognitoAuthenticationProvider: null,
cognitoAuthenticationType: null,
cognitoIdentityId: null,
cognitoIdentityPoolId: null,
principalOrgId: null,
user: null,
userArn: null
},
authorizer: null
},
resource: '/users',
...overrides
} as APIGatewayProxyEvent;
}
Key patterns here:
- Mock only external dependencies (DynamoDB), not business logic
- Test the business function (
getUser) separately from the handler - Verify actual AWS SDK call parameters, not just return values
- Create helper functions for test event construction
- Reset mocks in
beforeEachto avoid test pollution
Integration Testing Strategies
Unit tests give fast feedback but can’t catch integration issues. Here’s when integration testing becomes critical.
Testing with Real DynamoDB
For important operations, test against real DynamoDB:
// user-repository.integration.test.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand, GetCommand, QueryCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import { randomUUID } from 'crypto';
const client = new DynamoDBClient({
region: process.env.AWS_REGION || 'us-east-1'
});
const ddb = DynamoDBDocumentClient.from(client);
// Use unique table name per test run to allow parallel execution
const TABLE_NAME = `users-test-${Date.now()}`;
const testUserIds: string[] = [];
beforeAll(async () => {
// In real setup, create table with AWS CDK or CloudFormation
// Here we assume table exists
});
afterEach(async () => {
// Clean up test data
await Promise.all(
testUserIds.map(id =>
ddb.send(new DeleteCommand({
TableName: TABLE_NAME,
Key: { PK: `USER#${id}` }
}))
)
);
testUserIds.length = 0;
});
describe('DynamoDB Integration Tests', () => {
it('creates and retrieves user', async () => {
const userId = randomUUID();
testUserIds.push(userId);
// Create user
await ddb.send(new PutCommand({
TableName: TABLE_NAME,
Item: {
PK: `USER#${userId}`,
name: 'John Doe',
email: '[email protected]',
createdAt: new Date().toISOString()
}
}));
// Retrieve user
const result = await ddb.send(new GetCommand({
TableName: TABLE_NAME,
Key: { PK: `USER#${userId}` }
}));
expect(result.Item).toMatchObject({
PK: `USER#${userId}`,
name: 'John Doe',
email: '[email protected]'
});
});
it('queries users by email GSI', async () => {
const userId = randomUUID();
testUserIds.push(userId);
const email = `test-${userId}@example.com`;
await ddb.send(new PutCommand({
TableName: TABLE_NAME,
Item: {
PK: `USER#${userId}`,
email: email,
name: 'Test User'
}
}));
// Query by email using GSI
const result = await ddb.send(new QueryCommand({
TableName: TABLE_NAME,
IndexName: 'EmailIndex',
KeyConditionExpression: 'email = :email',
ExpressionAttributeValues: { ':email': email }
}));
expect(result.Items).toHaveLength(1);
expect(result.Items?.[0].PK).toBe(`USER#${userId}`);
});
});
Why this matters: This test catches real issues like:
- IAM permissions (if your test role lacks permissions, this fails)
- GSI configuration problems
- Conditional write conflicts
- DynamoDB API changes
Tradeoffs: Slower than unit tests (2-5 seconds vs 10ms), costs a few cents per month.
LocalStack vs Real AWS: When to Use Each
Note: LocalStack has known issues with Step Functions and EventBridge integration. For testing workflows involving these services, use real AWS or be prepared for behavior differences.
Here’s a decision framework I use:
Use LocalStack for:
- Simple DynamoDB operations (GET, PUT, QUERY)
- Basic S3 operations
- SQS message handling
- Rapid iteration during development
Use Real AWS for:
- IAM permission validation
- Step Functions orchestration
- EventBridge event routing
- Complex DynamoDB streams
- Pre-deployment validation
Testing API Gateway Integrations
API Gateway events have complex structures. Here’s how to test them properly:
// api-integration.test.ts
import { APIGatewayProxyEvent } from 'aws-lambda';
import { handler } from './api-handler';
describe('API Gateway Integration', () => {
it('handles POST request with JSON body', async () => {
const event: APIGatewayProxyEvent = {
httpMethod: 'POST',
path: '/users',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
name: 'John Doe',
email: '[email protected]'
}),
// ... other required fields
} as APIGatewayProxyEvent;
const result = await handler(event);
expect(result.statusCode).toBe(201);
expect(result.headers).toMatchObject({
'content-type': 'application/json',
'access-control-allow-origin': '*' // CORS
});
});
it('validates CORS headers', async () => {
const event = createApiGatewayEvent({
httpMethod: 'OPTIONS',
headers: {
'origin': 'https://example.com',
'access-control-request-method': 'POST'
}
});
const result = await handler(event);
expect(result.headers).toMatchObject({
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET,POST,PUT,DELETE',
'access-control-allow-headers': 'Content-Type,Authorization'
});
});
it('returns 400 for invalid JSON', async () => {
const event = createApiGatewayEvent({
httpMethod: 'POST',
body: 'invalid json{'
});
const result = await handler(event);
expect(result.statusCode).toBe(400);
expect(JSON.parse(result.body)).toMatchObject({
error: 'Invalid JSON'
});
});
});
Testing with AWS SAM Local
For higher-fidelity testing, use SAM CLI:
# Start local API Gateway
sam local start-api --port 3000
# In another terminal, test with curl
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name":"John","email":"[email protected]"}'
# Or invoke function directly with test event
sam local invoke UserFunction --event events/create-user.json
events/create-user.json:
{
"httpMethod": "POST",
"path": "/users",
"headers": {
"content-type": "application/json"
},
"body": "{\"name\":\"John Doe\",\"email\":\"[email protected]\"}"
}
This catches issues like:
- Lambda timeout configuration
- Memory limit problems
- Environment variable configuration
- Cold start behavior
Testing Step Functions
Step Functions orchestrate multiple services. Here’s how to test them:
Note: AWS Step Functions Local is currently unsupported by AWS and may have compatibility issues. For reliable testing, use real AWS Step Functions with test state machines.
Level 1: State Machine Definition Validation
// state-machine.test.ts
import * as fs from 'fs';
import * as path from 'path';
describe('State Machine Definition', () => {
it('has valid JSON syntax', () => {
const definitionPath = path.join(__dirname, 'state-machine.asl.json');
const definition = JSON.parse(fs.readFileSync(definitionPath, 'utf8'));
expect(definition).toHaveProperty('StartAt');
expect(definition).toHaveProperty('States');
});
it('has error handling for each state', () => {
const definition = JSON.parse(fs.readFileSync('state-machine.asl.json', 'utf8'));
Object.entries(definition.States).forEach(([name, state]: [string, any]) => {
if (state.Type === 'Task') {
expect(state).toHaveProperty('Catch',
`State ${name} should have error handling`
);
}
});
});
});
Level 2: Integration Testing with Real Executions
// step-functions-integration.test.ts
import {
SFNClient,
StartExecutionCommand,
DescribeExecutionCommand
} from '@aws-sdk/client-sfn';
const sfn = new SFNClient({ region: 'us-east-1' });
const STATE_MACHINE_ARN = process.env.STATE_MACHINE_ARN;
describe('Order Processing State Machine', () => {
it('processes order successfully', async () => {
const executionName = `test-${Date.now()}`;
// Start execution
const { executionArn } = await sfn.send(new StartExecutionCommand({
stateMachineArn: STATE_MACHINE_ARN,
name: executionName,
input: JSON.stringify({
orderId: '12345',
items: [{ id: 'item1', quantity: 2 }]
})
}));
// Poll for completion (with timeout)
const result = await waitForExecution(executionArn!, 30000);
expect(result.status).toBe('SUCCEEDED');
const output = JSON.parse(result.output!);
expect(output).toMatchObject({
orderId: '12345',
status: 'COMPLETED'
});
});
it('handles validation errors', async () => {
const executionName = `test-error-${Date.now()}`;
const { executionArn } = await sfn.send(new StartExecutionCommand({
stateMachineArn: STATE_MACHINE_ARN,
name: executionName,
input: JSON.stringify({
orderId: '', // Invalid
items: []
})
}));
const result = await waitForExecution(executionArn!, 30000);
expect(result.status).toBe('FAILED');
});
});
async function waitForExecution(executionArn: string, timeoutMs: number) {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const { status, output } = await sfn.send(new DescribeExecutionCommand({
executionArn
}));
if (status === 'SUCCEEDED' || status === 'FAILED') {
return { status, output };
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
throw new Error('Execution timeout');
}
Testing EventBridge
EventBridge testing is tricky due to asynchronous delivery. Here’s a reliable pattern:
// eventbridge-integration.test.ts
import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';
const eventBridge = new EventBridgeClient({ region: 'us-east-1' });
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
describe('EventBridge Event Processing', () => {
it('processes user.created event', async () => {
const testId = `test-${Date.now()}`;
// Publish event
await eventBridge.send(new PutEventsCommand({
Entries: [{
Source: 'user-service',
DetailType: 'user.created',
Detail: JSON.stringify({
userId: testId,
email: '[email protected]',
testMarker: testId // For identifying test events
})
}]
}));
// Wait for processing (EventBridge + Lambda execution)
await waitForEventProcessing(testId, 10000);
// Verify side effects in DynamoDB
const result = await ddb.send(new QueryCommand({
TableName: 'event-log',
KeyConditionExpression: 'testMarker = :marker',
ExpressionAttributeValues: { ':marker': testId }
}));
expect(result.Items).toHaveLength(1);
expect(result.Items?.[0]).toMatchObject({
eventType: 'user.created',
processed: true
});
});
});
async function waitForEventProcessing(testId: string, timeoutMs: number) {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const result = await ddb.send(new QueryCommand({
TableName: 'event-log',
KeyConditionExpression: 'testMarker = :marker',
ExpressionAttributeValues: { ':marker': testId }
}));
if (result.Items && result.Items.length > 0) {
return;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
throw new Error('Event processing timeout');
}
Key pattern: Use a unique test marker and poll for side effects instead of trying to intercept async events directly.
CI/CD Pipeline Integration
Here’s a GitHub Actions workflow that implements the testing pyramid:
# .github/workflows/test.yml
name: Serverless Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
# Fast: 30-60 seconds, Cost: $0
integration-tests:
name: Integration Tests (LocalStack)
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Start LocalStack
run: |
docker run -d \
-p 4566:4566 \
-e SERVICES=dynamodb,s3,sqs \
localstack/localstack:latest
# Wait for LocalStack to be ready
timeout 60 bash -c 'until curl -s http://localhost:4566/_localstack/health | grep -q "\"dynamodb\": \"available\""; do sleep 1; done'
- name: Install dependencies
run: npm ci
- name: Run integration tests
run: npm run test:integration
env:
AWS_ENDPOINT: http://localhost:4566
# Medium: 2-5 minutes, Cost: $0
e2e-tests:
name: E2E Tests (Real AWS)
runs-on: ubuntu-latest
needs: integration-tests
if: github.ref == 'refs/heads/main'
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Install dependencies
run: npm ci
- name: Deploy test stack
run: |
npx cdk deploy TestStack \
--require-approval never \
--outputs-file outputs.json
- name: Run E2E tests
run: npm run test:e2e
- name: Destroy test stack
if: always()
run: npx cdk destroy TestStack --force
# Slow: 5-15 minutes, Cost: ~$0.50 per run
package.json test scripts:
{
"scripts": {
"test:unit": "jest --testPathPattern=\\.test\\.ts$",
"test:integration": "jest --testPathPattern=\\.integration\\.test\\.ts$",
"test:e2e": "jest --testPathPattern=\\.e2e\\.test\\.ts$ --runInBand"
}
}
Common Pitfalls and Solutions
Pitfall 1: Over-Mocking
Problem: Mocking AWS SDK calls but missing IAM permission issues.
// BAD: Passes test, fails in production
ddbMock.on(PutCommand).resolves({});
// Deploy → "AccessDenied: User is not authorized to perform: dynamodb:PutItem"
// GOOD: Integration test with real DynamoDB catches this
const result = await ddb.send(new PutCommand({...}));
// Test fails if IAM permissions are wrong
Solution: Use integration tests with real AWS for critical paths.
Pitfall 2: Event Structure Mismatches
Problem: Test events missing required fields.
// BAD: Incomplete test event
const event = { body: JSON.stringify({ id: 1 }) };
// GOOD: Use @types/aws-lambda
import { APIGatewayProxyEvent } from 'aws-lambda';
const event: APIGatewayProxyEvent = {
body: JSON.stringify({ id: 1 }),
headers: {},
httpMethod: 'POST',
isBase64Encoded: false,
path: '/users',
pathParameters: null,
queryStringParameters: null,
multiValueHeaders: {},
multiValueQueryStringParameters: null,
stageVariables: null,
requestContext: {
accountId: '123456789012',
apiId: 'test',
// ... complete context
},
resource: '/users'
};
Solution: Create event builder functions or use saved real events.
Pitfall 3: Ignoring Async Behavior
Problem: Testing async EventBridge without waiting for processing.
// BAD: Event not yet processed
await eventBridge.putEvents({ Entries: [event] });
const result = await queryResults(); // Empty!
// GOOD: Wait for processing
await eventBridge.putEvents({ Entries: [event] });
await waitForEventProcessing(); // Poll or use Step Functions
const result = await queryResults(); // Has data
Solution: Implement polling or use Step Functions to track event processing.
Pitfall 4: Test Environment Pollution
Problem: Parallel tests interfering with each other.
// BAD: Shared resources
const TABLE_NAME = 'users-test'; // Conflicts in parallel tests
// GOOD: Unique resource names
const TABLE_NAME = `users-test-${Date.now()}-${Math.random()}`;
// Or use beforeEach/afterEach cleanup
afterEach(async () => {
await Promise.all(
testItems.map(id => ddb.delete({ Key: { id } }))
);
});
Solution: Use unique resource names and implement cleanup hooks.
Key Takeaways
Here’s what works in practice:
1. Follow the testing pyramid: 70% unit tests for fast feedback, 20% integration tests for confidence, 10% E2E tests for production validation.
2. Separate business logic from handlers: This makes unit testing easier and faster. Test your business logic thoroughly, then do lighter testing on the handler wiring.
3. Use LocalStack for rapid iteration, real AWS for validation: LocalStack is great for development speed, but always validate against real AWS before deploying.
4. Test IAM permissions explicitly: The most common “works in test, fails in prod” issue is IAM permissions. Integration tests with real AWS catch these.
5. Build event builder utilities: Create helper functions for constructing realistic test events. Use @types/aws-lambda types to ensure completeness.
6. Implement proper cleanup: Use afterEach/afterAll hooks and unique resource names to prevent test pollution. This also saves on AWS costs.
7. Handle async testing properly: EventBridge and Step Functions are asynchronous. Implement polling or use Step Functions executions to validate event processing.
8. Optimize CI/CD pipeline costs: Run unit tests on every commit, integration tests on PRs, E2E tests only on main branch. Use ephemeral stacks with auto-deletion.
9. Track test metrics: Monitor execution time, flakiness rate, and AWS costs. Optimize based on data, not assumptions.
10. Start simple: Begin with basic unit tests and add integration/E2E tests as your application matures. Perfect is the enemy of good.
Working with serverless taught me that testing strategy matters as much as the tests themselves. Fast feedback catches most bugs early, while strategic integration testing catches the issues that only appear when services actually interact. The key is finding the right balance for your team and application.
Related posts
A practical guide to implementing consumer-driven contract testing with Pact in TypeScript microservices. Learn how to catch breaking API changes before deployment and reduce integration testing overhead.
Setting up a production-grade link shortener with AWS CDK, DynamoDB, and Lambda. Real architecture decisions, initial setup, and lessons learned from building URL shorteners at scale.
Learn to build automated preview environments using AWS CDK, Lambda, and GitHub Actions for seamless PR testing and review workflows
Why I moved from Express.js to Lambda, the costly mistakes I made along the way, and the TypeScript patterns that saved my team thousands in AWS bills.
A comprehensive technical guide to Amazon Cognito's advanced features including custom authentication flows, federation patterns, multi-tenancy architectures, migration strategies, and production-grade security implementation.