Microservices for Startups
By Scott White
Microservices provide some surprising benefits over a monolith for small teams and startups. I’ve read many articles stating microservices are more difficult to manage and develop versus a monolith, and at a small scale, you won’t see any benefits. I’m currently on my second successful startup in a row that deployed microservices from day-one, and it’s not as hard or costly as these articles imply. There are also many advantages and even a small team can take advantage of them.
Setup
One of the main arguments against microservices is that you’re wasting time setting them up when you should be building your MVP. The reality is that when starting from scratch, your first microservice and your monolith are exactly the same. You’re going to need to figure out authentication, code structure, API conventions, call routing, monitoring and many more. If you’re encapsulating these functions into libraries, it’s trivial to call the same libraries from other microservices. The same goes for the OPS side where you’ll need to build out the same compute resources, load balancers, database setups and deployment schemes. The key for successful microservices is to just make sure all these processes can be replicated to a second service when the time comes. Modern DevOps practices around Kubernetes make this trivial, but it can be as easy as copying a few bash scripts if you don’t have the expertise or time to delve into the ops side of things. Creating a fast, easy and repeatable deployment process is vital with either microservices or monoliths. It can and should start simple and be built up over time.
The only additional setup you must do when using microservices is to choose a load balancer that can separate traffic to your different microservices based on the request. I’m a fan of RESTful APIs so that means I need something that inspects the path part of the URL and routes accordingly. When I did our first microservices startup in 2014, this was something we wrote ourselves, but today there are a variety of options that work out of the box. I started (in 2018) with a product called Traefik and recently switched to another called Ambassador. Both work great for microservice routing though we think Ambassador enables better observability that we need now as we’ve scaled.
The main takeaway here is that when you’re really starting from zero, most of the initial work for microservices and a monolith is exactly the same. Most of the corners that can be cut here aren’t worth the expense you’ll incur later. Like taking 10 minutes to throw up the monolith on a single EC2 instance booted from the AWS console. This may get you up and running fast, but it will fail quickly under scale from either the team or user growth. Taking a day or two to come up to speed on the managed Kubernetes of your cloud provider will be much less work than trying to learn it later when things are actively failing. When you’re really early, it can feel like a waste of time to be working on foundational infrastructure rather than business logic, but it’s an investment worth making. This rule applies to both monoliths and microservices.
Development
Separating your API across services can make data access more difficult by having to make many service to service (s2s) calls. A good way to architect any API is around the data it serves, which is why REST is a good convention. And a good way to architect your microservice is also around data locality as well. This means that at least for a while you’ll have minimal cross service calls. When we started building our API here I didn’t even bother figuring out how to make s2s calls for a few months because we didn’t need to. The client was happily making calls to what appears to be a monolithic API, but split between two services. There were no dependencies between the two (except for IDs that loosely coupled data together) so there wasn’t any need to figure out complicated s2s communication. This also implies that you shouldn’t overdo the microservices pattern. It’s ok to have multiple separate APIs hosted in the same microservice, especially if the work falls on ownership boundaries.
At some point you will have to start doing s2s communication and there are many articles talking about gRPC backplanes supported by service meshes. Figuring this out can be daunting and requires DevOps knowledge which you may not have available. With a small and inexperienced team, we also didn’t have enough resources to maintain both the public client API and another private one for s2s calls. I made the decision to have all of our s2s calls use the public API instead. And to be specific, the services make calls to other service APIs by calling directly to our public API DNS entry. This means we have no fancy internal routing whatsoever and the API for the clients is identical to the API our services use to communicate. When I first did this I couldn’t find anyone else using this technique and I thought “this won’t work for long” and “the latency will kill us” or “these calls won’t be reliable going on the ‘public’ internet load balancer” but this has never been a bottleneck for our backend. It is certainly a productivity hack because it means we only have one API to support and the same highly available load balancing infrastructure we setup for the clients serves the service clients as well.
Programmer productivity is one of my biggest concerns for any dev team and I needed to make sure using microservices wasn’t a huge burden when doing local development. The most important rule/convention I put in place to ensure productivity:
Every microservice starts with the same command and boots up with zero configuration.
The broader lesson here is to rely on convention over configuration as much as possible (thanks Ruby on Rails). Creating reasonable defaults takes a bit of work but it means any developer can work with any microservice without any extended specific setup. At a previous company, every service team created their own services each with their own extensive config files. If you weren’t directly developing that service it could take days or sometimes weeks to get the service running locally. Alternately, you could try to plumb through a shim to some working common development environment and make calls to there. Which works as long as you’re not making changes to both services which can cause an extended deployment hell ping pong as you squash bugs back and forth between the services. Having easy local development allows this process to be fast and easy. Having conventions that allow devs to get things working without pinging several other people on Slack to figure out “how to get this running” means much faster and easier development.
To make this work each microservice gets its own default listening port. We just picked a port for the first service and increment for every new service. All the microservices can be booted at the same time on a single machine easily. We don’t need a complicated routing configuration and we know that a given service will be found on localhost on a specific port. This didn’t quite solve the routing problem as we needed a way to know which API routes were on which service. At first we hard-coded the localhost and port into our clients when running locally, but I was able to leverage microservices for a better solution.
Being able to selectively redirect parts of the API to systems that already have the right data is a microservice super-power. We have a common library that is responsible for registering new API routes (along with our standard middleware like authentication and monitoring). I was able to leverage this standard to create a local proxy that would pull in the routes from all the services and route accordingly to each local service. We eventually extended this proxy to allow overrides to have certain services point at shared development environments rather than a locally running service. This allows us to run locally when necessary or point to a shared cloud environment where convenient. A monolith advocate may say this is overly complicated and a lot of work to setup, and that is true, but it’s actually been a huge benefit over a monolith. If we didn’t have this service separation the same development would have required pulling down huge databases to localhost. We’re a content-driven application and that data is constantly evolving so trying to keep some reasonable dataset that is viable for local development would be a huge burden. The team has extended this proxy to the point where we have the ability to point our client to a local laptop and the client is fully functional as if it were pointed to a dev environment with the exception of one or a handful of locally running services under development.
We rely on a lot of common code to enforce common conventions across microservices and this can be difficult to roll out when we make changes. One decision that I think really helped was using both a single language (Go) and a monorepo. Microservices enable multi-language deployment, but you don’t need to use it and I think it’s better to avoid until you really need it. That way when you want to create a new convention, you only need to do it once. Enforcing those conventions via shared library code is much easier in a monorepo. At the first startup where we used microservices, we had a multirepo approach and external shared libraries. This was a huge pain to maintain if you wanted to change something common to all the services (think 1 PR per service). Even a small contract change between two services required changing multiple repos at once. Monorepos have their own downsides, but having used both, I think the benefits out weight the negatives.
Over time, we added more productivity tooling to configure new routes and to generate the standard boilerplate code and configuration for a new service. Though we’re still a small team, our productivity is just as high or even higher than it would be working on a monolith. Our setup is still fairly simple compared to big companies like Netflix or Uber, but we’re building incrementally as the need arises. The approach we’ve taken is very coordinated; like a monolith would be. This setup allows reaping the benefits of microservices while incurring minimal overhead.
Benefits
When you’re a small team, moving fast is imperative and inevitably things break. Having “teams” working on their own independent microservices means that even if one is broken, progress can be made on the others. Often the interservice dependencies aren’t part of the active development which means that someone working on service A can use the last working state of service B while the service B team works out their issues. I use the word “team” loosely here, as early on, it may just be one person working on a service at a time. Our backend team still has about 2–3 microservices per developer down from 4–5 as the team has grown. This was intentional to allow core services (like getting a user record) to be very stable while the major business logic development could happen in other services.
When a monolith is unstable, that instability affects the whole team. Often this manifests at boot time with unmet dependencies that don’t initialize properly. When this happens in a monolith, all progress stops and often the whole team needs to stop and figure out how get their development back on track. I’ve often seen developers work around issues like this by infrequently pulling the latest changes because they’re always afraid something upstream will break what they’re working on. Having microservices that are well decoupled prevents this type of block and allows such changes to get rolled quickly out without disrupting everyone’s workflow.
It’s pretty amazing how much code can be written starting from a green field in a short time with a very small team. But when you add a person to the team, all that code can become a liability. It can be daunting to dive into a huge monolithic code base, even a well organized one. With microservices, bringing someone new on is easier because they can ramp up on just a single microservice first. We have a microservice that has just a few test endpoints that we use to show our common conventions and shared code. This allows a new employee to see how every service works, before diving into business-specific logic. We can also have that new person to come up to speed on just a single service at first. This is easier because the footprint is smaller and code in other services can be mostly ignored until the developer learns the ropes. This is possible in a well organized monolith, but much more difficult to maintain as the code grows.
Because each service is relatively small, the boot-up wiring and code is small and tractable with no need for complicated dependency injection frameworks. Also boot and compile times are very fast. These small improvements in productivity add up when the developers are looping through the process hundreds of times a day.
I was surprised at how early in our growth we took advantage of the independent scaling of our microservices. Within a few months of launch just a few APIs ended up taking far more resources and traffic than others. These endpoints were often getting slow while we figured out the bottlenecks but our overall product was still responsive since the trouble was isolated. Since we already had everything split it was easy to scale these out at very low cost. During these early days we didn’t have great monitoring set up, but isolating issues was usually easy because the client symptoms typically pointed to a single service where the number of things that could break was fairly limited.
Make it Happen
The typical microservice-using company got there by taking a giant unmaintainable monolith and breaking it up into microservices. This approach usually ends up creating a complicated mess of services spread across a large number of teams all using different conventions and standards. While this is something that works at large scale, it’s likely the reason microservices have gotten such a bad name for projects just starting out. Skipping the whole pain of outgrowing a monolith can save a whole lot of pain if your company is lucky enough to have rapid growth, and starting from scratch with microservices avoids a lot of the mess in the first place. Taking an approach that enforces some commonalities removes most of the overhead while preserving the benefits at a small team size. Using common frameworks as shared libraries makes rolling out system-wide changes, like common monitoring, nearly as easy as in a monolith. Sophisticated tooling and things like CI/CD can wait and be added over time. When we first launched, builds were created and deployed via a bash script from a developer laptop. Though I wouldn’t recommend it, we also didn’t get our monitoring up for months after launch.
Does your startup need microservices? Like any other business decision, it depends on your team and requirements. But there are some great benefits and it’s never been easier if you take a bit of time to make it happen.