A shared-nothing approach to serverless microservice ecosystems on AWS
In just under a year I’ve worked on several brand new projects at carsales. I was tasked with the design and implementation of the systems. I took this opportunity to harness the flexibility and scalability of AWS cloud native tools and shared-nothing serverless architecture.
In this post I will cover shared-nothing design patterns using AWS cloud native tools such as API Gateway, Lambda, SNS and DynamoDB.
The examples and scenarios used in this article are fictional and do not reflect actual project’s scope and purpose.
What is shared-nothing architecture?
To put it simply, shared-nothing architecture means no microservice should share any resources with another microservice. For example, no service or process should directly read/write to the database of another service.
This creates the overhead of providing CRUD API endpoints to access the data instead of directly reading it from database but the pros outweigh the cons.
Below are some of the advantages of shared nothing architecture:
- Elimination of a single point of failure. If a database is down it won’t impact the entire system.
- Flexibility with data store choices since each service abstracts the underlying technologies used.
- Scale based on the demands for each service.
- Internal data schema changes won’t impact other services.
- Ensures business logic and validation is applied to all data store/retrieved.
Let’s set some ground rules
Microservice architecture can become confusing and it’s not always clear what service should be responsible for what and when to create a new service and when not to. So I have come up with the following rules that I use to speed up my decision making and to ensure consistency across all services.
Each service should be responsible for one entity or concern or activity. For example if you need to persist files, that is one concern that can be turned into a service.
It’s important not to share any resources between microservice to be able to get all the benefits of this architecture. Each service should always listen to or emit an event using messaging services such as SNS and SQS or use APIs for any data access.
Avoid Dependency Loops
If service A depends on service B and service B depends on service A then you’ve got yourself into a dependency loop that is very hard to manage. Dependency loops create issues with deployments and maintenance of your services.
To break the loop, create a third service which acts as an aggregator. Service C will depend on service A and B which removes dependency between A and B.
Ensure your service is either exposing public or private APIs. Public APIs are exposed to authorised users on the internet while private APIs can only be called from within the VPC. If you need to call a private API from outside the VPC you should create a public facing API to proxy the API call.
Event Driven Pattern
Lambda functions are triggered based on an event and cannot run continuously. The event could be an SNS topic, API Gateway call or a DynamoDB stream. Design your application and microservice so they emit events to allow other services to be notified and execute appropriate logic.
Let’s build a shopping website
Now that we have set some ground rules, let’s tackle a real life scenario.
Let’s say we are asked to build a shopping website where users can purchase toys! We’ll be focusing on how to go about identifying different services involved.
Break it down
We are selling toys so we need a way to know what toys we have for sale. Each toy should be identified with an Id and it can have many other attributes. We can have a service responsible for storing and returning toys.
Toys Service will expose CRUD endpoints for the toy entity. It will also expose endpoints for searching for toys with different attributes. Since this service is exposing endpoints that you don’t want to be publicly available, we must keep this service private so we will need to have a public API to aggregate/proxy the requests to this service.
Each toy has multiple images and those images need to be hosted and available publicly through a URL. Instead of storing images with the Toys Service we can seperate the concern of storing files into a new service called File Manager Service.
Toys service will have a dependency on File Manager service to store all toy images.
We are not breaking any rules here because File Manager Service APIs can only be called from internal services. We are only allowing public access to our images through CloudFront.
Next we need to think about how to do checkouts and payments. Do we give this responsibility to Toys Service?
We need to break the problem down even more. When a user clicks checkout and enters their payment details they are effectively placing an order. An order now sounds more like an entity by itself. So let’s create an Orders Service which will handle CRUD APIs related to orders.
After receiving an order we need to process the payment and send a confirmation email with a receipt.
The rest of the architecture would look like this:
I’ve added a Payment Service which will handle processing payments and storing a record of the transaction and will call to Notification Service to send an email to the user. It also stores a copy of the receipt for archiving purposes using File Manager Service.
Now that we have decided what microservices we need to build we can look at how to architect our services using AWS native tools.
The example below shows how you can leverage DynamoDB streams to generate events that other services can listen to when a record has been stored or modified in the database.
We can use the above structure for Orders Service. We can configure DynamoDB to emit events when a new record is created and link that stream to call a Lambda function every time there is a new record. The Lambda function publishes an SNS message to notify any process that is listening to this topic about the new order. In this case Payments Service could be listening for new orders and will start processing the payment.
Using the above architecture we achieved the following objectives:
- Separation of concerns for storing an Order entity.
- Event driven non-blocking operations.
- Fault tolerance by taking advantage of DynamoDB stream and SNS’s
built-in failure retry features.
- Sharing no underlying resources.
Using concepts and disciplines of shared-nothing architecture and cloud native tools enabled me and my team to prototype exceptionally fast. It allowed my team to work in parallel, achieve faster delivery times, great agility in the face of regular requirement changes and impressive fault tolerance when data structures were updated. We were able to deliver a highly available, fault tolerant and cost effective solutions in a very short period of time.
In my next blog I’ll focus on how to implement and deploy the environment using CloudFormation and CodePipeline. Stay tuned and thanks for reading.