From Microservices in Action by Morgan Bruce and Paulo A. Pereira
This article explores approaches to software engineering using the microservice architecture and ways that you can build and design new features using microservices, instead of the traditional monolithic style.
Building a new feature
Let’s imagine that we’re software engineers at SimpleBank. We have recently decided that microservices are a good choice for SimpleBank, and are going to take a look at how we might use them to build new features. Building a proof-of-concept is a great first step to ensure that a team understands the constraints and requirements of the microservices style. We’ll start by exploring one of the features that SimpleBank needs to build and the design choices we will make.
Figure 1 shows an overview of how services might collaborate to place a sell order process.
Let’s look at how you’d approach building this feature. Several questions we need to answer are:
- Which services do we need to build?
- How do those services collaborate with each other?
- How do we expose their functionality to the world?
These aren’t dissimilar from the questions we might ask ourselves when designing a feature in a monolithic application, but they’ve different implications. For example, the effort required to deploy a new service is inherently higher than creating a new module! In scoping microservices, we need to ensure that the benefits of dividing up our system aren’t outweighed by added complexity.
NOTE: As our application evolves, these questions take on added dimensions. Later, we’ll also find ourselves asking whether to add functionality to existing services or when to carve those services up.
As we discussed earlier, each service should be responsible for a single capability. Our first step is to identify the distinct business capabilities we want to implement and the relationship between those capabilities.
Identifying microservices: modelling the domain well
To identify these business capabilities, we need to develop our understanding of the domain in which we’re building software. This is normally the hard work of product discovery or business analysis — research, prototyping, talking to customers, colleagues or other end-users.
Let’s start by exploring the order placement example from Figure 1. What value are you trying to deliver? At a high-level, a customer wants to be able to place an order. An obvious business capability is the ability to store and manage the state of those orders. This is our first microservice candidate.
Continuing our exploration of the example, we can identify other functionalities our application needs to offer. To sell something, we need to own it, and we need some way of representing a customer’s current holdings, or transactions which have occurred against their account. An order needs to be sent to a broker — the application need to interact with that third party. In fact, this one feature, “placing a sell order,” requires SimpleBank’s application to support all the following functionality:
- Recording the status and history of sell orders
- Charging fees to the customer for placing an order
- Recording transactions against the customer’s account
- Placing an order onto a market
- Providing valuation of holdings and order to customer
It’s not a given that each functionality maps to a single microservice. A single function is likely to be cohesive with other functions. For example, transactions resulting from orders are like transactions resulting from other events, such as account transfers. Together, a group of functions forms a capability that may be offered by one service. Table 1 maps the functions we’ve identified into individual microservices.
Function Business capabilities Recording the status and history of an order Order management Placing an order to market Order management, market gateway Charging a fee Fee ledger, transaction ledger Recording transactions against an account Transaction ledger Valuing positions held in an account Transaction ledger, market data
Some microservice practitioners would argue that microservices should more closely reflect single functions, rather than single capabilities. It’s even been suggested that microservices are “append only” and that it’s always more desirable to write new services than add to existing services.
We disagree. Decomposing too much may lead to services that lack cohesiveness, leading to tight coupling between closely-related collaborators. Likewise, deploying and monitoring many services might be beyond the abilities of the engineering team, particularly in the early days of a microservice implementation. A useful rule of thumb is to err on the side of larger services; it’s often easier to carve out functionality later if it becomes more specialized or more clearly belongs in an independent service.
Lastly, keep in mind that understanding your domain isn’t a one-off process! Over time, you’ll continue to iterate on your understanding of the domain; your users’ needs will change, and your product will continue to evolve. As this understanding changes, your system will change to effectively meet those needs. Luckily coping with changing needs and requirements is a strength of the microservices approach.
We’ve identified several microservice candidates. These services need to collaborate with each other to do something useful for SimpleBank’s customers.
As you’ll already know, service collaboration can be either point-to-point or event-driven. Point-to-point collaboration is typically synchronous, whereas event-driven communication is asynchronous. Many microservice applications begin by using synchronous communication. The motivations for doing this are twofold:
- Synchronous calls are typically simpler and more explicit to reason about than asynchronous interaction. We shouldn’t fall into the trap of thinking they share the same characteristics as local, in-process function calls.
- Most, if not all, programming ecosystems already support a simple, language-agnostic transport mechanism with wide developer mindshare: HTTP.
Consider SimpleBank’s order placement process. The Order service is responsible for recording and placing an order to market. To do this, it needs to interact with our Market service, Fees service, and Account Transaction service. This collaboration is illustrated in Figure 2.
Earlier we pointed out that microservices should be autonomous, and to achieve that, services should be loosely coupled. We achieved this partly through the design of our services, “[gathering] together the things that change for the same reasons” to minimize the chance that changes to one service require changes to its upstream or downstream collaborators. We also need to consider service contracts and service responsibility.
The messages that each service accepts, and the responses it returns, form a contract between that service and the services that rely upon it. We can call these upstream collaborators. Contracts allow each service to be treated as a black box by its collaborators: you send a request and you get something back. If that happens without errors, then the service is doing what it’s meant to do.
Although the implementation of a service may change over time, maintaining contract-level compatibility ensures two things: (1) that those changes are less likely to break consumers; and (2) that dependencies between services are explicitly identifiable and manageable.
In our experience, contracts are often implicit in naïve or early microservice implementations; suggested by documentation and practice, rather than explicitly codified. As the number of services grows, there is significant benefit in standardizing those interfaces in a machine-readable format. For example, REST APIs may use Swagger/OpenAPI. As well as aiding the conformance testing of individual services, publishing standardized contracts helps engineers within an organization find services appropriate to their needs.
You can see in Figure 2 that the Order service has a lot of responsibility. It directly orchestrates the actions of every other service involved in the process of placing an order. This is conceptually simple, but it has downsides. At worst, our other services become anemic; with many “dumb” services controlled by a small number of “smart” services. And those smart services become increasingly large!
This approach also leads to higher coupling. If we want to introduce a new part of this process — let’s say we want to notify a customer’s account manager when a large order is placed — we’re forced to deploy new changes to the Order service. This increases the cost of change. In theory, if the Order service doesn’t need to synchronously confirm the result of an action, then it shouldn’t need to have any knowledge of those downstream actions.
Within a microservice application, services naturally have differing levels of responsibility. Orchestration should be balanced with choreography. In a choreographed system, a service doesn’t need to directly command and trigger actions in other services. Instead, each service owns specific responsibilities, which they perform in reaction to other events.
Let’s revisit our earlier design and make a few tweaks:
- When an order is created, the market might not currently be open. Therefore, we need to record what status an order is in: created or placed. Placement of an order doesn’t need to be synchronous.
- A fee is charged once an order is placed, and this doesn’t need to be synchronous. In fact, it should happen in reaction to the Market service, rather than being orchestrated by the Order service.
- Unfortunately, we need to reserve stock synchronously, and we can’t make that event-driven.
Figure 3 illustrates our changed design. Adding events adds an architectural concern to our design; we need some way of storing them and exposing them to other applications. We’d recommend using a message queue for that purpose, such as RabbitMQ, SQS or Kafka.
In this design, we’ve removed the following responsibility from the Orders service:
- Charging fees: our Order service has no awareness that a fee is being charged once an Order is being placed to market.
- Placing orders: the Order service has no direct interaction with the Market service. We could easily replace this with a different implementation, or even a service per market, without needing to change the Order service.
The Order service reacts to the behavior of other services by subscribing to the OrderPlaced event emitted by the Market service. This is easily extended to further requirements: the Order service might subscribe to TradeExecuted events to record when the sale has been completed on the market, or OrderExpired events if the sale can’t be made within a certain timeframe.
This is more complex than our original synchronous collaboration. By favoring choreography over orchestration where possible, you’ll build services that are highly decoupled, and therefore independently deployable and amenable to change.
The design we’ve come up with also has some benefit in terms of resiliency. For example, failure in the market service is isolated from failure in the orders service. If placing an order fails, we can replay that event later once the service is available (or expire it if too much time passes). On the other hand, it’s now more difficult to trace the full activity of the system, which we’ll need to consider when we think about how to monitor these services in production.
That’s all for this article.
About the author:
Morgan Bruce and Paulo A. Pereira are experienced engineering leaders. They work daily with microservices in a production environment, using the techniques detailed in this book.
 Assuming the queue itself is persistent
Originally published at freecontent.manning.com.