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

Layering responsibilities brings architectural clarity - Part 1/3

The Backend For Frontend (BFF)[1] pattern is a software architectural pattern gaining more popularity recently. At Expedia Group™️, we are using this pattern heavily throughout our micro-frontend teams as part of our platform solution implementation. In the journey of evolving our architecture, we have recently introduced a new approach, explained in this 3-post series. We hope this helps others in similar situations in the future.

A layered approach to developing our micro-frontend and BFF

Inspired by the Single Responsibility Principle (SRP) [2], we've broken out the different layers composing the our micro-frontend capabilities, as such:

Frontend

  • UI view layer: responsible for all rendering aspects (React components, styles, accessibility, UX). It deals with view models and knows nothing about domain models.
  • UI state layer: responsible for global state, and API calls to the BFF API.

Backend

  • BFF API: Responsible for orchestrating calls to downstream APIs and all backend functionality needed to support the frontend. It translates the downstream domain models into the UI view models, and vice-versa.
  • Downstream APIs: Responsible for infrastructure services, persistence, and business logic. It deals with domain models.

If we honor the responsibilities of each layer, while keeping clear boundaries between them, we can arrive at an architecture that resembles the following diagram.

Putting it into practice

When thinking about these layers, before considering any code logic, it helps to think about message contracts first. What message does each layer need to get or send to any of the neighboring layers, in order to accomplish a use-case? As shown in the above diagram, there are 2 key "models" in our architecture: the view model and the domain model. They represent the message contracts between the web client and the BFF API, and the BFF API and downstream APIs accordingly. So let's focus on those for now.

A use-case example

Let's take this use-case as an example for a hypothetical capability (assume you're managing a global company with multiple stores across the world):

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.

Here are the wireframes for this use-case:

The View Model

To support a view like the one in the wireframe, we'll need an array of objects with a shape similar to this:

[
{
uri: "",
name: "EG1",
country: "US",
city: "Seattle",
hours: "8am - 10pm",
timeZone: "PDT",
lastUpdatedOn: "01/01/2021",
lastUpdatedBy: "John Doe",
}
]

Now that we have a potential view model defined, let's look at the domain model.

The Domain Model

This example assumes the downstream APIs have already been created and perhaps are managed by a different team, so when implementing the BFF all we need to do is to find out (a) what those endpoints are, and (b) what's their API contract (input request shape, and output response shape). Ideally, we'd want a single downstream API call that will give us the data needed, but sometimes we may need to execute multiple API calls. Regardless of the situation, we can leave all that implementation logic back at the BFF layer, so that our client is agnostic of any domain-level details.

For this example, let's assume there's a single downstream API call needed to hydrate our view model:

Mapping the Domain Model to the View Model

Now that we have a view model defined and we understand the contract(s) to deal with the relevant domain model(s), the next question is how to fit the latter into the former. Enter the mapper, which is simply a layer that adapts the downstream API response(s) into what our web client expects (i.e., the view model). As noted in the diagram above, this is the responsibility of the BFF layer, so therefore that's where we'd implement it.

This mapper simply takes in a domain-model message as an input (or a combination of them), and returns a view-model message. The controller then is free to just return this adapted message, which the client understands.

The details of this mapper implementation are outside the scope of this post, but more-or-less this example summarizes it:

// Example: map the `name` attribute from the domain model
const toViewModel = domainModel => domainModel.map(m => m.name);

More intricate mapping operations can also be done here (e.g., concatenating domain-model attributes to construct view-model attributes).

Pros and Cons

Like any architectural decision you make, there are always pros andcons. Here's a brief list of aspects to consider in this case.

System complexity

Initially, one of the cons of this approach is higher complexity when it comes to understanding the system as a whole. Especially, if your app is moving from a Proof of Concept (POC) to a more established codebase. During the POC phase, we're inclined (and perhaps even forced) to take shortcuts, and very possibly to de-prioritize refactoring or use sound software engineering principles. Our priority during that phase is usually to prove a concept and/or gain market traction.

As we adopt a more layered approach through our codebase, more abstractions come into place, and with them, more levels of indirection that require reasoning about the code differently. Such mindset shift can be challenging for some developers, especially the ones that have been working on the POC codebase since day one (and the longer, the worse). Nonetheless, it is somehow a necessary "evil". Code structures are no different than hierarchical structures we human beings establish in our daily lives to help us manage complexity. Abstractions, especially when properly implemented, can make it harder to understand the entire picture, but also can help us achieve new levels of productivity and scalability.

Think about the level of productivity we are able to achieve with libraries like React. At a basic level, React can be thought of an abstraction of the HTML rendering logic. Perhaps you don't fully understand what's going on underneath the covers, but that doesn't stop you from achieving great things with it.

Divide-and-conquer

Having clear boundaries/contracts between the UI and the BFF, independent from downstream services APIs can raise some interesting opportunities for team efficiencies. For example, if the downstream services are still in development, frontend work does not have to put on hold.

Likewise, if the BFF endpoints haven't been implemented, or even defined, the frontend devs can start making progress. All it's needed is a clear view model definition to define the contract, which can usually be arrived to from the requirements and/or UX wireframes.

Unit Testing

While the BFF dev work is being done, the UI devs can use mock data based on the agreed-upon view model(s) to implement both the UI rendering and state management layers. Furthermore, this contract would allow you to do TDD more easily, on both the UI and BFF if that's in your tool belt.

With a defined contract between the frontend and the BFF (i.e., the view model), we can attempt to do more black-box testing on both sides. In fact, even though we're still doing unit testing, as long as we keep these "view model contracts" in synch between both codebases, we can lean towards having more of an integration-like testing strategy, while still not having to stand up a true integration environment.

Note: this is not to say that this technique should replace true integration testing. However, it can be a very useful tool in our holistic testing approach.

Come back tomorrow for Part 2 of this three-part series, where we'll look in more detail at the front end considerations when using this approach.

References

Attachments

  • Original document
  • Permalink

Disclaimer

Expedia Group Inc. published this content on 26 October 2021 and is solely responsible for the information contained therein. Distributed by Public, unedited and unaltered, on 26 October 2021 15:15:04 UTC.