Microservices Shared Libraries — Design and Best Practices
Yotam
Posted on October 8, 2022
Microservices is a buzzword that any software engineer has heard about.
We love microservices, right? This architecture helps you divide the application into small self-contained applications, with significant advantages, such as scaling faster and at lower cost, a smaller and readable codebase, developing and releasing features faster if planned correctly.
HOWEVER, it also introduces several difficulties, like
- communication — requires network communication between different microservices,
- debugging — the code and logs are distributed,
- more complex architecture — usually microservices are used with a domain-driven design that requires more effort to design correctly. Luckily there are many solutions and tools to help us to overcome these and other challenges we face when developing microservices.
Another challenge microservice architecture creates is code duplication, which will be the focus of this post, as well as how shared libraries help us reduce our code duplication.
Why shared libraries are important
Shared libraries are the key solution for code duplication between microservices.
One of the most common examples of the need for shared libraries is logging.
Logging can have custom logic, like formatting or hiding sensitive information, such as customers’ addresses and phone numbers.
Now imagine each microservice having its own implementation, how many developer hours will be wasted creating the same implementation? What if it’s not the exact same implementation across the different microservices?
Aggregating logs will become an even harder task, two similar log records may be labeled differently because of small changes in the implementation.
Another point worth mentioning is the recently discovered log4j vulnerability, which requires much more effort in microservice architecture, as you need to identify and fix per microservice.
Alternatively, if you have your own logging library that uses log4j internally, you only need to protect yourself from this vulnerability at a single point.
Logging appears in any microservice you create (hopefully), and it isn’t dependent on any of them, so it’s a great example for a shared library.
Other great examples for shared libraries are security, monitoring, async communication, and exceptions.
Why it is important to get it right
Microservices architecture has been created to decouple the different parts of the application (amongst other reasons). Shared libraries do the opposite — it’s a common code shared by all the microservices.
It means that if you don’t do it well, it has the potential to cancel out one of the biggest benefits granted by the microservices architecture! The result is a weird mix of parts, like Frankenstein’s monster.
Best Practices
Don׳t create a single library
There are several ways you can manage your shared libraries, it’s considered best to either create a different repository for each needed library or a single repository (A.K.A monorepo) with multiple libraries. The important thing is that in one way or another they are separated.
For example, introduce a single repository that includes a project for monitoring, security, logging — each of them will be self-contained (except if there are needed dependencies).
At Duda, we prefer to have a single repository that includes different projects, there are multiple reasons we like this approach, such as:
- Usually, each library (in our case anyway) is quite slim
- We can have a single Jenkins pipeline for the build and release of all the libraries
- It makes it easier to have dependencies (when needed) between libraries under the same repository
- When building a new release we create the same version for all the libraries, so when consuming the libraries we can have a single version for all of them Library structure Each library can be structured any way you want, but there are 2 possible structures that fit particularly well:
- A simple src folder containing all your code
- Dividing the library into 3 separate projects: api, impl (for implementation), and test-kit — with the impl and test-kit being dependent on the api project
Let’s dive deeper into the second structure…
- The api project contains all the interfaces and classes that are used by the library’s client
- The impl project has all the actual logic
- The test-kit project contains mocking and testing support — for example, if your library uses rest calls you will probably want to mock the request and response
The separation into 3 different projects allows you to encapsulate the library implementation details, with 3 major benefits:
- Avoids breaking the contract with the users when changing the implementation details
- You can use the impl project, and while testing be dependent on the test-kit. This introduces the library’s client to a much better experience while testing
- Build tool optimization — using the test-kit project doesn’t require loading the impl project. Even more, some build tools have cache mechanisms (such as gradle), so it doesn’t need to rebuild projects that have not been changed
Let’s take a closer look at a real-life example from Duda.
To have a unified behavior of feature flags across our microservices, we have a shared library that uses LaunchDarkly internally.
LaunchDarkly is a platform to manage feature flags, using it you can manage features in production, control which users have access to them and when.
If you have a problem with a new feature? No problem, you can turn off the relevant feature flag and it will be disabled in production until further investigation, no need to redeploy.
While testing our microservices, we don’t want actual network calls to LaunchDarkly and to get the real values, but defining the values of the relevant flags for each test case.
Instead each microservice will need to mock or create a test implementation, we already have it set up and ready in the test-kit project, all we need to do is to import it and we are ready to go.
To help you understand it better, here is a tiny snapshot from our code:
public class InMemoryFeatureFlags implements FeatureFlags {
public void setBoolean(String flag, boolean value) {
booleanFlagsMap.get().put(flag, value);
}
}
FeatureFlags interface implementation in flag-test-kit project
FeatureFlags — interface in flags-api
LaunchDarklyFeatureFlags — actual implementation in flags-impl (not in above snapshot), implements FeatureFlags methods, such as getBoolean(String flag)
InMemoryFeatureFlags — implementation in flags-test-kit that implements its methods. But instead of getting the actual flag from LaunchDarkly (or other platforms, the api and test-kit projects are not platform locked!) we get it from memory, and can set it using setBoolean method
My advice to you is to take your time while designing the api and test-kit, as it can save you a lot of time later!
Avoid backward compatibility break
The last thing you want to do is create a poorly designed library that will introduce potential backward compatibility breaks in the future.
Encapsulation
It is important to encapsulate the internal decisions and logic from the user, it’s essential to programming in general, and specifically to a shared library.
Let’s say you create a library with vendor-specific code, for example, a library to upload images to storage, it is critical you create the interface/classes that are used by the client generically.
You should avoid names such as “S3ImageUploader” (s3 is an Amazon service for file storage).
This is because, if later on you want to move to Azure Blob (Microsoft equivalent service for amazon S3), all your clients will need to fix the method signature.
It’s better to use names such as “ImageUploader” for the interfaces that are exposed to the user.
Mitigate the damage
Sometimes you must break the code, for example, you must replace a library used internally because of a newfound vulnerability that affects the output.
- Use semantic versioning, so your versions follow the pattern MAJOR.MINOR.PATCH, allowing your clients to upgrade the versions appropriately. Change in the major version let’s people know that this version might introduce a compatibility break, so they can do their research and decide whether to upgrade or not. Semantic versioning might be messy to manage manually, don’t worry Conventional commits to the rescue! (more about it later on)
- Another option is instead of modifying the existing interface behavior, introducing a new interface with the new logic inside — you can use parts or all of the existing code as well, mark the old interface as deprecated and don׳t support any new functionality to the old interface, but only the new one. This will help push your clients to adopt the new interface (with the new behavior)
Keep it clean!
Remember — many people might work on the shared library, don’t make it into a monolith!
Give a lot of thought before introducing a new library, and when you create one try to think how it might change and who might use it. Don’t be tempted to create it for your own specific needs, making it hard for others to use or extend.
Don’t write any domain-specific code inside! Even shared code that is business-related is probably not supposed to be there!
For example, a User model that begins the same for all the microservices is still domain-related logic, and it probably doesn’t need to be in the library, even if it means that each microservice that uses this model needs to duplicate it. The reason is that different microservices might need to change it in the future to fit their business needs. It makes no sense that they all work with the same model, it can introduce fields or logic that aren’t related to other MS or might even break them, if they want to rename or change some of the logic.
When using a single repository, we find Conventional commits to be very helpful to communicate the changes we introduce to the code.
TL;DR
Use conventions for the commits, such as starting each commit with a fix — for a bug fix (related to patch), feat — for introducing a new feature (related to minor in versioning), and BREAKING CHANGE — for (you guessed it) breaking change in the API (related to major in versioning).
This helps a lot when someone else tries to understand the history of the repository, what was done and where in the code.
There are many great tools to help with automation here, some of them are action-semantic-pull-request to enforce conventional commits and standard version to bump the version and create a changelog according to the conventional commits.
Well, that’s it!
After reading this article, you’re encouraged to try to create shared libraries that will help your organization avoid code duplication and a lot of wasted time fixing the problems introduced by poorly written shared libraries.
Best practices today might not be the best practices of tomorrow, so you are welcome to try new approaches, but only after you understand why and how we design and implement shared libraries the way we do today.
Posted on October 8, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.