The concept of a service is indisputably among the most influential guiding design principles to modern, distributed computer systems. Just as an object encapsulates the details of its implementation with a program, a service encapsulates the implementation of a much broader subset of functionality. And by decomposing a complex system into services, a hard problem can be divided into more manageable chunks.
A lot of great books have been written about such microservices, such as Practical Microservices[48] by Ethan Garofolo and Microservices Patterns[49] by Chris Richardson. Although the design of a large-scale distributed system is outside the scope of this book, these trends do have an impact on the way we design the kind of programs we’ve been writing for the last few chapters.
In particular, it’s common for services in this style to share some or all of the following characteristics:
One of the most influential designs in this space is the Scala library, Finagle,[50] which was developed at Twitter in 2011. The success of Finagle at handling Twitter’s enormous scale and workload was, in turn, a huge factor in more broadly popularizing Scala as a language. Other popular frameworks include Google’s gRPC[51] and a broad portfolio of open-source libraries from Netflix, notably Ribbon.[52]
In other words, Scala is already a great language for writing microservices; and for the rest of this chapter, we’ll explore how to adapt our asynchronous framework to this modern environment.
The two most essential aspects of a service framework are the endpoint and the service call. An endpoint is a particular functionality provided by a service, analogous to a method on an object. For example, suppose we have an Authentication service. It might have a login endpoint, a getUserDetails endpoint, and so on, each with typed arguments and return values—just like a regular Scala function.
Accordingly, a service call is what happens when some other service invokes another service remotely. For example, if we had a Widget service, it might call getUserDetails on the user service. And one of the fundamental insights of modern distributed systems is that service calls are fundamentally asynchronous. Even if the Authentication service itself doesn’t block at any point in generating the result of getUserDetails, the call is asynchronous from the point of view of the Widget service because the request and response must both be routed over a network, which brings with it any number of possible failure and delay scenarios.
Finally, we’re also going to orient the services described in this chapter toward HTTP and JSON. Although it’s becoming more and more common to use other protocols for messaging and serialization, HTTP and JSON are still suitable for many real-world workloads and can be much easier to implement and debug than more efficient binary protocols. If you’re interested in learning about alternatives, however, the gRPC stack of Protocol Buffers[53] for serialization and HTTP/2[54] or QUIC[55] for transport are widely adopted and well documented.
It’s common for microservice frameworks in Scala to follow one of two patterns for the high-level design of a microservice API:
A DSL design, often with macros to transform elegant code into a more sophisticated representation.
A trait-based design, where a base trait is extended with application-specific methods.
Both have their respective pros and cons, and unfortunately, both tend to demand more specialized Scala techniques that are outside the scope of this book. What we’ll do instead is design a framework from the bottom up, beginning with HTTP parsing and basic web serving, while relying on more or less ordinary Scala techniques, thoughtfully applied.
The basic requirements are these:
A service needs to be able to declare any number of endpoints by HTTP method and URL.
A service needs to declare a regular Scala type for endpoint requests and responses.
A service needs to be able to read HTTP headers on requests, and set them on response.
A service needs to be able to provide some results asynchronously, while still supporting synchronous endpoints as well.
All of these capabilities are well within reach for us, simply by applying the techniques we’ve developed over the last few chapters. I’ll introduce one new capability to support the design, though: a proper HTTP parser.