4
Designing new features

This chapter covers

  • Scoping microservices based on business capabilities and use cases
  • When to scope microservices to reflect technical capabilities
  • Making design choices when service boundaries are unclear
  • Scoping effectively when multiple teams own microservices

Designing a new feature in a microservice application requires careful and well-reasoned scoping of microservices. You need to decide when to build new services or extend existing services, where boundaries lie between those services, and how those services should collaborate.

Well-designed services have three key characteristics: they’re responsible for a single capability, independently deployable, and replaceable. If your microservices have the wrong boundaries, or are too small, they can become tightly coupled, making them challenging to deploy independently or replace. Tight coupling increases the impact, and therefore the risk, of change. If your services are too large — taking on too much responsibility — they become less cohesive, increasing friction in ongoing development.

Even if you get it right the first time, you need to keep in mind that the requirements and needs of most complex software applications will evolve over time, and approaches that worked early in that application’s lifetime may not always remain suitable. No design is perfect forever.

You’ll face additional challenges in longer running applications (and larger engineering organizations). Your services may rely on a web of dependencies managed by multiple teams — as an engineer in one team, you’ll need to design cohesive functionality while relying on services that won’t necessarily be under your control. And you’ll need to know when to retire and migrate away from services that no longer meet the needs of the wider system.

In this chapter, we’ll walk you through designing a new feature using microservices. We’ll use that example to explore techniques and practices that you can use to guide the design of maintainable microservices in both new and longer running microservice applications.

4.1 A new feature for SimpleBank

Remember SimpleBank? The team is doing well — customers love their product! But SimpleBank has discovered that most of those customers don’t want to pick their own investments — they’d much rather have SimpleBank do the hard work for them. Let’s take this problem and work out how to solve it with a microservice application. In the next few sections, we’ll develop the design in four stages:

  1. Understanding the business problem, use cases, and potential solution
  2. Identifying the different entities and business capabilities your services should support
  3. Scoping services that are responsible for those capabilities
  4. Validating your design against current and potential future requirements

This will build on the small collection of services we explored in chapters 2 and 3: orders, market gateway, account transactions, fees, market data, and holdings.

First, let’s understand the business problem you’re trying to solve. In the real world, you could carry out the discovery and analysis of business problems using several techniques, such as market research, customer interviews, or impact mapping. As well as understanding the problem, you’d need to decide whether it was one your company should solve. Luckily, this isn’t a book about product management — you can skip that part.

Ultimately, SimpleBank’s customers want to invest money, either up front or on a regular basis, and see their wealth increase, either over a defined period or to meet a specific goal, such as a deposit on a house. Currently, SimpleBank’s customers need to choose how their money is invested — even if they don’t have a clue about investing. An uninformed investor might choose an asset based on high predicted returns, without realizing that higher returns typically mean significantly higher risk.

To solve this problem, SimpleBank could make investment decisions on the customer’s behalf by allowing the customer to choose a premade investment strategy. An investment strategy consists of proportions of different asset types — bonds, shares, funds, and so on — designed for a certain level of risk and investment timeline. When a customer adds money to their account, SimpleBank will automatically invest that money in line with this strategy. This setup is summarized in figure 4.1.

c04_01.png

Figure 4.1 Potential use cases to support defining and selecting investment strategies

Based on figure 4.1, you can start to identify the use cases you need to satisfy to solve this problem:

  • SimpleBank must be able to create and update available strategies.
  • A customer must be able to create an account and elect an appropriate investment strategy.
  • A customer must be able to invest money using a strategy, and investing in a strategy generates appropriate orders.

Over the next few sections, we’ll explore these use cases. When identifying use cases in your own domain, you may prefer to use a more structured and exhaustive approach, such as behavior-driven development (BDD) scenarios. What’s important is that you start to establish a concrete understanding of the problem, which you then can use to validate an acceptable solution.

4.2 Scoping by business capabilities

After you’ve identified your business requirements, your next step is to identify the technical solution: which features you need to build and how you’ll support them with existing and new microservices. Choosing the right scope and purpose for each microservice is essential to building a successful and maintainable microservice application.

This process is called service scoping. It’s also known as decomposition or partitioning. Breaking apart an application into services is challenging — as much art as science. In the following sections, we’ll explore three strategies for scoping services:

  • By business capability or bounded context — Services should correspond to relatively coarse-grained, but cohesive, areas of business functionality.
  • By use case — Services should be verbs that reflect actions that will occur in a system.
  • By volatility — Services should encapsulate areas where change is likely to occur in the future.

You don't necessarily use these approaches in isolation; in many microservice applications, you’ll combine scoping strategies to design services appropriate to different scenarios and requirements.

4.2.1 Capabilities and domain modeling

A business capability is something that an organization does to generate value and meet business goals. Microservices that are scoped to business capabilities directly reflect business goals. In commercial software development, these goals are usually the primary drivers of change within a system; therefore, it’s natural to structure the system to encapsulate those areas of change. You’ve seen several business capabilities implemented in services so far: order management, transaction ledgers, charging fees, and placing orders to market (figure 4.2).

c04_02.png

Figure 4.2 Functions that existing microservices provide and their relationship to business capabilities performed by SimpleBank

Business capabilities are closely related to a domain-driven design approach. Domain-driven design (DDD) was popularized by Eric Evans’ book of the same name and focuses on building systems that reflect a shared, evolving view, or model, of a real-world domain.1  One of the most useful concepts that Evans introduced was the notion of a bounded context. Any given solution within a domain might consist of multiple bounded contexts; the models inside each context are highly cohesive and have the same view of the real world. Each context has a strong and explicit boundary between it and other contexts.

Bounded contexts are cohesive units with a clear scope and an explicit external boundary. This makes them a natural starting point for scoping services. Each context demarcates the boundaries between different areas of your solution. This often has a close correspondence with organizational boundaries; for example, an e-commerce company will have different needs — and different teams — for shipping versus customer payments.

To begin with, a context typically maps directly to a service and an area of business capability. As the business grows and becomes more complex, you may end up breaking a context down into multiple subcapabilities, many of which you’ll implement as independent, collaborating services. From the perspective of a client, though, the context may still appear as a single logical service.

4.2.2 Creating investment strategies

You can design services to support creating investment strategies using a business capability approach. You might want to get a sketch pad to work through this one. To help you work through this example and give the use case more shape, we’ve wireframed what the UI for this feature might look like in figure 4.3.

c04_03.png

Figure 4.3 A user interface for an admin user to create new investment strategies

To design services by business capability, it’s best to start with a domain model: some description of the functions your business performs in your bounded context(s) and the entities that are involved. From figure 4.3, you’ve probably identified these already. A simple investment strategy has two components: a name and a set of assets, each with a percentage allocation. An administrative staff member at SimpleBank will create a strategy. We’ve drafted those entities in figure 4.4.

The design of these entities helps you understand the data your services own and persist. Only three entities, and it already looks like you’ve identified (at least) two new services: user management and asset information. The user and asset entities are both part of distinct bounded contexts:

  • User management — This covers features like sign-up, authentication, and authorization. In a banking environment, authorization for different resources and functionality is subject to strict controls for security, regulatory, and privacy reasons.
c04_04.png

Figure 4.4 The first draft of a domain model made up of entities to support the creation of investment strategies

  • Asset information — This covers integration with third-party providers of market data, such as asset prices, categories, classification, and financial performance. This capability would include asset search, as required by your user interface (figure 4.3).

Interestingly, these different domains reflect the organization of SimpleBank itself. A dedicated operational team manages asset data; likewise, user management. This comparability is desirable, as it means your services will reflect real-world lines of cross-team communication.

More on that later — let’s get back to investment strategies. You know that you can associate them with customer accounts and use them to generate orders. Accounts and orders are both distinct bounded contexts, but investment strategies don’t belong in either one. When strategies change, the change is unlikely to affect accounts or orders themselves. Conversely, adding investment strategies to either of those existing services will hamper their replaceability, making them less cohesive and less amenable to change.

These factors indicate that investment strategies are a distinct business capability, requiring a new service. Figure 4.5 illustrates the relationships between this context and your existing capabilities.

c04_05.png

Figure 4.5 Relationships between your new business capability and other bounded contexts within the SimpleBank application

You can see that some contexts are aware of information that belongs to other contexts. Some entities within your context are shared: they’re conceptually the same but carry unique associations or behavior within different contexts. For example, you use assets in multiple ways:

  • The strategy context records the allocation of assets to different strategies.
  • The orders context manages the purchase and sale of assets.
  • The asset context stores fundamental asset information for use by multiple contexts, such as pricing and categorization.

The model we’ve drawn out in figure 4.5 doesn’t tell you much about the behavior of a service; it only tells you the business scope your services cover. Now that you have a firmer idea of where your service boundaries lie, you can draft out the contract that your service offers to other services or end users.

First, your investment strategies service needs to expose methods for creating and retrieving investment strategies. Other services or your UI can then access this data. Let’s draft out an endpoint that allows creating an investment strategy. The example shown in listing 4.1 uses the OpenAPI specification (formerly known as Swagger), which is a popular technique for designing and documenting REST API interfaces. If you’re interested in learning more, the Github page for the OpenAPI specification2  is a good place to start.

Listing 4.1 API for the investment strategies service

openapi: "3.0.0"
info:    ①  
  title: Investment Strategies    ①  
servers:    ①  
  - url: https://investment-strategies.simplebank.internal    ①  
paths:
  /strategies: 
    post:    ②  
      summary: Create an investment strategy
      operationId: createInvestmentStrategy
      requestBody:    ③  
        description: New strategy to create
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewInvestmentStrategy'    ④  
      responses:
        '201':
          description: Created strategy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvestmentStrategy'    ⑤  
components:    ⑥  
  schemas:
    NewInvestmentStrategy:    ⑦  
      required:
        - name
        - assets
      properties:
        name:
          type: string
        assets:    ⑧  
          type: array    ⑧  
          items:    ⑧  
            $ref: '#/components/schemas/AssetAllocation'    ⑧  
    AssetAllocation:
      required:
        - assetId
        - percentage
      properties:
        assetId:
          type: string
        percentage:
          type: number
          format: float
    InvestmentStrategy:
      allOf:
        - $ref: '#/components/schemas/NewInvestmentStrategy'    ⑨  
        - required:
          - id
          - createdByUserId
          - createdAt
          properties:
            id:
              type: integer
              format: int64
            createdByUserId:
              type: integer
              format: int64
            createdAt:
              type: string
              format: date-time

If you’re going to use strategies again later — and you are — you’ll need to retrieve them. Immediately under your paths: element in listing 4.1, add the code in the following listing.

Listing 4.2 API for retrieving strategies from the investment strategies service

  /strategies/{id}:    ①  
    get:
      description: Returns an investment strategy by ID
      operationId: findInvestmentStrategy
      parameters:    ②  
        - name: id    ②  
          in: path    ②  
          description: ID of strategy to fetch    ②  
          required: true    ②  
          schema:    ②  
            type: integer    ②  
            format: int64    ②  
      responses:
        '200':
          description: investment strategy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvestmentStrategy'    ③  

You also should consider what events this service should emit. An event-based model aids in decoupling services from each other, ensuring that you can choreograph long-term interactions, rather than explicitly orchestrate them.

c04_06.png

Figure 4.6 The inbound and outbound contract of your investment strategies microservice

For example, imagine that creating a strategy will trigger email notifications to potentially interested customers. This is separate from the scope of the investment strategies service itself; it has no knowledge of customers (or their preferences). This is an ideal use case for events. If a POST to /strategies post-hoc triggers an event — let’s call it StrategyCreated — then arbitrary microservices can listen for that event and act appropriately. Figure 4.6 illustrates the full scope of your service’s API.

Great work — you’ve identified all the capabilities that you require to support this use case. To see how this fits together, you can map the investment strategies service and the other capabilities you’ve identified to the wireframe (figure 4.7).

Let’s summarize what you’ve done so far:

  1. For a sample problem, you’ve identified functions the business performs to generate value and the natural seams between different areas of SimpleBank’s business domain.
  2. You’ve used that knowledge to identify boundaries within your microservice application, identifying entities and responsibility for different capabilities.
  3. You’ve scoped your system into services that reflect those domain boundaries.
c04_07.png

Figure 4.7 Identified capabilities and services mapped to how they’d support functionality in the create investment strategy user interface

This approach results in services that are relatively stable, cohesive, oriented to business value, and loosely coupled.

4.2.3 Nested contexts and services

Each bounded context provides an API to other contexts, while encapsulating internal operation. Let’s take asset information as an example (figure 4.8):

  • It exposes methods that other contexts can use, such as searching for and retrieving assets.
  • Third-party integrations or specialist teams within SimpleBank populate asset data.

The private/public divide provides a useful mechanism for service evolution. Early in a system’s lifecycle, you might choose to build coarser services, representing a high-level boundary. Over time, you might decompose services further, exposing behavior from nested contexts. Doing this maintains replaceability and high cohesion, even as business logic increases in complexity.

c04_08.png

Figure 4.8 A context exposes an external interface and may itself contain nested contexts

4.2.4 Challenges and limitations

In the previous sections, you identified the natural seams within the organization’s business domain and applied them to partition your services. This approach is effective because it maps services to the functional structure of a business — directly reflecting the domain in which an organization operates. But it’s not perfect.

Requires substantial business knowledge

Partitioning by business capabilities requires having significant understanding of the business or problem domain. This can be difficult. If you don’t have enough information — or you’ve made the wrong assumptions — you can’t be completely certain you’re making the right design decisions. Understanding the needs of any business problem is a complex, time-consuming and iterative process.

This problem isn’t unique to microservices, but misunderstanding the business scope — and reflecting it incorrectly in your services — can incur higher refactoring costs in this architecture, because both data and behavior can require time-consuming migration between services.

Coarse-grained services keep growing

Similarly, a business capability approach is biased toward the initial development of coarse-grained services that cover a large business boundary — for example, orders, accounts, or assets. New requirements increase the breadth and depth of that area, increasing the scope of the service’s responsibility. These new reasons to change can violate the single responsibility principle. It’ll be necessary to partition that service further to maintain an acceptable level of cohesion and replaceability.

4.3 Scoping by use case

So far, your services have been nouns, oriented around objects and things that exist within the business domain. An alternative approach to scoping is to identify verbs, or use cases within your application, and build services to match those responsibilities. For example, an e-commerce site might implement a complex sign-up flow as a microservice that interacts with other services, such as user profile, welcome notifications, and special offers.

This approach can be useful when

  • A capability doesn’t clearly belong in one domain or interacts with multiple domains.
  • The use case being implemented is complex, and placing it in another service would violate single responsibility.

Let’s apply this approach to SimpleBank to understand how it differs from noun-oriented decomposition. Get your pencil and paper ready!

4.3.1 Placing investment strategy orders

A customer can invest money into an investment strategy. This will generate appropriate orders; for example, if the customer invests $1,000, and the strategy specifies 20% should be invested in Stock ABC, an order will be generated to purchase $200 of ABC.

This raises several questions:

  1. How does SimpleBank accept money for investment? Let’s assume a customer can make an investment by external payment (for example, a credit card or bank transfer).
  2. Which service is responsible for generating orders against a strategy? How does this relate to your existing orders and investment strategies services?
  3. How do you keep track of orders made against strategies?

You could build this capability into your existing investment strategies service. But placing orders might unnecessarily widen the scope of responsibility that the service encapsulates. Likewise, the capability doesn’t make sense to add to the orders service. Coupling all possible sources of orders to that service would give it too many reasons to change.

You can draft out an independent service for this use case as a starting point — call it PlaceStrategyOrders. Figure 4.9 sketches out how you’d expect this service to behave.

Consider the input to this service. For orders to be placed, this service needs three things: the account placing them, the strategy to use, and the amount to invest. You can formalize that input, as shown in the following listing.

c04_09.png

Figure 4.9 Expected behavior of a proposed PlaceStrategyOrders service

Listing 4.3 Draft input for PlaceStrategyOrders

paths:
  /strategies/{id}/orders:    ①  
    post:
      summary: Place strategy orders
      operationId: PlaceStrategyOrders
      parameters:
        - name: id
          in: path
          description: ID of strategy to order against
          required: true
          schema:
            type: integer
            format: int64
      requestBody:
        description: Details of order
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StrategyOrder'
components:
  schemas:
    StrategyOrder:
      required:
        - destinationAccountId    ②  
        - amount    ②  
      properties:
        destinationAccountId:
          type: integer
          format: int64
        amount:
          type: number
          format: decimal

This is elegant but a little too simple. If you assume your payment is coming from an external source, you can’t execute orders until those funds are available. It doesn’t make sense for PlaceStrategyOrders to handle receipt of funds — this is clearly a distinct business capability. Instead, you can link placing strategy orders to a payment, as follows.

Listing 4.4 Using payment ID for PlaceStrategyOrders

components:
  schemas:
    StrategyOrder:
      required:
        - destinationAccountId
        - amount
        - paymentId    ①  
      properties:
        destinationAccountId:
          type: integer
          format: int64
        amount:
          type: number
          format: decimal
	  paymentId:
          type: integer
          format: int64

This anticipates the existence of a new service capability: payments. This capability should support

  • Initiating payments by users
  • Processing those payments by interacting with third-party payment systems
  • Updating account positions at SimpleBank

Because you know that payments aren’t instantaneous, you’d expect this service to trigger asynchronous events that other services can listen for, such as PaymentCompleted. Figure 4.10 illustrates this payments capability.

From the perspective of PlaceStrategyOrders, it doesn’t matter how you implement the payments capability, as long as something implements the interface the consumer expects. It might be a single service — Payments — or a collection of action-oriented services, for example, CompleteBankTransfer.

You can summarize what you’ve designed so far in a sequence diagram (figure 4.11).

There’s one missing element in this diagram: getting these orders to market. As mentioned, although this service generates orders, this capability clearly doesn’t belong within your existing orders service. The orders service exposes behavior that multiple consumers can use, including this new service (figure 4.12); although the source of orders differs, the process of placing them remains the same.

c04_10.png

Figure 4.10 The interface that your proposed payments capability expects

c04_11.png

Figure 4.11 The process of creating a payment and making an investment using the proposed PlaceStrategyOrders service

Lastly, you need to persist the link between these orders and the strategy and investment that created them. PlaceStrategyOrders should be responsible for storing any request it receives — it clearly owns this data. Therefore, you should record any order IDs within the strategy order service to preserve that foreign key relationship. You could also record the order source ID — the ID of this investment strategy investment request — within the orders service itself, although it seems less likely you’d query data in that direction.

c04_12.png

Figure 4.12 Your orders service provides an API that multiple other services within your system can consume.

The orders service emits OrderCompleted events when an order has been completed. Your strategy orders service can listen for these events to reflect that status against the overall investment request.

You can add the orders service and tie this all together as shown in figure 4.13.

Great! You’ve designed another new service. Unlike the previous section, you designed a service that closely represented a specific complex use case, rather than a broad capability.

This resulted in a service that was responsible for a single capability, replaceable, and independently deployable, meeting your desired characteristics for well-scoped microservices. In contrast, unlike if you’d focused on business capabilities, the tight focus of this service on a single use case limits potential for reuse in other use cases in the future. This inflexibility suggests that fine-grained use case services are best used in tandem with coarser grained services, rather than alone.

4.3.2 Actions and stores

We’ve identified an interesting pattern in the above examples: multiple higher level microservices access a coarse-grained underlying business capability. This is especially prevalent in a verb-oriented approach, as the data needs of different actions often overlap.

c04_13.png

Figure 4.13 The full process of creating an investment strategy order using your new PlaceStrategyOrders service

For example, imagine you have two actions: update order and cancel order. Both operations operate against the same underlying order state, so neither can exclusively own that state itself, and you need to reconcile that conflict somewhere. In the previous examples, the orders service took care of the problem. This service is the ultimate owner of that subset of your application’s persistent state.

This pattern is similar3  to Bob Martin’s clean architecture4  or Alistair Cockburn’s hexagonal architecture. In those models, the core of an application consists of two layers:

  • Entities — Enterprisewide business objects and rules
  • Use cases — Application-specific operations that direct entities to achieve the goals of the use case

Around those layers, you use interface adapters to connect these business-logic concerns to application-level implementation concerns, such as particular web frameworks or database libraries. Similarly, at an intraservice level, your use cases (or actions) interact with underlying entities (or stores) to generate some useful outcome. You then wrap them in a façade, such as an API gateway, to map from your underlying service-to-service representations to an output friendly to an external consumer (for example, a RESTful API). Figure 4.14 sketches out that arrangement.

c04_14.png

Figure 4.14 The architecture of a microservice application compared to Bob Martin’s clean architecture

This architecture is conceptually elegant, but you need to apply it judiciously in a microservice system. Treating underlying capabilities as, first and foremost, stores of persistent state can lead to anemic, “dumb” services. These services fail to be truly autonomous because they can’t take any action without being mediated by another, higher level service. This architecture also increases the number of remote calls and the length of the service chain you need to perform any useful action.

This approach also risks tight coupling between actions and underlying stores, hampering your ability to deploy services independently. To avoid these pitfalls, we recommend you design microservices from the inside out, building useful coarse-grained capabilities before building fine-grained action-oriented services.

4.3.3 Orchestration and choreography

In chapter 2, we discussed the difference between orchestration and choreography in service interaction. A bias toward choreography tends to result in more flexible, autonomous, and maintainable services. Figure 4.15 illustrates the difference between these approaches.

If you scope services by use case, you might find yourself writing services that explicitly orchestrate the behavior of several other services. This isn’t always ideal:

  • Orchestration can increase coupling between services and increase the risk of dependent deployments.
  • Underlying services can become anemic and lack purpose, as the orchestrating service takes on more and more responsibility for useful business output.

When designing services that reflect use cases, it’s important to consider their place within a broader chain of responsibilities. For example, the PlaceStrategyOrders service you designed earlier both orchestrates behavior (placing orders) and reacts to other events (payment processing). Taking a balanced approach to choosing orchestration or choreography reduces the risk of building services that lack autonomy.

4.4 Scoping by volatility

In an ideal world, you could build any feature by combining existing microservices. This might sound impractical, but it’s interesting to consider how you maximize the reusability — and therefore long-term utility — of the services you build.

c04_15.png

Figure 4.15 Orchestration versus choreography in service interaction

So far, we’ve taken a predominantly functional approach to decomposing services. This approach is effective but has limitations. Functional decomposition is biased toward the present needs of an application and doesn’t explicitly consider how that application might evolve. A purely functional approach can constrain the future growth of a system by resulting in services that are inflexible in the face of new or evolving requirements, thereby increasing the risk of change.

Therefore, as well as considering the functionality of your system, you should consider where that application is likely to change in the future. This is known as volatility. By encapsulating areas that are likely to change, you help to ensure that uncertainty in one area doesn’t negatively impact other areas of the application. You can find an analogy to this in the stable dependencies principle in object-oriented programming: “a package should only depend on packages that are more stable than it is.”

SimpleBank’s business domain has multiple axes of volatility. For example, placing an order to market is volatile: different orders need to go to different markets; SimpleBank might have different APIs to each market (for example, through a broker, direct to an exchange); and those markets might change as SimpleBank broadens its offering of financial assets.

Tightly coupling market interaction as part of the orders service would lead to a high degree of instability. Instead, you’d split the market service and ultimately build multiple services to meet the needs of each market. Figure 4.16 illustrates this approach.

c04_16.png

Figure 4.16 The market service encapsulates change in how SimpleBank communicates with different financial market providers. Over time, this might evolve into multiple services.

c04_17.png

Figure 4.17 Partitioning a distinct area of system volatility — investment strategy optimization — as a separate service

Let’s take one more example: imagine you have more than one type of investment strategy. Perhaps you have strategies that are optimized by deep learning: the performance of assets on the market should drive adjustments to future strategy allocations.

Adding this complex behavior to your InvestmentStrategies service would significantly broaden its reasons to change — reducing cohesiveness. Instead, you should add new services with responsibility for that behavior — as you can see in figure 4.17. By doing this, you can develop and release these services independently without unnecessary coupling between different features or rates of change.

Ultimately, good architecture strikes a balance between the current and future needs of an application. If microservices are too narrowly scoped, you might find the cost of change becomes higher in the future as you become increasingly constrained by earlier assumptions about the limits of your system. On the flipside, you should always be careful to keep YAGNI — “you aren’t gonna need it” — in mind. You may not always have the luxury of time (or money) to anticipate and meet every possible future permutation of your application.

4.5 Technical capabilities

The services you’ve designed so far have reflected actions or entities that map closely to your business capabilities, such as placing orders. These business-oriented services are the primary type you’ll build in any microservice application.

You can also design services that reflect technical capabilities. A technical capability indirectly contributes to a business outcome by supporting other microservices. Common examples of technical capabilities include integration with third-party systems and cross-cutting technical concerns, such as sending notifications.

4.5.1 Sending notifications

Let’s work through an example. Imagine that SimpleBank would like to notify a customer — perhaps through email — whenever a payment has been completed. Your first instinct might be to build that code within your payments service (or services). But that approach has three problems:

  1. The payments service has no awareness of customer contact details or preferences. You’d need to extend its interface to include customer contact data (pushing that obligation on to service consumers) or query another service.
  2. Other parts of your application might send notifications as well. You can easily picture other features — orders, account setup, marketing — that might trigger emails.
  3. Customers might not even want to receive emails: they might prefer SMS or push notifications…or even physical mail.

The first and second points suggest that this should be a separate service; the third point suggests you might need multiple services — one to handle each type of notification. Figure 4.18 sketches that out. Your notification services can listen to the PaymentCompleted event that your payments service emits.

You can configure your group of notification services to listen to any events — from any service — that should result in a notification. Each service will need to be aware of a customer’s contact preferences and details to send notifications. You could store that information in a separate service, such as a customers service, or have each service own it. This area has hidden dimensions of complexity; for example, many customers might own the payment’s destination account, triggering multiple notifications.

You may have realized that the notification services are also responsible for generating appropriate message content based on each event, which suggests they could grow significantly in the future, in line with the potential number of notifications. It eventually might be necessary to split message content from message delivery to reduce this complexity.

c04_18.png

Figure 4.18 Supporting technical microservices for notifications

This example illustrates that implementing technical capabilities maximizes reusability while simplifying your business services, decoupling them from nontrivial technical concerns.

4.5.2 When to use technical capabilities

You should use a technical capability to support and simplify other microservices, limiting the size and complexity of your business capabilities. Partitioning these capabilities is desirable when

  • Including the capability within a business-oriented service will make that service unreasonably complex, complicating any future replacement.
  • A technical capability is required by multiple services — for example, sending email notifications.
  • A technical capability changes independently of the business capability — for example, a nontrivial third-party integration.

Encapsulating these capabilities in separate services captures axes of volatility — areas that are likely to change independently — and maximizes service reusability.

In certain scenarios, it’s unwise to partition a technical capability. In some situations, extracting a capability will reduce the cohesiveness of a service. For example, in classic SOA, systems were often decomposed horizontally, in the belief that splitting data storage from business functionality would maximize reusability. Figure 4.19 illustrates how requests would be serviced in this approach.

Unfortunately, the intended reusability came at a high cost. Splitting those layers of an application led to tight coupling between different deployable units, as delivering individual features required simultaneous change across multiple applications (figure 4.20). When you have to coordinate changes to distinct components, this leads to error-prone, lock-step deployments — a distributed monolith.

If you focus on business capabilities first, you’ll avoid these pitfalls. But you should carefully scope any technical capability to ensure it’s truly autonomous and independent from other services.

c04_19.png

Figure 4.19 Lifecycle of a create order request in a horizontally partitioned service application

c04_20.png

Figure 4.20 The impact of change in a horizontally partitioned service versus a service scoped to business capability

4.6 Dealing with ambiguity

Scoping microservices is as much art as science. A large part of software design is finding effective ways to achieve the best solution when faced with ambiguity:

  • Your understanding of the problem domain might be incomplete or incorrect. Understanding the needs of any business problem is a complex, time-consuming, and iterative process.
  • You need to anticipate how you might need to use a service in the future, rather than only right now. But you’ll often run into tension between short-term feature needs and long-term service malleability.

Suboptimal service partitioning in microservices can be costly: it adds friction to development and extra effort to refactoring.

4.6.1 Start with coarse-grained services

In this section, we’ll explore a few approaches you can use to make practical service decisions when the right solution isn’t obvious. To start, we’ve talked a lot about the importance of keeping the responsibility of a service focused, cohesive, and limited, so what I’m about to say might sound a little counterintuitive. Sometimes, when in doubt about service boundaries, it’s better to build larger services.

If you err on the side of building services that are too small, it can lead to tight coupling between different services that should be combined in one service. This indicates you’ve decomposed a business capability too far, making responsibility unclear and making it more difficult — and costly — to refactor this element of functionality.

If instead you combine that functionality into a larger service, you reduce the cost of future refactoring, as well as avoiding intractable cross-service dependencies. Likewise, one of the most expensive costs you’ll incur in a microservice application is changing a public interface; reducing the breadth of interfaces between components aids in maintaining flexibility, especially in early stages of development.

Understand that making a service larger also incurs a cost, because larger services become more resistant to change and difficult to replace. But at the beginning of its life, a service will be small. The costs associated with a service being too large are less than the costs of complexity that decomposing too far introduces. You need to carefully observe both service size and complexity to ensure you’re not building more monoliths.

Here, it’s useful to apply a key principle of lean software development: decide as late as possible. Because building a service incurs cost in both implementation and operation, avoiding premature decomposition when faced with uncertainty can give you time to develop your understanding of the problem space. It also will ensure you’re making well-informed decisions about the shape of the application as it grows.

4.6.2 Prepare for further decomposition

The modeling and scoping techniques from earlier in this chapter will help you identify when a service has become too large. Often, you’ll be able to identify possible seams quite early in the lifetime of a service. If so, you should endeavor to design your service internals to reflect them, whether through class and namespace design or as a separate library.

Maintaining disciplined internal module boundaries, with a clear public API, is generally sound software design. In a microservice, it reduces the cost of future refactoring by reducing the chance that code becomes highly coupled and difficult to untangle. That said, be careful — an API that’s well-designed in the context of a code library may not always be ideal as the interface to a microservice.

4.6.3 Retirement and migration

We’ve talked about planning for future decomposition, but we also should talk about service retirement. Microservice development requires a certain degree of ruthlessness. It’s important to remember that it’s what your application does that matters, not the code. Over time — and especially if you start with larger services — you’ll find it necessary to either carve out new microservices from existing services or retire microservices altogether.

This process can be difficult. Most importantly, you need to ensure that consuming services don’t get broken and that they migrate in a timely way to any replacement service.

To carve out new services, you should apply the expand-migrate-contract pattern. Imagine you’re carving out a new service from your orders service. When you first built the orders service, you were confident that it’d fit the needs of all order types, so you built it as a single service. But one order type has turned out to be different from the others, and supporting it has bloated your original service.

First, you need to expand — pulling the target functionality into a new service (figure 4.21). Next, you need to migrate consumers of your old service to the new service (figure 4.22). If access is through an API gateway, you can redirect appropriate requests to your new service.

But if other services call the orders service, you need to migrate those usages. Telling other teams to migrate doesn’t always work (competing priorities, release cycles, and risk). Instead, you need to either make sure your new service is compelling — make people want to invest effort in migration — or do that migration for them.

To complete the process, you have one last step. Finally, you can contract the original service, removing the now obsolete code (figure 4.23).

c04_21.png

Figure 4.21 Expanding functionality from one service into a new service

c04_22.png

Figure 4.22 Migrating existing consumers to the new service

c04_23.png

Figure 4.23 In the final state of your service migration, you’ve contracted your service to remove functionality that now resides in the new service

Great, you made it! This measured, multistep process systematically retires or migrates functionality while reducing the risk of breaking existing service consumers.

4.7 Service ownership in organizations

The examples so far have mostly assumed that a single team is responsible for building and changing microservices. In a large organization, different teams will own different microservices. This isn’t a bad thing — it’s an important part of scaling as an engineering team.

As we pointed out earlier, bounded contexts themselves are an effective way of splitting application ownership across different teams in an organization. Forming teams that own services in specific bounded contexts takes advantage of the inverse version of Conway’s Law: if systems reflect the organizational structure that produces them, you can attain a desirable system architecture by first shaping the structure and responsibilities of your organization. Figure 4.24 illustrates how SimpleBank might organize its engineering teams around the services and bounded contexts you’ve identified so far.

Splitting ownership and delivery of services across teams has three implications:

  • Limited control — You might not have full control over the interface or performance of your service dependencies. For example, payments are vital to placing investment strategy orders, but the team model in figure 4.24 means that another team is responsible for the behavior of that dependency.
  • Design constraints — The needs of consuming services will constrain your service contracts; you need to ensure service changes don’t leave consumers behind. Likewise, the possibilities that other existing services offer will constrain your potential designs.
  • Multispeed development — Services that different teams own will evolve and change at different rates, depending on that team’s size, efficiency, and priorities. A feature request from the investment team to the customers team may not make it to the top of the customers team’s priority list.
c04_24.png

Figure 4.24 A possible model of service and capability ownership by different engineering teams as the size of SimpleBank’s engineering organization grows

These implications can present an immense challenge, but applying a few tactics can help:

  • Openness — Ensuring that all engineers can view and change all code reduces protectiveness, helps different teams understand each other’s work, and can reduce blockers.
  • Explicit interfaces — Providing explicit, documented interfaces for services reduces communication overhead and improves overall quality.
  • Worry less about DRY — A microservice approach is biased toward delivery pace, rather than efficiency. Although engineers want to practice DRY (don’t repeat yourself), you should expect some duplication of work in a microservice approach.
  • Clear expectations — Teams should set clear expectations about the expected performance, availability, and characteristics of their production services.

These sorts of tactics touch on the people side of microservices. This is a substantial topic by itself, which we’ll explore in depth in the final chapter of this book.

Summary

  • You scope services through a process of understanding the business problem, identifying entities and use cases, and partitioning service responsibility.
  • You can partition services in several ways: by business capability, use case, or volatility. And you can combine these approaches.
  • Good scoping decisions result in services that meet three key microservice characteristics: responsible for a single capability, replaceable, and independently deployable.
  • Bounded contexts often align with service boundaries and provide a useful way of considering future service evolution.
  • By considering areas of volatility, you can encapsulate areas that change together and increase future amenability to change.
  • Poor scoping decisions can become costly to rectify, as the effort involved in refactoring is higher across multiple codebases.
  • Services may also encapsulate technical capabilities, which simplify and support business capabilities and maximize reusability.
  • If service boundaries are ambiguous, you should err on the side of coarse-grained services but use internal modules to actively prepare for future decomposition.
  • Retiring services is challenging, but you’ll need to do it as a microservice application evolves.
  • Splitting ownership across teams is necessary in larger organizations but causes new problems: limited control, design constraints, and multispeed development.
  • Code openness, explicit interfaces, continual communication, and a relaxed approach to the DRY principle can alleviate tension between teams.
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset