Make your microservices converge

How to share code, configuration and dependencies between services

Théo Carrive
Cheerz Engineering
9 min readJan 9, 2019

--

From nothing to a monolith

In our life of developers, we start a project with just one very small piece of code with a very small scope.

With the time, thousand of requests from marketing later, your codebase has grown, to become a fully grown project. And very often, this fully grown project turns into a kind of monster with thousands of features and even more classes.

This is the creepy monolith that everyone is talking about, and tries to get rid off.

This situation has many flaws:

  • Huge codebase
  • Very little isolation
  • Tight coupling between all business logic
  • Very hard to scale because the project has many different needs and constraints
  • It’s almost impossible for one single person to have extensive knowledge of the codebase and of the side effects code changes could lead to.

I won’t go further into detail why one would want to run away from a monolith, many articles on the web explain that quite well already.

OK, so we have a monolith, so what do we do?

We split.

The natural process of cell division

From one monolith to many services

This split is the microservices that we are going to talk about.

To sum-up what microservices are, they consist of different isolated services (ideally: different codebases, different hosting, different deployment pipelines), communicating together with APIs:

Services communicating together with APIs

At Cheerz, for instance, this is what our microservices architecture looks like:

Cheerz services architecture

(If you're interested, we go much more into the details of our architecture in this article)

Great. Now, we have :

  • Code isolation
  • Simplified deployment pipelines
  • Shorter build times
  • Simpler onboarding

And especially, we can scale services individually, and thus, much more easily and in a more optimized way.

Great.

But…

Each time we had to create one of those microservices, it has been a huge pain to do all the boilerplate to handle: monitoring, logging, deployment, test configuration, the toolchain, administration tools, authentification, etc.

We clearly have a problem with boilerplate when we spawn up a new microservice.

But boilerplate is not the main problem we have here. With time, each change that we make to CI, project configuration, linter, dependencies, etc., will be done on a per-codebase basis. Thus, with the time, we have more and more discrepancies between them. And this is not only valid for configuration, tooling, and dependencies, but also for a lot of small utils that we develop per codebase.

What we have here is the phenomenon of divergence between our microservices.

Here come the divergence

We end up with a collection of microservices that we would expect to be of the same species because we all created them to be based on the same technological stack, but we find ourselves with :

divergence between services

By splitting our microservices, we have lost an easy way to have one single version of the standard toward which we make our microservices go.

The question we have to ask ourselves now is:

How to make our microservices converge again?

For that, we have a few leads.

The bootstrap project

First, we could at least prevent us all the boilerplate needed to setup a new service, so that at least, we start the life of a new service the right way. That is to say, we could create a base project for our company, that will already have everything configured. This is basically what Thoughtbot did with their “Suspenders” project: https://github.com/thoughtbot/suspenders.

This is a good start, but this doesn’t really solve our problem, that will happen later in the lifecycle of our codebase.

The middleware layers

Another solution would be to extract all the logic we can and use a maximum number of tools shared by all of our microservices.

This would be what serverless architecture tend to do, with providers like Google Cloud or AWS, or that you can setup with platforms like Kong:

This is interesting, but first, this can strongly lock you in a cloud provider, and especially, this does not solve a big part of the problem, that is more related to the business logic.

We need a homogenization solution that is closer to the codebase.

Share some code with submodules

This is interesting. It could solve a big part of the problem, however, we would try to avoid sub-modules for many reasons.

  • Git submodules can be hard to deal with on a daily basis, especially for junior programmers who can see this as an additional complexity on top of the regular git flow.
  • Integrating them properly could require a lot of small hacks.

Open source your libraries

Oh! This one is nice. If we would have a published a new library each time we wanted to share some code or configuration between our projects, integration would be seamless thanks to package managers (Bundler, Yarn, Cabal, etc.), and we would have good isolation.

However, if this means that each time we want to share some code, we have to:

  • Create a new library
  • Create a new repository
  • Configure the library, specs, and pipeline (CI, linting, …)
  • Publish the library
  • Integrate it into the project

This seems like a lot of overhead. Even more than the overhead of duplicating code. And thus, we can think that people won’t naturally tend to this solution since it does not seem that efficient in the end.

In addition to that, a lot of the problems that we want to solve are very company-specific (configuration, business-logic helpers, dependency management, …), and thus, won’t have a lot of interest for the open-source community

Make your corporate library

What we can do, is to create one library, and only one library, that will be the “corporate library” in a sense that this will be the one centralizing the standard of our company.

Let’s call it “cheerz_on_rails”.

This library will hold for us:

  • All the dependencies shared by our microservices: byebug, newrelic agent, bugsnag agent, capistrano, …
  • All the configuration shared by our microservices, including the configuration of the previously listed dependencies (bugsnag, newrelic, etc.) but also linting (rubocop), rspec, and plenty others.
  • Simple utils that we re-use from one project to another: helpers to manipulate data structures, errors, …

If at some point, we see that some part of this corporate library could have an interest by itself for the outside world, we will continue open-sourcing it, and we will then include it as a dependency of our corporate library.

That’s it, we have our final candidate. Now, let’s implement it.

Make your corporate Ruby library

In the following, we explain how we have implemented it for a Ruby stack. If implementing it for other stacks is different, the methodology remains the same.

First, we need to create the gem.

bundle gem cheerz_on_rails

That’s it. Bundler takes care of the job and creates the gem for us. We now just have to create a repository on Github and configure our project to track it.

Now, we need to add this gem to all of our projects.

Adding a private gem is very simple, we can just specify the path to its Github repository, and bundler will fetch it directly through SSH:

./microservice/Gemfile:(...)gem ‘cheerz_on_rails’, git: 'git@github.com:cheerz/cheerz_on_rails.git', branch: :master(...)

Great.

Now, the question is: What do we put inside this gem?

First of all, we are going to manage common dependencies with this gem.

In the gem project, there is a “cheerz_on_rails.gemspec” file, in which we can specify the dependencies we want:

./cheerz_on_rails/cheerz_on_rails.gemspec:(...)spec.add_dependency ‘bugsnag’, ‘~> 6.10.0’
spec.add_dependency ‘config’, ‘~> 1.7.0’
spec.add_dependency ‘newrelic_rpm’, ‘~> 5.6.0’
spec.add_dependency ‘pry’, ‘~> 0.12.0’
spec.add_dependency ‘pry-rails’, ‘~> 0.3.0’
spec.add_dependency ‘rubocop’, ‘~> 0.62.0’
(...)

Super. We now share dependencies versions through our different projects, thanks to this gem.

Now, we want to share boilerplate configuration. One very simple example is how we setup Bugsnag to report errors:

./cheerz_on_rails/lib/cheerz_on_rails/bugsnag.rb:require 'bugsnag'module CheerzOnRails  module Bugsnag    extend self    def setup(api_key:)
app_version = capistrano_release
::Bugsnag.configure do |config|
config.api_key = api_key
config.app_version = app_version if app_version.present?
config.notify_release_stages = %w[production staging]
config.ignore_classes << ActiveRecord::RecordNotFound
end
end
private def capistrano_release
return if !Rails.env.production? && !Rails.env.staging?
current_dir = Dir.pwd
return unless current_dir =~ %r{releases/([0-9]+)\z}
Regexp.last_match(1)
end
endend

And now, in our microservice, if we want to add Bugsnag, well configured, we just have to add the following initializer:

./microservice/initializers/cheerz_on_rails.rb:CheerzOnRails::Bugsnag.setup(api_key: Settings.bugsnag.api_key)

Great. We see that we can share code and dependencies. The only thing that remains is to share pure configuration. Example: Linting!

We want all of our projects to share the same linting configuration. Thus, we add the rubocop configuration to our Gem:

./cheerz_on_rails/cheerz-rubocop-main.yml:inherit_mode:
merge:
- Exclude
AllCops:
CacheRootDirectory: 'tmp/cache'
Exclude:
- 'db/schema.rb'
- 'db/**/schema.rb'
TargetRubyVersion: 2.3
Layout/AlignHash:
EnforcedHashRocketStyle: key
EnforcedColonStyle: key
EnforcedLastArgumentHashStyle: ignore_implicit
(...)

And now, in our microservice, we just have to reference it in our rubocop configuration:

./microservice/.rubocop.yml:inherit_gem:
cheerz_on_rails: .rubocop.cheerz-main.yml

And voilà!

🎉🎉🎉🎉🎉🎉🎉

Conclusion

We now have a library that allows us:

  • To homogenize dependencies
  • To share code between codebases
  • To share configuration

This approach has the great benefit of giving each developer a direction of where we want to go. What are the libraries that we want to use everywhere? What are the pieces of configuration that we chose? What is the style that we try to enforce?

Developers don’t have to ask themselves “OK. I’m on the microservice XX, what are the debugging tools that are setup? Which version? What is the style guide already?”

This makes it way much easier for everyone to jump from one project to another. Homogenization removes mental workload, for our greatest happiness!

Going further

  • When we presented this approach, someone asked us: “How do you deal with this problem, when the microservices use entirely different technology stacks?”. Unfortunately, this question is a bit more complex and would require another article for us to answer it. This might happen soon ;)
  • If you try to do the same, you might notice that iterating on a Ruby gem of yours while checking how well it integrates into your project can be a bit of a pain. In this article, we explain how we try to solve this problem.
  • At some point, we thought that having one single gem for all environments can be a problem if we don’t want to include dev tools in your production environment. For that problem, we thought about splitting the gem into two: one for dev+test, one for staging+production.
  • With the time, we thought that our corporate gem could grow too much. If this happens, we plan to split it, exactly like we split our initial codebase in the first place.

Thanks to Maxime Garcia with whom I have made this work!

Do you want to see more than what we’ve shown here?

Don’t hesitate to contact us, it be a pleasure to talk, or even to welcome for you for a coffee at our office (France -> Paris -> Place de Clichy)!

Just so you know, we are hiring engineers! Don’t hesitate to contact us, or check our page here!

--

--