EXPEDIA GROUP TECHNOLOGY — SOFTWARE

Applying the Single Responsibility Principle to a FE/BFF Layered Architecture — Backend Architecture Details

Backend as a composition of single-responsibility components — Part 3/3

Rafael Torres
Expedia Group Technology

--

Photo of high-rise building foundation in construction.
Image by Samuel Regan-Asante on Unsplash

This post dives into the backend architecture details of the approach explained in part 1 Applying the Single Responsibility Principle to a FE/BFF Layered Architecture and in part 2 of the series looking at the Frontend Architecture Details. For more context, please read these posts first.

The use-case

For this discussion, we’re going to focus on a single use case for our hypothetical app:

As an admin user, I would like to see a list of configured hours of operation for my stores, so that I can better manage my staff in different time zones.

The backend layers

For this application, we have a backend that implements the Backend For Frontends (BFF) pattern. Its main purpose is to be a mediator between the domain services and our frontend. In simple terms, this backend orchestrates messages between our web client and the downstream domain services. For now, we can assume that both the web client and the domain services speak HTTP REST. In other words, this backend will communicate via HTTP on both sides: upstream and downstream.

Similarly to what we did at the beginning of our overall architecture design, it helps to think about “responsibilities” or “categories of things that need to happen” in the code when designing these layers. That is also the case in our backend design.

For some context, here’s the high-level SRP-based architecture we arrived to earlier. We’re focusing on the BFF API (backend) section for this discussion.

Diagram with the different layers of responsibilities: UI View, UI State, BFF API, and Downstream APIs.
Layered Responsibilities based on SRP

With this in mind, we can break the layers of this backend into 3 responsibilities, as shown in the following diagram:

  • Controller
  • Service
  • Mapper
A diagram of the 3 layers we identified for our BFF architecture: (a) the controller — it interfaces between models and view components, (b) the service — abstracts the downstream services; deals with domain entities (business models), and (c) the mapper — composes view models by mapping service responses to client-level models.
Backend layers: Controller + Service + Mapper

Sequence of backend operations

It usually helps to visualize the sequence of operations / contracts that are needed to support a piece of functionality in the backend (in this case fetching the stores business hours). Such diagram is depicted below.

A UML diagram showing the flow of data between the 3 layers. 
 
 The controller receives the HTTP request from the client. 
 
 It calls getStores() in the service, which calls the downstream service to receive the stores’ domain models and passes them to the controller. 
 
 The controller then passes this response to the mapper, which returns the view model.
 
 This view model is sent back to the client in the request response.
UML Sequence Diagram involving the 3 backend layers previously established

To summarize it, here’s what needs to happen:

  1. The controller receives the web client HTTP request to get the business hours.
  2. The controller calls the service to fetch and deal with the corresponding downstream services and domain entities.
  3. The controller forwards the service response to the mapper.
  4. The mapper translates the domain service messages into the desired view model.
  5. The controller sends the mapped view model to the web clients in the request response.

Thinking about “composition”

Now, let’s take a step back. Usually at this point, if you’re familiar with UML sequence diagrams as part of your dev workflow in your OO habits, you’ll start thinking about classes and/or perhaps interfaces. In this case, let’s not go down that route, but instead think about function composition. We think of a chain of functions that when composed together and invoked, give us back what we need. Something like this:

request --> controller --> service --> mapper --> view model

In this paradigm, we can think of a chain of functions that gradually process a message. Each function takes the previous function’s result and further processes it. In our flow, the controller passes a request (payload) to be processed by a service, whose response is then processed by a mapper, whose response represents the view model that’s eventually returned to the requestor. Or in JS terms:

viewModel = mapper(service(request))
A diagram based on the UML sequence diagram previously shown. The core part of the diagram is hidden in a box that expresses the following composition expression: request → controller → service → mapper → view model.
Thinking about function compositions for the layers

A composition interface

If we think about the logic that encapsulates the view model generation as a black-box, and we think about general terms (types), we can formalize it into an interface that looks something like this:

resolve :: a -> b  /*   
resolve = our black-box function that resolves `a` onto `b`
a = the POJO input, i.e., HTTP payload/params in this case
b = the JSON response sent to the client with the `viewModel`
*/

We call this black-box function “resolve”, inspired by GraphQL resolvers.

A diagram based on the UML sequence diagram previously shown. The core part of the diagram is hidden in a box that expresses the following interface expression: resolve :: a -> b where “a” is the POJO payload, and “b” is the view model.
Formalizing the composition into an interface

You may be asking, what’s the point of this interface if we’re not doing OO per se? Because it helps us to reason about the resolver contract in general terms to abstract its logic at a higher level without having to explain any of the inner details.

Note that this doesn’t necessarily mean adopting a type-checking technology (such as TypeScript). Whether you want to enforce these interfaces in some automated manner is a different discussion. That doesn't diminish the fact that it's a good idea to formalize this contract somehow.

Let’s say we have different backend (downstream) systems that can resolve a request, depending on different conditions (e.g. app deployment configurations), and they all have different API interfaces. Our black-box logic now has to adapt to different conditions for the same request. As long as these adaptations adhere to our resolve interface, we can plug in any resolver that we want to in order to fulfill a request; even at runtime.

Runtime adapters for different upstream infrastructures

To build up on the previous notion, let’s say we have to adapt our black-box logic to 2 different systems: CP and EG (let’s call them that for this example). We can then build an “ABC Resolver” and an “XYZ Resolver” which implement our resolve :: a -> b interface. These resolvers act as adapters in our system to whichever downstream backend service(s) we may need to use. This approach allows us to plug in at runtime whichever resolver is appropriate, making our system more flexible.

A diagram based on the UML sequence diagram previously shown. The core part of the diagram is hidden in a box that shows 2 hypothetical resolvers (ABC Resolver and XYZ Resolver) derived from the “resolve” interface.
Thinking about adapters

Wrapping up

At this point, we have the basis to start implementing a maintainable BFF codebase. To summarize of our BFF architecture, we have distinct responsibilities:

  • Controller
  • Service
  • Mapper

Keep these concerns/responsibilities in mind as your codebase evolves, and respect them throughout your code. This should help you keep it maintainable as it grows.

Diagrams are courtesy of author.

--

--