In many applications, a single Lambda function contains all the application logic that handles all external events. The Lambda function sometimes acts as an orchestrator, handling different business workflows within complex logic. This approach has several drawbacks, like big package size, difficulty enforcing least privilege principles, and hard to test.
In this blog post, I will explain how to leverage the Hexagonal Architecture, also known as the "Ports and Adapters" approach, to refactor a Lambda monolith into microservices.
The big, complex, and tightly-coupled
AWS Lambda is incredibly easy to build and deploy, but it's also easy to fall into the trap of creating a "Lambda monolith". This approach bundles all your logic, processes, and dependencies into a single function that attempts to manage various events. For example, a Lambda monolith function would handle all API Gateway routes and integrate with all necessary downstream resources.
It becomes even more complex when the Lambda function acts as an orchestrator, handling different business workflows resulting in "spaghetti code" when many if-else statements are used.
These approaches are considered anti-patterns in Lambda-based applications and have several drawbacks, for instance:
Big Package Size: As logic grows, so does the size of your deployment package, which can slow down deployment times and the Lambda cold start time.
Hard to enforce the least privilege: It becomes challenging to assign least-privilege permissions, as one Lambda may require broad access to different resources.
Testing Complexities: Testing a large Lambda function is problematic because every modification impacts a broad range of code. Unit testing is difficult, and integration testing is even more challenging to build.
The preferred approach is to decompose the monolithic Lambda function into individual microservices, with each Lambda function dedicated to a single, well-defined task.
Decomposing the monolith
Before you enter Hexagonal Architecture, you need to think about how you would decompose your monolith. Moving from a monolithic Lambda to microservices is more than just splitting code into smaller parts; it's about strategically decomposing the monolith to create services that are independent, scalable, and maintainable. Without careful decomposition, you risk creating a set of microservices that are still tightly coupled or that mirror the same complexities and limitations of the original monolith.
My favorite approach is the "decompose by business capability". This means that each microservice is structured around a specific business capability or function, ensuring that each service corresponds to a particular area of the application's logic.
This method of decomposition is especially valuable in larger applications, where different business functions - such as product catalog management, order management, or delivery - have distinct lifecycles, data requirements, scaling needs, and sometimes distinct development teams. More importantly, you build a stable architecture since the business capabilities are relatively stable.
Strangling the monolith
With your business capabilities mapped to services, you need to think about where to start coding.
Instead of attempting a full rewrite, which can be risky and disruptive, the Strangler Pattern allows us to migrate functionality piece by piece. This technique involves incrementally replacing parts of the monolith with microservices, gradually "strangling" the monolith until it's fully decomposed.
For each business function that we pull out of the monolith, we create a new microservice that can handle it independently. Over time, as more services are extracted, the monolith becomes smaller until it's eventually replaced by a cohesive set of microservices.
This approach is ideal for modernizing applications in stages, reducing downtime and risks while moving toward a microservices architecture.
At this point, you probably already know where to start. It's time to dive a little bit deeper into Hexagonal Architecture.
Enter Hexagonal Architecture
Hexagonal Architecture, also known as "Ports and Adapters," offers a way to modularize your application so it can be more flexible and maintainable. By isolating the core business logic from external systems, this architecture promotes separation of concerns, where the application's core logic isn't tightly coupled to any specific technology or service.
- Core Logic (Domain): The core contains the application's core business rules, completely isolated from the outer layers.
- Ports: Defined interfaces that describe actions available to the core.
- Adapters: Connect external systems to the application's core through ports, making it easy to switch out databases, API integrations, or other dependencies without impacting the core logic.
This approach allows us to design a Lambda setup where each function or service remains lean and single-purpose, eliminating the challenges of a monolithic structure. Let's look at how each layer of the Hexagonal can be implemented in a Lambda-based microservice.
Implementing the hexagonal layers
Each layer requires specific attention to refactor your Lambda application using Hexagonal Architecture. Here's a breakdown of the layers and how they work in practice.
To demonstrate implementing Hexagonal Architecture layers in code, I'll write an simple web-application backed by a Lambda function, which serves API Gateway requests and communicates with both DynamoDB and S3, using TypeScript, as it's the language I am most familiar with. While Hexagonal Architecture is particularly well-suited for typed languages, it is language-agnostic and can be implemented in any language or framework of your choice.
I have used InversifyJS, a library used to create inversion of control (IoC) container for TypeScript. An IoC container uses a class constructor to identify and inject its dependencies
Core Logic (Domain)
The core business rules reside here. The domain layer is completely isolated from AWS-specific code or other external dependencies, making it easy to test and modify.
import { injectable, inject } from "inversify";
import TYPES from "../../container/types";
import IRepository from "../../interfaces/repositoryIF";
import IStorage from "../../interfaces/storageIF";
@injectable()
class HelloWorld {
constructor(
@inject(TYPES.Repository) private repository: IRepository,
@inject(TYPES.Storage) private storage: IStorage,
) {}
async handler(_event: any): Promise<any> {
//get data from storage
const storageData = await this.storage.get("123");
//update repository with new data
await this.repository.update("dummy-id", {
name: "dummy-name",
age: 20,
storageData,
});
}
}
export default HelloWorld;
Ports
The ports layer coordinates interactions between the domain and external services but doesn't contain direct calls to those services. It is agnostic about what the downstream service is called. In oriented-object programming, a port can be directly related to an interface.
export default interface IRepository {
get(id: string): void;
update(id: string, data: any): void;
}
export default interface IStorage {
get(id: string): void;
}
Adapters
- Inbound Adapters: These handle incoming requests, such as API Gateway events or other triggers, and pass them to the core logic. This is also the entry file for the Lambda Function.
import { APIGatewayProxyEvent, APIGatewayProxyHandler, APIGatewayProxyResult } from "aws-lambda";
import { container, TYPES } from "../container/inversify.config";
import HelloWorld from "./hello-world/helloWorld";
export const helloWorld: APIGatewayProxyHandler = async (
event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
const lambda = container.get<HelloWorld>(
TYPES.HelloWorld,
);
return lambda.handler(event);
};
- Outbound Adapters: These manage outbound calls, like calls to databases (in this case, DynamoDB and S3) or third-party APIs, abstracting them through well-defined interfaces (ports).
import { DynamoDBClient, GetItemCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
import { injectable } from "inversify";
import IRepository from "../../../interfaces/repositoryIF";
@injectable()
class DynamoDbRepository implements IRepository {
private client: DynamoDBClient;
async update(id: string): Promise<void> {
const updateItemCommand = new UpdateItemCommand({
TableName: "example-table",
Key: {
id: { S: id },
},
UpdateExpression: "SET #name = :name",
ExpressionAttributeNames: {
"#name": "name",
},
ExpressionAttributeValues: {
":name": { S: "dummy-name" },
},
});
await this.client.send(updateItemCommand);
}
async get(id: string): Promise<void> {
const getItemCommand = new GetItemCommand({
TableName: "example-table",
Key: {
id: { S: id },
},
});
await this.client.send(getItemCommand);
}
}
export default DynamoDbRepository;
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { injectable } from "inversify";
import IStorage from "../../../interfaces/storageIF";
@injectable()
class S3Repository implements IStorage {
private client = new S3Client({});
async get(id: string): Promise<void> {
const getItemCommand = new GetObjectCommand({
Bucket: "example-bucket",
Key: id,
});
await this.client.send(getItemCommand);
}
}
export default S3Repository;
The full example can be found here.
By isolating each concern, the Lambda functions become much smaller, enabling us to enforce least-privilege permissions and streamline testing. Each function now interacts with its dependencies through well-defined interfaces, making it easier to manage and test independently. This is what the Hexagonal looks like in the example above:
The modular, scalable, and flexible
Using Hexagonal Architecture, it is possible to gradually decouple the monolith into smaller and independent microservices, for example:
This approach improves a Lambda-based application and brings many potential benefits:
Simplified Maintenance: Clear boundaries allow for focused modifications without fear of inadvertently breaking other parts of the application, optimizing package size, and reducing deployment and cold start times.
Clarity and Separation of Concerns: Each Lambda function has a single responsibility, making the codebase easier to read and navigate, and easy to enforce the least privileges principle.
Testing Efficiency: Testing becomes simpler, as services can be isolated and mocked cleanly.
Reusability: Since adapters are cleanly abstracted, they can be reused across multiple functions, reducing development redundancy.
It isn't all smooth sailing…
While this transition has numerous advantages, some challenges accompany the move to microservices using Hexagonal Architecture:
How to decouple: Breaking apart a monolithic Lambda into separate microservices requires careful thought about how to decouple services and deploy them effectively. Ensuring that each service is truly independent can be difficult.
Testing Overload: With multiple services and adapters, testing can grow exponentially. The question arises: where to draw the line with testing? The focus shifts to defining clear boundaries for the unit, integration, and end-to-end tests.
Repository Management: As the codebase grows, it's crucial to enforce strict practices against code duplication and maintain clear documentation.
Traceability: With the application composed of several microservices, it can become tricky to trace a request and log, instead of handling it as a single service, you need to make sure the request is been traced across all your microservices.
Final thoughts
Refactoring from a monolithic Lambda to a microservice-based Hexagonal Architecture setup involves a significant initial effort, but the rewards make it worthwhile:
Reusable Adapters: Once built, adapters can be used across multiple Lambda functions, making development faster and more consistent.
Simplified Testing: With a clear separation of concerns, testing becomes easier and more reliable. By abstracting dependencies, we eliminate the need for complex Jest mocks, focusing instead on testing business logic directly.
Enhanced Flexibility: Adapters provide a modular approach, so if there's a need to swap AWS services or add new integrations, the core logic remains unaffected. This adaptability allows for seamless changes without impacting the entire application.
Refactoring your Lambda setup with Hexagonal Architecture can transform a complex, tightly coupled monolith into a streamlined, testable, and flexible microservice ecosystem, providing a solid foundation for future growth.
How are your experiences with such a migration? Leave a comment below!
Top comments (0)