Advanced Serverless Techniques I: Do Not Repeat Yourself

The SaaS Enthusiast
5 min readFeb 3, 2024

--

Visualize a tranquil coffee shop where we see a female developer from the back, working on her laptop. The setting is calm and minimalist, embodying a moment of clarity and inspiration in serverless computing development. The scene is clean, with the developer seated at a table, focusing intently on her laptop screen. Natural light bathes the interior, highlighting a comfortable and productive workspace. This image, devoid of any faces or text, focuses on the essence of a serene work environment

We’re diving into innovative strategies to refine scalability, enhance reusability, and perfect our project’s structure with our latest series, “Mastering Serverless with Advanced Techniques.” After laying a solid foundation in serverless architecture, it’s time to take things up a notch. Our journey begins with a fresh approach to structuring Lambdas. As we wrap up our foundational serverless series, we’ve equipped you with a well-architected Lambda setup, complete with dependency injection for DynamoDB interactions, sophisticated logging tools, and middleware for thorough validation. Yet, the duplication across different Lambda functions poses a challenge to the DRY principle — Don’t Repeat Yourself. Let’s explore a new strategy to address this.

Pre-Requisite

You may need to read some of the articles of the “Mastering Serverless” series:

Current Approach

Before we dive deeper, make sure you’re caught up with our previous articles on mastering serverless. Understanding the interaction with AWS services through dependency injection, the role of middleware like middy, and the utilities AWS provides is crucial. Typically, a Lambda function for one of our APIs might look something like this:

async function lambdaHandler(event) {
const TableName = process.env.bacaSupportersTableName;
const {username} = event.pathParameters;

try {
// Interaction with DynamoDB
const {Items: supporters=[]} = await dynamodbQueryHelper(
TableName,
"#hashKey = :hashKey",
{"#hashKey": "createdBy"},
{":hashKey": username}
);
logger.info(`Supporters obtained: ${supporters?.length}`);

// Sending the Results
return _200(supporters.sort((a, b) => a?.name?.localeCompare(b?.name)));
} catch (e) {

// Managing errors
logger.error(e.message);
return _500({status: false, message: e.message});
}
}

export const handler = middy(lambdaHandler)
.use(injectLambdaContext(logger))
// Code to do validation
.use(inputValidator({
validateEnv: ['bacaSupportersTableName'],
// validatePathParameters: ['username'],
}));

This snippet illustrates how we interact with DynamoDB, manage logging, and handle errors — a pattern repeated across many of our Lambdas, leading us to rethink our approach.

Creating a Base Handler

To streamline our Lambda functions, I’m introducing a base handler that encapsulates common functionalities such as logging, input validation, and error handling. This allows each Lambda to inherit from this base, focusing on the unique logic needed for its specific task.

Explaining the Base Handler Code

'use strict';
import middy from '@middy/core';
import { MsaLogger as logger } from "../../libs/logger.js";
import { _200, _500 } from "../../libs/response-helper.js";
import { injectLambdaContext } from '@aws-lambda-powertools/logger';
import { inputValidator } from "../../libs/middleware/input-validator.js";

export class BaseHandler {
constructor() {
this.handler = middy(this.handle.bind(this))
.use(injectLambdaContext(logger))
.use(inputValidator(this.getValidatorConfig()));
}

async handle(event) {
// Common pre-processing
try {
const result = await this.main(event);
return _200(result);
} catch (e) {
logger.error(e.message);
return _500({ status: false, message: e.message });
}
// Common post-processing
}

async main(event) {
// To be overridden by subclasses
}

getValidatorConfig() {
// Common validator config or to be overridden by subclasses
}
}

This code snippet showcases our BaseHandler class. It’s designed to handle common pre-processing, such as logging and input validation, and post-processing tasks. The main method is where the unique logic of each Lambda will be defined, making this a powerful tool for reducing redundancy and focusing on what matters most in each function.

Extending the Base Handler & Injecting DynamoDB Service

To further enhance our serverless architecture, creating a custom middleware for DynamoDB operations allows us to encapsulate database logic, making it reusable and easy to maintain across different Lambdas. This middleware injects an instance of the DynamoService into the Lambda context, simplifying database interactions within your handlers.

Middleware and Lambda Handler Implementation

// dynamoServiceMiddleware.js
import { DynamoService } from 'path-to-dynamo-service'; // Adjust the path accordingly

export const dynamoServiceMiddleware = () => {
let dynamoService;

return {
before: async (handler) => {
if (!dynamoService) {
dynamoService = new DynamoService();
}
handler.context.dynamoService = dynamoService;
}
};
};
import middy from '@middy/core';
import { BaseHandler } from 'path-to-your-base-handler'; // Adjust the path accordingly
import { dynamoServiceMiddleware } from 'path-to-dynamo-service-middleware'; // Adjust the path

class MyLambdaHandler extends BaseHandler {
async main(event) {
const dynamoService = this.context.dynamoService;
const TableName = process.env.bacaSupportersTableName;
const { username } = event.pathParameters;

// Use the dynamoService for database operations
const response = await dynamoService.dynamodbGetHelper({ TableName, Key: { username } });

// Process the response and return
// ...
}

// ...
}

export const handler = new MyLambdaHandler().handler.use(dynamoServiceMiddleware());

This implementation demonstrates how the dynamoServiceMiddleware seamlessly integrates DynamoDB operations into our Lambda functions. By injecting the DynamoService instance before the handler executes, we maintain clean, maintainable code that’s easy to reuse across projects.

Conclusion

In this series, we’ve embarked on a journey to refine our serverless architecture with advanced techniques. By rethinking our code structure, we’ve unlocked valuable lessons that not only enhance our current projects but also provide insights applicable across a wide range of development scenarios. Emphasizing progressive improvement, we continue to evolve our codebase, seeking the most effective strategies to achieve efficiency, maintainability, and scalability. Join us as we push the boundaries of serverless architecture, ensuring our skills and projects remain at the cutting edge.

Empower Your Tech Journey:

Explore a wealth of knowledge designed to elevate your tech projects and understanding. From safeguarding your applications to mastering serverless architecture, discover articles that resonate with your ambition.

New Projects or Consultancy

For new project collaborations or bespoke consultancy services, reach out directly and let’s transform your ideas into reality. Ready to take your project to the next level?

Protecting Routes

Advanced Serverless Techniques

Mastering Serverless Series

--

--