I had the privilege of being a speaker at the AWS Paris Summit this year (2024) with the opportunity to share my insights about how to build a clean and evolutionary serverless architecture on AWS. Here is a summary of my talk.
N.B.: Code examples can be found here : https://github.com/welcloud-io/wio-clean-serverless-architecture
What is architecture?
In the software world, there is no consensus on an architecture definition. Ask three people, and you'll likely receive four different answers... minimum.
One of the definition I like the most is this one from Grady Booch:
All architecture is design but not all design is architecture
So, it's difficult to make the difference between design and architecture. For example the same diagram with boxes and arrows could represent both.
Fortunately, Grady Booch adds:
Architecture represents the set of significant design decisions that shape the form and the function of a system , where significant is measured by cost of change
Meaning the more expensive it becomes to change a design decision in the future, the closer you are to an architecture decision.
And, from my point of view, one of the architect's role is to spot these decisions, and make the cost of change as low as possible.
What is serverless?
There are many serverless services on AWS and their number is continually growing. However, the best way to explain what is serverless is to present on of the most popular service on AWS in the next section: AWS Lambda.
What is AWS Lambda?
To set the stage, let's remember that, before cloud computing, when you wanted to run a web application for your users, you had to order a physical server, unpack it, plug it, configure it,... into your own data center. That took a lot of time and the cost of change was high.
Then, came virtual machines. They started the cloud computing era, with disruptive services like Amazon EC2 (Elastic Compute Cloud) allowing you to start, stop servers without having your own datacenter. Moreover, you only paid for what you used and that reduced the cost of change.
Then, came application containers. They simplified software deployment, ensuring that an application package runs the same way on any machine. However, in order to scale a large number of containers and deployments you needed to have an orchestrator like Amazon ECS (Elastic Container Service), an equivalent of Kubernetes.
Finally, came the functions as a service, named Lambdas on AWS. That made things even simpler. You write your code, and you push it to the Lambda service. It scales automatically and you pay only for the execution time (if the Lambda lasts 2ms, you pay only for 2ms, which is very, very low price).
In short, when you run a Lambda, it starts a container, which runs on a virtual machine, which runs on a physical server.
And even if serverless means less servers to manage, it also means less scalability to manage, less availability to manage, less security to manage and often less network to manage. All these things are now AWS responsibilities.
Now, let's use 2 other serverless services on AWS in order to quickly create a simple architecture.
The first service is Amazon API gateway that will allow calling my Lambda from anywhere in the world via an URL endpoint.
The second one is DynamoDB, which is a document oriented database that you can use in a pay per request mode. It is also a database which is very simple to start with.
What is the CDK?
Creating an AWS Lambda, an API Gateway and a DynamoDB table in the AWS console takes a few minutes each. However, the more of these components you have in your architecture, the more challenging it becomes to manage them manually. And the cost of change increases.
Fortunately, creating an architecture on AWS can be completely automated using IaC (Infrastructure as Code) techniques.
The oldest technique is to use an AWS CloudFormation template. The template is then used by the CloudFormation engine, which calls AWS apis to create the architecture that is described in the file.
Another way is to use SAM (Serverless Application Model). It is in fact a simplified syntax for CloudFormation templates dedicated to serverless services. So, you describe you architecture with SAM specific vocabulary which is translated into a CloudFormation templates and executed by the CloudFormation engine.
Previous techniques are nice, but they use declarative languages like YAML or JSON. So, it's difficult to create loops, use conditions or create abstractions.
The AWS CDK (Cloud Development Kit) aims to describe an architecture with real code (e.g. Typescript, C#, Java, Python). With the AWS CDK, you can create reusable components, leverage the power programming language, and benefit from features like autocompletion, code generation, and type checking in the IDE.
For example, here is how I create an architecture with an api gateway, a Lambda and a DynamoDB table:
simple_lambda = _lambda.Function(self, "SimpleLambda",
function_name="CleanServerlessFunction",
runtime=_lambda.Runtime.PYTHON_3_9,
handler="simple_lambda.handler",
code=_lambda.Code.from_asset("lambda"),
)
api = apigw.LambdaRestApi(self, "Endpoint",
handler=simple_lambda,
)
table = dynamodb.Table(self, "SimpleTable", table_name = 'CleanServerlessTable',
partition_key=dynamodb.Attribute(name="id", type=dynamodb.AttributeType.STRING),
removal_policy=RemovalPolicy.DESTROY
)
table.grant_read_write_data(simple_lambda)
And the code in my Lambda can be as simple as that:
def handler(event, context):
return {
'statusCode': 200,
'body': 'Success'
}
Then I deploy my architecture and the code of my Lambda using the 'cdk deploy' command in my terminal:
$> cdk deploy
CleanServerlessArchitectureStack: deploying... [1/1]
CleanServerlessArchitectureStack: creating CloudFormation changeset...
✅ CleanServerlessArchitectureStack
✨ Deployment time: 26.44s
Using hexagonal architecture with your Lambda function
Now that I know how to deploy a Lambda function with an API endpoint and a DynamoDB table, let's say I want to use this Lambda to insert or update a stock level in my DynamoDB table.
I also want to apply a simple business rule: when the stock level of an item is lower or equal to 1, I want to receive a notification on my mobile phone.
However, I do not know exactly what I will use in the future in this architecture. For example, I can start with Amazon SNS to send a notification, but not sure it will be the right option in the future.
I also want also minimize the cost of that kind of change. So, one good option is to use what we call a hexagonal architecture inside my Lambda function.
Hexagonal Architecture is a code structure where you isolate your business logic from the outside world, using ports and adapters.
Here is for example my api gateway adapter which extracts the stock_id and stock_level from the http request (the event) received by my Lambda function when called:
# ------------------------------------------------------------
# INPUT Adapters
# ------------------------------------------------------------
def api_gateway_adapter(event):
body = json.loads(event.get('body'))
stock_id = body['stock_id']
stock_level = int(body['stock_level'])
stock_update_input_request(stock_id, stock_level)
The adapter then calls the port, which simply calls the domain logic in this example:
# -------------------------------------------------------------
# INPUT Ports
# -------------------------------------------------------------
def stock_update_input_request(stock_id, stock_level):
stock_update(stock_id, stock_level)
And here is my domain logic, which processes my stock update.
# -----------------------------------------------------------
# DOMAIN LOGIC
# -----------------------------------------------------------
def stock_update(stock_id, stock_level):
update_stock_level(stock_id, stock_level)
if stock_level <= 1:
send_notification('Stock level is low')
Note that this domain logic does not make any reference to a DynamoDB table or an SNS topic. Which means that I could use anything else behind the scene.
Moreover, this code can be tested in complete isolation using mocking libraries (e.g. pytest-mock). Which is even more interesting when the domain logic is complex.
Now, the only thing I have to do is calling my API gateway adapter in my Lambda handler, which will call my port and then my domain logic.
# ------------------------------------------------------------
# LAMBDA HANDLER
# ------------------------------------------------------------
def handler(event, context):
api_gateway_adapter(event)
Make your architecture evolve with a minimum of impact
Let's say that now I decide to change my mind and I do not want to use an API gateway to update my stock, but a file full of stock references and stock levels.
I can do this by sending the content of my file into an SQS queue while using the same Lambda and the same domain logic.
So, let's add this SQS queue in the CDK template and make little changes to my code.
Adding an SQS queue to the CDK Stack is quite simple:
queue = sqs.Queue(self, "Queue",
queue_name = "CleanServerlessQueue",
)
queue.grant_consume_messages(simple_lambda)
simple_lambda.add_event_source(
event_sources.SqsEventSource(queue,
batch_size=1
)
)
Then the first change in the Lambda code is to create a new SQS adapter, which will read en SQS event (different from an API gateway event), and extract the stock id and the stock level from the event:
# ------------------------------------------------------------
# INPUT Adapters
# ------------------------------------------------------------
...
def sqs_adapter_receive_message(event):
message = event.get('Records')[0].get('body')
stock_id = message.split(';')[0]
stock_level = int(message.split(';')[1])
stock_update_input_request(stock_id, stock_level)
The second change is to update the Lambda handler with the new SQS adapter:
# ------------------------------------------------------------
# LAMBDA HANDLER
# ------------------------------------------------------------
def handler(event, context):
sqs_adapter_receive_message(event)
Then I do a new cdk deploy
in my terminal to deploy the SQS queue and the updated Lambda code.
When deployment is finished, I can use a simple script to read my file and send the content to my SQS queue, which will be consumed by my Lambda function. And scaling is managed for me by all the AWS serverless services!
So, here is my final architecture:
Conclusion
This article explains briefly how to make an evolutionary architecture with serverless on AWS. This is a broader subject that would take hours to explore and explain, however I hope you enjoyed this gentle introduction.
Links
https://aws.amazon.com/blogs/compute/developing-evolutionary-architecture-with-aws-lambda/
https://www.youtube.com/watch?v=kRFg6fkVChQ&t=2114s
https://docs.aws.amazon.com/prescriptive-guidance/latest/hexagonal-architectures/welcome.html