Introduction #
If you’re working in an architecture with multiple services talking to each other (call it microservices, SOA, or whatever), one pattern usually emerges quite quickly and also reemerges occasionally. I’m talking about the idea of providing a set of libraries that include clients for talking to the respective services. There are variations of this pattern, like sharing types for domain entities between services by putting them in a library. Just like Sam Newman also writes in Building Microservices, I also agree that this pattern is harmful. Let’s explore my reasoning by first diving into why this seems like a good idea at first glance.
Promised benefits #
The usual arguments in favor of this pattern are:
- We only have to implement the client once, we are following DRY.
- Engineers find it easier to call the service because they don’t have to write their own client.
- Updating clients is easy this way because we just have to bump the library version.
- It ensures we are calling the other service correctly.
I find some of these are false assumptions. And some of these are true, but come with bigger drawbacks than these advantages. Let me go into detail about each of them.
It is DRY #
For the uninitiated, DRY stands for Don’t Repeat Yourself. It’s a principle that says you shouldn’t have code implementing the same principle twice, but reuse one implemenation of that code.
It’s true that this way you only have to maintain one client implementation. However, that implementation must also satisfy the requirements of all client services. When your architecture evolves and your clients develop different needs, this means you’ll have to write more complex code that supports all those needs at the same time.
Ease of use #
I think this one is the truest statement of the bunch. It’ll always be easier to install a dependency and call a function than it is to call an HTTP endpoint. But on well-structured and documented APIs, I don’t find the difference to be all that big. I’ll present an alternative later in the article that is almost as easy as using a dependency.
Easy updating #
If anything changes on the server side, you just have to run a single command on the client to update the package responsible for calling the server. This is easy. However, there are two reasons why I think this is not a very strong argument.
Firstly, you still can’t do any breaking changes on the server side, because you cannot update the client and the server at the same time. So you still have to be careful about how you introduce changes to your APIs.
The second is that while this makes it easy to update, it also forces me to accept any and all updates that are introduced to the client, even if my specific service might not benefit from these. If you have a larger number of clients, you will have to make sure that every client’s needs are met by a single implementation. Depending on how diverse your clients are, this might be easy or very hard. In any case, I find it is often underestimated how much simpler a client can be if it is implemented to just specifically support the needs of a single service. Having a client supporting multiple services will inevitably have to be at least a little more abstract.
Calling services correctly #
This argument is a popular one among people that highly value type-safety. They will claim that having a library ensures that the call made from the client matches the interface that is present on the server. However, I think this is not true at all. The only thing it will guarantee is that the client request will match the API of the server at the time that the client was compiled.
Now, you could be forgiven for thinking this is practically the same. But remember that you usually have multiple environments like a staging, demo, production. And those can run different versions of your server, for various reasons. Maybe a deployment failed, maybe you update the demo environment less frequently. No matter the reason, having a shared library will not protect you to deploy a client with a version of the library that does not work with what is deployed on the server side.
If you want to ensure compatibility between client and server, you have to check their interface compatibility before a deployment. The best way to do that in my experience is contract testing.
Disadvantages #
Now that we’ve put the advantages into context, I want to highlight the disadvantages I see with the approach of the shared client library. Those are:
- Versioning is difficult
- You are dependent on the technology it is implemented with
- It introduces subtle coupling
Versioning hell #
Depending on how exactly you implement this approach, you can get into quite a bit of versioning hell. If every service has a dedicated library with a client for that service, you have a lot of library versions to check and update regularly. This creates a complex web of dependencies between different services.
You could decrease the impact of this by putting all client implementations inside a single library. But then you have a single point of failure and a massive shared dependency between all services. Also, you might want to publish the client library from the repository of the service that the client talks to, so that client and service change together. But you can’t do that if all the clients are part of a single library.
Technology dependency #
This is the most obvious drawback, but also the one depending most on your circumstances. Naturally, if you implement a client in a specific language, other services can only use that client if they are also written in the same language, or at least the same runtime. And I don’t think I have to convince anyone that maintaining multiple different implementations of these clients in different languages is a bad idea.
This might not be a big issue for you since you write all your frontends and backends in the same language, like is possible with TypeScript. However, how confident are you that it will stay like this forever? That you will never need to write a service in a different language, maybe for performance reasons? Or maybe at some point you want to have an iOS or Android app. Sure, you can write those in TypeScript as well, but if you want an excellent user experience, you’re usually forced to adopt the native stack of the platform instead.
I think there are situations where you can be quite sure you will not need another language, but don’t be overconfident when deciding this. We humans are historically very bad at predicting the future.
Coupling #
The most important problem with sharing clients between services is that it introduces a subtle coupling between the services. Naturally, we try to reduce coupling between microservices to the absolute minimum. So this is a problem for us. But many engineers fail to see that sharing a library with a client introduces coupling, so let me explain further.
Without a library, the only coupling that exists is that the client depends on the shape of the API of the server. This is not something we can get around, so this is the absolute minimum of coupling we can get away with. Now, if you also introduce a library that includes types/classes for the server’s business entities and logic to translate those entities to HTTP requests, those are now additional things that the client depends on. You might also decide to use the same type/class for the server and the shared client library. But this means that every change to the representation of the business entity also forces a change in the library, which in turn is also forced on the user of the library.
This might not seem very dangerous when you are starting out, but over time, you will make many changes to your services and to the shared libraries. And there will always be a temptation to put something in the shared library because it is the easiest way to solve a problem at that moment. Maybe you need some caching, maybe a translation function for a specific entity, or maybe add a header with logging information. The amount of responsibilities will inevitably increase over time, increasing the coupling further.
Each and every of these responsibilities is a potential source for breakage on every change. And it is a decision that you make for every user of the client, taking agency away from them and potentially not presenting the optimal solution for their usage patterns. The more services use one of those client libraries, the more difficult it is to introduce changes to the library.
I have personal experience with this downside. At one place I worked at, we had a shared client library for a service that used certain small helper libraries to implement the client. One of those helpers had a big breaking change one day and we had to spend quite some time to both update the library and update all the services that used it. And none of those changes had anything to do with the API itself, so it felt like a big waste of time.
What to do instead? #
But then what should we be doing instead? Should we implement a client in every repository where we need to call another service? While I don’t think that this is actually as bad as it sounds, there is also another solution that avoids this while also avoiding the disadvantages we just talked about so thoroughly.
Enter OpenAPI #
Have you ever heard of OpenAPI? It is a standard for API descriptions. It defines a format for JSON or YAML files that allows you to specify what endpoints exist on an HTTP API, what parameters they accept, what status codes they can return, and so on. Not only that, but it is a wonderful way to document APIs in a way that is both useful for tooling and also for humans, since you can also add descriptions in prose. Nowadays, many APIs publish an OpenAPI specification for their interfaces. For example, here is the OpenAPI specification for the PokéAPI.
For example, the endpoint to list Pokémon on the PokéAPI is described like this:
/api/v2/pokemon:
get:
operationId: pokemon_list
description: Pokémon are the creatures that inhabit the world of the Pokémon
games. They can be caught using Pokéballs and trained by battling with other
Pokémon. Each Pokémon belongs to a specific species but may take on a variant
which makes it differ from other Pokémon of the same species, such as base
stats, available abilities and typings. See [Bulbapedia](http://bulbapedia.bulbagarden.net/wiki/Pok%C3%A9mon_(species))
for greater detail.
summary: List pokemon
parameters:
- name: limit
required: false
in: query
description: Number of results to return per page.
schema:
type: integer
- name: offset
required: false
in: query
description: The initial index from which to return the results.
schema:
type: integer
- in: query
name: q
schema:
type: string
description:
"> Only available locally and not at [pokeapi.co](https://pokeapi.co/docs/v2)\n\
Case-insensitive query applied on the `name` property. "
tags:
- pokemon
security:
- cookieAuth: []
- basicAuth: []
- {}
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/PaginatedPokemonSummaryList"
description: ""
The $ref: "#/components/schemas/PaginatedPokemonSummaryList"
is a reference to another part of the document. You can use those references to avoid repeating the same payload schema several times.
Now OpenAPI is great for documentation, but it also allows you to generate clients automatically that comply with the spec that you present as an input. So if you create good documentation for your API, you also get a client for that API for free.
How to use it #
There are a few things we need to do to take full advantage of this approach. First, we need to agree in our organization that all services will publish OpenAPI specifications for their APIs. The specifications need to be in a place where every engineer can easily get them. In organizations where I can grant at least read-access for all repositories to the engineers, I usually just define a conventional path where the spec should be put in the repository of the service that implements the described API.
Next, we need to make sure that the API is actually describing the interface of the service. Since the spec is just a JSON or YAML file, this does not enforce anything. And it’s really easy to forget updating the spec after you make a change to your API. An easy way to make sure that the API and the spec match is to use some kind of framework that generates the spec from your API code. In Node.js with TypeScript, Fastify has a plugin that uses the schema of your routes to also generate an OpenAPI spec.
This works pretty well. But let’s assume your web server or framework of choice does not offer anything like that. Another easy way to achieve this is to leverage your test suite. During the automated tests of the HTTP API, you can just read the OpenAPI spec and check every request and response against the specification, failing the current test if any of them do not match the spec. An easy way to do this in Node.js is jest-openapi. If you already have tests for your HTTP API and an OpenAPI spec, then this literally adds one line of code per test. Like this:
import jestOpenAPI from "jest-openapi";
// Here you have to load your OpenAPI spec.
jestOpenAPI("openapi/spec.yml");
describe("GET /example/endpoint", () => {
it("should satisfy OpenAPI spec", async () => {
const response = await fetch("http://localhost:3000/api/v2/pokemon");
expect(response.status).toEqual(200);
// This is what checks the response against your OpenAPI spec.
expect(response).toSatisfyApiSpec();
});
});
Now that we know that our spec really describes our implemented API, we only need to solve how the client can call the API. As I already said earlier, we can leverage the specification to generate a client in whatever language we want. For that, we just copy the OpenAPI specification file from the service that defines it into the codebase of the service or client that wants to call the API. In TypeScript, my preferred tool is openapi-typescript. It really takes advantage of TypeScript’s type system, allows adding middlewares and authentication, and only weighs about 5kb. Generating a client with it is just one line on your terminal:
npx openapi-typescript ./openapi/spec.yml -o ./openapi/spec.d.ts
And using it is similarly simple:
import createClient from "openapi-fetch";
import type { paths } from "./openapi/spec";
const client = createClient<paths>({ baseUrl: "http://localhost:3000" });
// The URL is autocompleted here due to the generated types.
// It also enforces any required headers and returns a discriminated union
// of either the error or the success result.
const { data, error } = await client.GET("/api/v2/pokemon", {
headers: { Authorization: "some-token" },
});
And now we are ready to do calls, without having introduced any form of shared library. Success!
Why is this better? #
Since I already spent a lot of time explaining the shared library’s disadvantages, you already probably see how we avoid many of them with this approach. But at the risk of repeating myself, let me explain why this is better than sharing a library with a client implementation.
Open for any tech stack #
The generator we used to get a client implementation is by far not the only one out there. There is pretty much a generator for every major language. So in a polyglot environment, we can easily generate clients for any of our services in seconds. This also allows each team to choose for themselves which tech stack suits their problem best. While I think there is great benefit in keeping tech stacks across teams consistent, there is usually a need for at least some variation. And you might always have that one really critical service that needs to be really performant, making it a great candidate for a rewrite in Rust.
No publishing/updating lifecycle #
Obviously, if you don’t have any library, you also don’t need to publish or update it. Depending on the amount of libraries, this saves quite a bit of CI/CD work. And even if you can automate large parts of this lifecycle, it’s still there, it still needs to be maintained, and it can still break. Having no code at all will always be easier to maintain.
No single source of truth #
This might be a bit counter-intuitive, but I really like that there is no single source of truth. Since we copy-paste the OpenAPI specification from the server, each client ends up with their own version of the specification. You could even delete parts of the specification that you’re not using on your client.
Many engineers shudder at the thought of this, because it is a violation of DRY, and they need to maintain all of those duplicated specs manually. But the mistake is assuming that those specs need to be maintained. We should be trying to avoid making breaking changes to our APIs anyway. So unless there is either a breaking change or I need to call a new endpoint, why would I update my local specification if I can do everything I need with it? There is no reason at all to do this.
This creates a situation where every client has their own truth for what the API of the server looks like. They basically only document their assumptions about the API. As long as the server satisfies every client’s assumptions, it doesn’t really matter that they are different. If one client is missing a field of the response in his spec, but the client doesn’t intend to use that field, then we don’t have a problem.
Since this is not a single source of truth, I like to call this a “distributed truth”. And as the name implies, I also think this is a very suitable solution for a distributed system, which is what a microservice architecture is.
Potential drawbacks #
Every solutions has pros and cons, and this one is not an exception. So while I’m convinced this is the better way to do this, I still want to talk a bit about the challenges with this approach.
You need to create the specs #
This one is somewhat obvious, but depending on your organization, it can be challenging to make engineers write documentation. For this approach to succeed, you need to convince your engineers to document their APIs using the OpenAPI standard. And also to document it well and thoroughly. For example, if you only document success responses and not the possible error status codes, this greatly diminishes the value of the specification.
Having said that, getting a client for free is usually a good incentive to write such a spec. And the payoff for good documentation is well worth the effort even when you don’t use it to generate clients from it.
Only works with REST APIs #
OpenAPI is really focused on documenting REST APIs that use JSON or XML. If you use something like GraphQL or gRPC, OpenAPI doesn’t really work for that. So it’s not a solution that you can employ. Having said that, those solutions usually come with their own way of documenting the APIs and generating clients for them. So it’s a better idea anyways to use what they provide.
Where client libraries work well #
I have mostly worked in environments where there is one service per repository. In this case, I find the OpenAPI approach far superior to the shared client library. However, if you have a monorepo where all clients and the server are in the same codebase, this changes things. You can change the client and server in the same commit, and you can proceed to deploy them entirely together. I can see the approach of sharing a client work very well here. Although this technically wouldn’t be a shared client library, because you just import the same code in both client and server without making it a library.
Conclusion #
Now you should have a good overview of the disadvantages of sharing client implementations using a shared library. You should also know how to do it instead by leveraging OpenAPI specifications.
I want to emphasize that this aims specifically at client libraries. I still think there is a place for shared libraries between microservices if those libraries implement true cross-cutting concerns, like logging, monitoring, or caching. You still have to be careful to stay with a general-purpose implementation of these concerns and not introduce specifics of any service in them, but as long as you do that, shared libraries are a great use case for that.
If you really bought into this and want to try it yourself, start by searching for OpenAPI integration for your current HTTP server or framework and your test framework. This is usually a good place to start when you don’t yet have any specs. Then search for some generators for clients in your tech stack. There are usually plenty to choose from. Once you found the tools you want to use, just start adding a spec for a single, simple endpoint and see how it feels.
I hope this was valuable to you and that I could convince you of my perspective on this topic. If not, I’d love to hear from you why you still think shared client libraries are a good solution for this and what your experience with them is.