There has been a paradigm shift in the software architecture of web applications in recent years. There is a move away from monolithic structures towards heterogeneous architectures that are characterized by the implementation of various microservices. This approach is also recommended for larger, complex, serverless, and cloud-native web applications for Amazon Web Services (AWS). Usually, the infrastructure code for different technologies, the business code, and the necessary CI/CD processes are combined in one application for each service using the AWS Cloud Development Kit (CDK). These applications are typically housed in their own repository.
The division into different microservices using domain-driven design proves to be useful even for individual teams, within partial projects, or for smaller projects. Sometimes the team takes responsibility for multiple services, for organizational reasons.
Parallel to this structuring, however, there are recurring challenges in the area of infrastructure code and technical solutions for cross-cutting aspects that require uniform and centralized provision. But this creates a considerably larger workload for individual teams in terms of providing different library versions, managing dependencies, and maintaining CI/CD processes in different repositories.
One approach to overcoming these challenges is to consider using a monorepo. In the context of microservices architectures based on the CDK, the question arises: Why not combine all services and dependencies in a common repository without creating a monolithic structure?
Figure 1: From monoliths to microservices in multi-repos and back to monorepos?
Figure 1 shows an example of such a scenario. An application originally developed and grown in a repository as a monolith was initially converted into a microservices architecture with multiple repositories. This architecture consists of various CDK applications and libraries. Finally, as part of the software evolution, the individual components and services were merged into a monorepo. This is just an example from an existing project and not a general recommendation, although such migration occur frequently in real life. At the start of the project, you should be aware of the appropriate strategy and, after careful consideration, can start directly with the monorepo approach. Alternatively, there might be clear reasons not to use a monorepo and instead opt for a multi-repo approach. Let’s take a closer look at why we want to choose monorepos and what the consequences are. In the second part, we will examine in more detail about how a monorepo strategy can work effectively with AWS CDK and the Nx build system.
Advantages of monorepos
The key strength of monorepos lies in its function as a single source of truth. This results in many further advantages:
- Simplified dependency management: The repository houses the entire code base and each component is integrated with its main version, which makes dependency management significantly easier. This also makes the use of artifact repositories (e.g. CodeArtifact, Nexus) superfluous.
- Simplified access: Teams can collaborate easily as they have an overview of the entire repository.
- Large-scale code refactoring: Atomic commits in the entire code make cross-module refactorings and implementations considerably easier.
- Continuous deployment pipeline: No or little new configuration is required in order to add new services.
Dealing with possible disadvantages of monorepos
Of course, there are consequences when using monorepos. Some disadvantages can be offset from the start if certain conditions are met. Ideally, this includes an agreement on the technologies used and the selection of similar CI/CD processes across all services. Consideration must also be given to the fact that access control cannot usually be set at a fine granular level and that everyone has access to the entire code base. This can be especially important when several teams work in a monorepo. Otherwise, many of the advantages of monorepos can quickly be lost, and a multi-repo approach might be more appropriate.
There are three other limitations or risks that must be taken into account:
Limited versioning
All services are always available in their current versions. A service cannot simply reference an older version of another service. When designing microservices, particular attention must be paid to the interface contracts of the services within the monorepo. These should ideally be semantically versioned and documented. Established standards such as OpenAPI, GraphQL, and JSON Schema support this. Ensure backward compatibility when using shared libraries, otherwise changes will require adjustments in all modules that use the library.
High coupling is possible
The advantages of a monorepo, namely fast and efficient collaboration through a central code base, can quickly turn into the opposite. This happens when services directly reference blocks of other services, or if code reusability is misunderstood, and leads to the business logic of the services being outsourced to shared libraries. This quickly creates an architecture with high coupling between the individual blocks. There is strong temptation to incur technical debts, especially when under time pressure when developing new features. If these accumulate, there is a risk that no further refactorings will be carried out for fear of breaking changes, which in turn significantly impairs the maintainability of the entire system.
Therefore, it is important to define clear rules and ensure that compliance to these rules is monitored and measured, ideally using static code analyses. The aim is for the services to have no dependencies on each other during the build time and instead communicate with each other at runtime via clearly defined interfaces. The interface contracts can be efficiently stored centrally as libraries in the monorepo.
Monolithic, lengthy CI/CD processes
If the entire code is located in a single repository, the CI/CD processes for automated testing, static code analysis, build, and deployment must run through the entire code with every change, in the worst case. With increasing project sizes, these long waiting times lead to frustration, which can have a negative impact on team performance.
In a microservices architecture, however, this should not be the case, because this contradicts the goal of considering each service individually. In the case of code changes in a service, only the necessary CI/CD processes for this service and its dependent libraries should be executed. The development should be as quick and isolated as if you were only working on the code base of a service in a repository. There are suitable tools, such as Nx, to implement this in a monorepo.
Learn more about this in Part 2.