Skip to content

Microservices in AWS Migrating from a Monolith

Published: at 09:11 AM

The first rule about microservices is that you don’t need microservices (for 99% of applications). They were invented as a REST-based implementation of Service-Oriented Architectures, which is an XML-based Enterprise Architecture pattern so complex that XML is the easiest part.

At some point, microservices became this really cool thing that all the cool kids were doing for street cred. “Netflix does it, so if I do it I’ll be as cool as Netflix!” Folks, it doesn’t work like that.

What Are Microservices?

A microservice is a service in a software application which encapsulates a bounded context (including the data), can be built, deployed and scaled independently, and exposes functionality through a clearly defined API.

Let’s expand a bit on each characteristic:

Why Use Microservices?

Microservices exist to solve a specific problem: problems in complex domains require complex solutions, which become unmanageable due to the size and complexity of the domain itself. Microservices (when done right) split that complex domain into simpler domains, encapsulating the complexity and reducing the scope of changes.

Microservices also add complexity to the solution, because now you need to figure out where to draw the boundaries of the domains, and how the microservices interact with each other, both at the domain level (complex actions that span several microservices) and at the technical level (service discovery, networking, permissions).

So, when do you need microservices? When the reduction in complexity of the domain outweighs the increase in complexity of the solution.

When do you not need microservices? When the domain is not that complex. In that case, use regular services, where the only split is in the behavior (i.e. backend code). Or stick with a monolith, Facebook does that and it works pretty well, at a size we can only dream of.

Types of Microservices

There’s two ways in which you can split your application into microservices:

Overall, vertical slices is easier to understand, and easier to implement for smaller systems. The drawback is that if your system does 200 different things, you’ll need 200 services, plus support services and libraries. Functional services are harder to conceptualize, and it’s not uncommon to end up with a ton of microservices that have 50 lines of code and don’t own any data. If that’s your case, you’re doing it wrong. Remember that the split should be at the domain level, not at the code level. It’s perfectly ok for a microservice to be implemented with several services!

Don’t combine these two types of microservices! If you’re doing vertical slices, support microservices should be only for non-business behavior, such as logging. If you’re doing functional microservices, don’t create a service that just orchestrates calls between other microservices; either use an orchestrator for all transactions, or choreograph them. And don’t even think about migrating from one type of microservices to the other one. It’s much, much easier to just drop the whole system and start from scratch.

Splitting a Monolith into Microservices

Let’s see microservices in a real example. Picture the following scenario: We have an online learning platform built as a monolithic application, which enables users to browse and enroll in a variety of courses, access course materials such as videos, quizzes, and assignments, and track their progress throughout the courses. The application is deployed on Amazon ECS as a single service that’s scalable and highly available.

As the app grew, we’ve noticed that content delivery becomes a bottleneck during normal operations. Additionally, changes in the course directory resulted in some bugs in progress tracking. To deal with these issues, we decided to split the app into three microservices: Course Catalog, Content Delivery, and Progress Tracking.

Out of scope (so we don’t lose focus):

AWS Services involved:

Final design of the app split into microservices

How to Split a Monolith Into Microservices

Step 0: Make the Monolith Modular

The first step should always be to make sure your monolith is already separated into modules with clearly defined responsibilities. Modules should be well scoped, both in terms of functionality and in the code that implements that functionality. They should be cohesive, and lowly coupled to other modules. The level of granularity doesn’t matter much, though ideally you’d be splitting modules according to the concept of domains from Doman-Driven Design (you don’t need to apply the entirety of Domain-Driven Design). However, you can refine the scope and granularity when you start with microservices. For now, what’s important is that you have clearly defined modules with clearly defined responsibilities, instead of a bowl of spaghetti code.

For this example we’re going to assume this is already the case, but if you’re dealing with a monolith that’s not well modularized, that should be the first thing you do. If you commit all the way to microservices, you won’t really use the modular monolith. However, I still recommend you first work on separating it into modules, to make the overall process easier by tackling one thing at a time.

Step 1: Identify the Microservices

Start by analyzing the monolithic application, focusing on the course catalog, content delivery, and progress tracking functionalities. Based on these functionalities, outline the responsibilities for each microservice:

Step 2: Define the APIs for each microservice

Once you understand what each microservice needs to do, you need to design the API endpoints for each microservice:

API endpoints are how microservices define and expose their functionality to external components. Essentially, the API is what a microservice can do for the user or for other microservices. We already knew the responsibilities of each microservice from Step 1, with this step we’re expressing them in technical terms that other components can understand. We’re also documenting them in a clear and unambiguous way.

If you’re starting from a well-designed modular monolith, these APIs already exist as the APIs for services and interfaces for components, and you’re just re-expressing them in a different, unified way. If the starting monolith isn’t well modularized, you may find some of these APIs as functions, and you may need to add a few. In those cases it’s easier to first modularize the monolith, then split it into microservices.

API design is really important, and hard to do. We’re not just splitting the entire app’s responsibilities into groups that we call microservices. We’re actually creating several apps, that we’re then going to interconnect to produce the expected system behavior. We need to not only define those apps’ responsibilities well, but also design them in a maintainable way. Check out Fowler’s post on consumer-driven contracts for some deeper insights.

Step 3: Configure API Gateway for each microservice

Create an API Gateway resource for each microservice (Course Catalog, Content Delivery, and Progress Tracking). Point the different routes to your monolith’s APIs for now, since we don’t have any microservices yet. Update any frontend code or DNS routes to resolve to the API Gateways.

This isn’t a strict requirement, but I added it as part of the solution because it makes the switchover much easier: All we need to do is update the API Gateway of each microservice to point to the newly deployed microservice. Since everything else already depends on that API Gateway for that functionality, we’re just changing who’s resolving those requests. This way, we’ve effectively decoupled the API from its implementation. API Gateway also makes other things much easier, such as managing authentication for microservices.

Step 4: Create separate repositories and projects for each microservice

Set up individual repositories and Node.js projects for Course Catalog, Content Delivery, and Progress Tracking microservices. Structure the projects using best practices, with separate folders for routes, controllers, and database access code. You know the drill.

This is just the scaffolding, moving the actual code comes in the next step. The key takeaway is that you treat each microservice as a separate project. You could also use a monorepo, where the whole codebase is in a single git repository, each service has its own folder, and it’s still deployed separately. This works well when you have a lot of shared dependencies, but in my experience it’s harder to pull off.

Step 5: Separate the code

Refactor the monolithic application code, moving the relevant functionality for each microservice into its respective project:

The code in the monolith may not be as clearly separated as you might want. In that case, first refactor as needed until you can copy-paste the implementation code from your monolith to your services (but don’t copy it just yet). Then test the refactor. Finally, do the copy-pasting.

Step 6: Separate the data

First, create separate Amazon DynamoDB tables for each microservice:

Then update the database access code and configurations in each microservice, so each one interacts with its own table.

Remember that the difference between a service and a _micro_service is the bounded context. Each microservice owns its domain model, including the data, and the only way to access that model (and the database that stores it) is through that microservice’s API.

We could implement this separation of data at the conceptual level, without enforcing it through separate tables. We could even enforce it while keeping all data in a single table, using DynamoDB’s field-level permissions. The problem with that idea (aside from the permissions nightmare) is that we wouldn’t be able to scale the services independently, since DynamoDB capacity is managed per table.

If you’re doing this for a database which already has data, but you can tolerate the system being offline during the migration, you can export the data to S3, use Glue to filter the data, and then import it back to DynamoDB.

If the system is live, this step gets trickier. Here’s how you can split a DynamoDB table with minimal downtime:

I picked DynamoDB for this example because DynamoDB tables are easy to create and manage (other than designing the data model). In a relational database we would need to consider the tradeoff between having to manage (and pay for) one DB cluster per microservice, or having different databases in the same cluster. The latter is definitely cheaper, but it can get harder to manage permissions, and we lose the ability to scale the data stores independently. Aurora Serverless is a viable alternative, it scales very similarly to DynamoDB in Provisioned Mode. However, it’s 4x more expensive than serverful Aurora.

Step 7: Build and Deploy the Microservices

We’re using ECS for this example, just so we can focus on the microservices part, instead of debating over how to deploy an app. These are the steps to deploy in ECS, which you’ll need to do separately for each microservice:

I don’t want to dive too deep into how to deploy an app to ECS. If you’re not sure how to do it, here’s an article I wrote about it.

Step 8: Update API Gateway

For each API in API Gateway, you’ll need to update the routes to point to the newly deployed microservice, instead of to the monolith. First do it on a testing stage, even if you already ran everything in a separate dev environment. Then configure a canary release, and let the microservice gradually take traffic.

You might want to preemptively scale the microservice way beyond the expected capacity requirement. One hour of overprovisioning will cost you a lot less than angry customers.

User Interaction in the Monolith vs in Microservices

Here’s the journey for a user viewing a course in our monolith:

  1. The user sends a login request with their credentials to the monolithic application.
  2. The application validates the credentials and, if valid, generates an authentication token for the user.
  3. The user sends a request to view a course, including the authentication token in the request header.
  4. The application checks the authentication token and retrieves the course details from the Courses table in DynamoDB.
  5. The application retrieves the course content metadata from the Content table in DynamoDB, including the S3 object key.
  6. Using the S3 object key, the application generates a pre-signed URL for the course content from Amazon S3.
  7. The application responds with the course details and the pre-signed URL for the course content.
  8. The user’s browser displays the course details and loads the course content using the pre-signed URL.

And here’s the same functionality in our microservices:

  1. The user sends a login request with their credentials to the authentication service (not covered in the previous microservices example).
  2. The authentication service validates the credentials and, if valid, generates an authentication token for the user.
  3. The user sends a request to view a course, including the authentication token in the request header, to the Course Catalog microservice through API Gateway.
  4. The Course Catalog microservice checks the authentication token and retrieves the course details from its Course Catalog table in DynamoDB.
  5. The Course Catalog microservice responds with the course details.
  6. The user’s browser sends a request to access the course content, including the authentication token in the request header, to the Content Delivery microservice through API Gateway.
  7. The Content Delivery microservice checks the authentication token and retrieves the course content metadata from its Content table in DynamoDB, including the S3 object key.
  8. Using the S3 object key, the Content Delivery microservice generates a pre-signed URL for the course content from Amazon S3.
  9. The Content Delivery microservice responds with the pre-signed URL for the course content.
  10. The user’s browser displays the course details and loads the course content using the pre-signed URL.

Best Practices for Microservices on AWS

Operational Excellence

Security

Reliability

Performance Efficiency

Cost Optimization

A 4 minute explanation of Zero Trust, by its creator. Best 4 minutes you’ll spend today.

If you haven’t, go check the part about networking and service discovery of the ECS Workshop.

The best way to separate networking and discovery from application logic is through a Service Mesh. AWS has a service that does that, and an excellent workshop to learn about it.