Back to Blog
ServerlessArchitectureAWSCloud

Serverless Architecture Patterns

Design serverless applications effectively. From function composition to event sourcing to cost optimization.

B
Bootspring Team
Engineering
September 20, 2022
5 min read

Serverless changes how we design applications. Here are patterns that work well in serverless environments.

Function Composition#

1// Single-purpose functions 2export const validateOrder = async (event: APIGatewayEvent) => { 3 const order = JSON.parse(event.body!); 4 5 const errors = validateOrderSchema(order); 6 if (errors.length > 0) { 7 return { statusCode: 400, body: JSON.stringify({ errors }) }; 8 } 9 10 // Pass to next function via Step Functions or EventBridge 11 await eventBridge.putEvents({ 12 Entries: [{ 13 Source: 'orders', 14 DetailType: 'OrderValidated', 15 Detail: JSON.stringify(order), 16 }], 17 }); 18 19 return { statusCode: 202, body: JSON.stringify({ status: 'processing' }) }; 20}; 21 22export const processPayment = async (event: EventBridgeEvent) => { 23 const order = event.detail; 24 25 const payment = await stripe.charges.create({ 26 amount: order.total, 27 currency: 'usd', 28 source: order.paymentToken, 29 }); 30 31 await eventBridge.putEvents({ 32 Entries: [{ 33 Source: 'payments', 34 DetailType: 'PaymentProcessed', 35 Detail: JSON.stringify({ orderId: order.id, paymentId: payment.id }), 36 }], 37 }); 38}; 39 40export const fulfillOrder = async (event: EventBridgeEvent) => { 41 const { orderId, paymentId } = event.detail; 42 43 await db.order.update({ 44 where: { id: orderId }, 45 data: { status: 'fulfilled', paymentId }, 46 }); 47 48 await sendConfirmationEmail(orderId); 49};

Event-Driven Architecture#

1# serverless.yml 2functions: 3 orderCreated: 4 handler: handlers/orders.created 5 events: 6 - eventBridge: 7 pattern: 8 source: 9 - orders 10 detail-type: 11 - OrderCreated 12 13 inventoryReserved: 14 handler: handlers/inventory.reserved 15 events: 16 - eventBridge: 17 pattern: 18 source: 19 - inventory 20 detail-type: 21 - InventoryReserved 22 23 sendNotification: 24 handler: handlers/notifications.send 25 events: 26 - eventBridge: 27 pattern: 28 source: 29 - orders 30 - payments 31 detail-type: 32 - OrderFulfilled 33 - PaymentFailed
1// Event publisher 2import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge'; 3 4const eventBridge = new EventBridgeClient({}); 5 6async function publishEvent( 7 source: string, 8 detailType: string, 9 detail: object 10): Promise<void> { 11 await eventBridge.send(new PutEventsCommand({ 12 Entries: [{ 13 Source: source, 14 DetailType: detailType, 15 Detail: JSON.stringify(detail), 16 EventBusName: process.env.EVENT_BUS_NAME, 17 }], 18 })); 19} 20 21// Usage 22await publishEvent('orders', 'OrderCreated', { 23 orderId: '123', 24 userId: 'user_456', 25 items: [{ productId: 'prod_789', quantity: 2 }], 26});

Step Functions Orchestration#

1{ 2 "Comment": "Order processing workflow", 3 "StartAt": "ValidateOrder", 4 "States": { 5 "ValidateOrder": { 6 "Type": "Task", 7 "Resource": "arn:aws:lambda:region:account:function:validateOrder", 8 "Next": "CheckInventory", 9 "Catch": [{ 10 "ErrorEquals": ["ValidationError"], 11 "Next": "OrderFailed" 12 }] 13 }, 14 "CheckInventory": { 15 "Type": "Task", 16 "Resource": "arn:aws:lambda:region:account:function:checkInventory", 17 "Next": "ProcessPayment", 18 "Catch": [{ 19 "ErrorEquals": ["OutOfStockError"], 20 "Next": "OrderFailed" 21 }] 22 }, 23 "ProcessPayment": { 24 "Type": "Task", 25 "Resource": "arn:aws:lambda:region:account:function:processPayment", 26 "Next": "FulfillOrder", 27 "Retry": [{ 28 "ErrorEquals": ["PaymentRetryableError"], 29 "IntervalSeconds": 2, 30 "MaxAttempts": 3, 31 "BackoffRate": 2 32 }], 33 "Catch": [{ 34 "ErrorEquals": ["PaymentError"], 35 "Next": "RefundAndFail" 36 }] 37 }, 38 "FulfillOrder": { 39 "Type": "Task", 40 "Resource": "arn:aws:lambda:region:account:function:fulfillOrder", 41 "End": true 42 }, 43 "OrderFailed": { 44 "Type": "Task", 45 "Resource": "arn:aws:lambda:region:account:function:notifyFailure", 46 "End": true 47 }, 48 "RefundAndFail": { 49 "Type": "Task", 50 "Resource": "arn:aws:lambda:region:account:function:refund", 51 "Next": "OrderFailed" 52 } 53 } 54}

Fan-Out Pattern#

1// Process items in parallel 2export const processOrder = async (event: SQSEvent) => { 3 const order = JSON.parse(event.Records[0].body); 4 5 // Fan out to process each item 6 await Promise.all( 7 order.items.map(item => 8 sqs.sendMessage({ 9 QueueUrl: process.env.ITEM_PROCESSING_QUEUE, 10 MessageBody: JSON.stringify({ 11 orderId: order.id, 12 item, 13 }), 14 }) 15 ) 16 ); 17}; 18 19export const processItem = async (event: SQSEvent) => { 20 const { orderId, item } = JSON.parse(event.Records[0].body); 21 22 // Process individual item 23 await reserveInventory(item.productId, item.quantity); 24 25 // Check if all items processed 26 const remaining = await db.orderItem.count({ 27 where: { orderId, status: 'pending' }, 28 }); 29 30 if (remaining === 0) { 31 await publishEvent('orders', 'AllItemsProcessed', { orderId }); 32 } 33};

CQRS with DynamoDB Streams#

1// Write model 2export const createOrder = async (event: APIGatewayEvent) => { 3 const order = JSON.parse(event.body!); 4 5 await dynamodb.put({ 6 TableName: 'Orders', 7 Item: { 8 PK: `ORDER#${order.id}`, 9 SK: `ORDER#${order.id}`, 10 ...order, 11 createdAt: new Date().toISOString(), 12 }, 13 }); 14 15 return { statusCode: 201, body: JSON.stringify(order) }; 16}; 17 18// Stream processor - updates read model 19export const updateReadModel = async (event: DynamoDBStreamEvent) => { 20 for (const record of event.Records) { 21 if (record.eventName === 'INSERT' || record.eventName === 'MODIFY') { 22 const order = unmarshall(record.dynamodb!.NewImage!); 23 24 // Update Elasticsearch for search 25 await elasticsearch.index({ 26 index: 'orders', 27 id: order.id, 28 body: order, 29 }); 30 31 // Update aggregation table 32 await dynamodb.update({ 33 TableName: 'UserOrderStats', 34 Key: { userId: order.userId }, 35 UpdateExpression: 'ADD orderCount :one SET lastOrderDate = :date', 36 ExpressionAttributeValues: { 37 ':one': 1, 38 ':date': order.createdAt, 39 }, 40 }); 41 } 42 } 43};

Saga Pattern#

1// Saga coordinator 2interface SagaStep { 3 execute: () => Promise<void>; 4 compensate: () => Promise<void>; 5} 6 7async function executeSaga(steps: SagaStep[]): Promise<void> { 8 const completedSteps: SagaStep[] = []; 9 10 try { 11 for (const step of steps) { 12 await step.execute(); 13 completedSteps.push(step); 14 } 15 } catch (error) { 16 // Compensate in reverse order 17 for (const step of completedSteps.reverse()) { 18 try { 19 await step.compensate(); 20 } catch (compensateError) { 21 console.error('Compensation failed:', compensateError); 22 // Log to dead letter queue for manual intervention 23 } 24 } 25 throw error; 26 } 27} 28 29// Usage 30const orderSaga: SagaStep[] = [ 31 { 32 execute: () => reserveInventory(orderId), 33 compensate: () => releaseInventory(orderId), 34 }, 35 { 36 execute: () => chargePayment(orderId), 37 compensate: () => refundPayment(orderId), 38 }, 39 { 40 execute: () => createShipment(orderId), 41 compensate: () => cancelShipment(orderId), 42 }, 43]; 44 45await executeSaga(orderSaga);

Cost Optimization#

1// Batch processing to reduce invocations 2export const processBatch = async (event: SQSEvent) => { 3 // Process up to 10 messages per invocation 4 const results = await Promise.allSettled( 5 event.Records.map(record => processMessage(JSON.parse(record.body))) 6 ); 7 8 // Return failures for retry 9 const failures = results 10 .map((result, index) => ({ result, record: event.Records[index] })) 11 .filter(({ result }) => result.status === 'rejected'); 12 13 return { 14 batchItemFailures: failures.map(({ record }) => ({ 15 itemIdentifier: record.messageId, 16 })), 17 }; 18}; 19 20// Use reserved concurrency to control costs 21// serverless.yml 22// functions: 23// expensive: 24// handler: handlers.expensive 25// reservedConcurrency: 5 # Max 5 concurrent executions
1# Right-size memory allocation 2functions: 3 lightweight: 4 handler: handlers.lightweight 5 memorySize: 128 # MB 6 7 cpuIntensive: 8 handler: handlers.cpuIntensive 9 memorySize: 1024 # More CPU allocated with more memory 10 11 heavyProcessing: 12 handler: handlers.heavy 13 memorySize: 3008 # Max before ARM pricing changes 14 architecture: arm64 # 20% cheaper on ARM

Best Practices#

Design: ✓ Single responsibility per function ✓ Use events for async communication ✓ Implement idempotency ✓ Design for failure and retries Performance: ✓ Minimize cold starts ✓ Keep functions small ✓ Use connection pooling ✓ Cache when possible Cost: ✓ Right-size memory allocation ✓ Use ARM architecture ✓ Batch process where possible ✓ Set reserved concurrency limits Observability: ✓ Structured logging ✓ Distributed tracing ✓ Custom metrics ✓ Alert on errors

Conclusion#

Serverless architecture requires different patterns than traditional applications. Embrace event-driven design, use orchestration for complex workflows, and always consider idempotency. Good serverless design leads to scalable, cost-effective applications.

Share this article

Help spread the word about Bootspring