BIF pattern: clean front-end code and convenient work with server data

Original author: David Gilbertson
  • Transfer
In the material, the translation of which we are publishing today, we will talk about what to do in a situation where the data received from the server does not look like what the client needs. Namely, first we will consider a typical problem of this kind, and then we will examine several ways to solve it.

image

Problem of unsuccessful server API


Consider a conditional example based on several real projects. Suppose we are developing a new website for an organization that has been existing for some time. It already has REST endpoints, but they are not fully calculated on what we are going to create. Here we need to contact the server only for user authentication, for obtaining information about it and for downloading the list of unvisited notifications of this user. As a result, we are interested in the following server API endpoints:

  • /auth: authorizes the user and returns the access token.
  • /profile: returns basic user information.
  • /notifications: allows you to receive unread user notifications.

Imagine that our application always needs to receive all these data as a single unit, that is, ideally, it would be good if instead of three end points we would have only one.
However, we face many more problems than too many endpoints. In particular, we are talking about the fact that the data we receive does not look the best.

For example, the endpoint /profilewas created in ancient times, it was written not in JavaScript, as a result, the property names in the data returned to it look, for a JS application, it is unusual:

{
  "Profiles": [
    {
      "id": 1234,
      "Christian_Name": "David",
      "Surname": "Gilbertson",
      "Photographs": [
        {
          "Size": "Medium",
          "URLS": [
            "/images/david.png"
          ]
        }
      ],
      "Last_Login": "2018-01-01"
    }
  ]
}

In general, nothing good.

True, if you look at what the endpoint gives /notifications, then the above data from the one /profilewill seem downright pretty:

{
  "data": {
    "msg-1234": {
      "timestamp": "1529739612",
      "user": {
        "Christian_Name": "Alice",
        "Surname": "Guthbertson",
        "Enhanced": "True",
        "Photographs": [
          {
            "Size": "Medium",
            "URLS": [
              "/images/alice.png"
            ]
          }
        ]
      },
      "message_summary": "Hey I like your hair, it re",
      "message": "Hey I like your hair, it really goes nice with your eyes"
    },
    "msg-5678": {
      "timestamp": "1529731234",
      "user": {
        "Christian_Name": "Bob",
        "Surname": "Smelthsen",
        "Photographs": [
          {
            "Size": "Medium",
            "URLS": [
              "/images/smelth.png"
            ]
          }
        ]
      },
      "message_summary": "I'm launching my own cryptocu",
      "message": "I'm launching my own cryptocurrency soon and many thanks for you to look at and talk about"
    }
  }
}

Here the list of messages is an object, not an array. Further, there are user data here, as inconveniently arranged as in the case of the end point /profile. And - that's a surprise - the property timestampcontains the number of seconds since the beginning of the 1970th.

If I had to draw a diagram of the architecture of that hellishly awkward system that we just talked about, it would look like the one shown in the figure below. Red color is used for those parts of this scheme that correspond to data that are poorly prepared for further work.


System diagram

We, in these circumstances, may not strive to correct the architecture of this system. You can simply load data from these three APIs and use this data in the application. For example, if you need to display on the page the full name of the user, we will need to combine the properties Christian_Nameand Surname.

Here I would like to make one comment concerning the names. The idea of ​​dividing the full name of a person into a personal name and surname is typical of Western countries. If you are developing something designed for international use, try to treat the full name of the person as an indivisible line, and do not make assumptions about how to break this line into smaller parts in order to use what happened, in those places where need brevity or want to appeal to the user in an informal style.

Let's return to our non-ideal data structures. The first obvious problem, which can be seen here, is expressed in the need to merge disparate data in the user interface code. It lies in the fact that we may need to repeat this action in several places. If it is necessary to do this only occasionally - the problem is not so serious, but if you need it often, it is much worse. As a result, undesirable phenomena occur here, caused by a mismatch of how the data received from the server are arranged and how they are used in the application.

The second problem is the complexity of the code used to form the user interface. I believe that such code should be, firstly, as simple as possible, and secondly - as clear as possible. The more internal data transformations you have to do on the client - the higher its complexity, and the complex code is the place where errors usually hide.

The third problem concerns data types. From the above code snippets, you can see that, for example, message IDs are strings, and user IDs are numbers. From a technical point of view, everything is fine, but such things can confuse a programmer. Also, look at the date presentation! And how do you mess in the part of the data that relates to the image profile? After all, all we need is a URL leading to the corresponding file, and not something from which you have to create this URL yourself, making your way through the jungle of nested data structures.

If we process this data, transferring it to the user interface code, then, analyzing the modules, it will not be possible to immediately understand exactly what we are working with there. Transformation of the internal data structure and their type when working with them creates an additional burden on the programmer. But without all these difficulties it is quite possible to do.

In fact, as an option, it would be possible to implement a static type system to solve this problem, but strict typing is not capable, just by the fact of its presence, to make bad code good.

Now that you have been able to verify the seriousness of the problem we are facing, let's talk about how to solve it.

Solution # 1: change server API


If the inconvenient device of an existing API is not dictated by any important reasons, then nothing prevents you from creating a new version that better meets the needs of the project and locating this new version, say, at /v2. Perhaps this approach can be called the most successful solution to the above problem. The scheme of such a system is shown in the figure below, in green the data structure is highlighted, which perfectly meets the needs of the client.


A new server API that gives exactly what the client side needs.

Starting to develop a new project, whose API leaves much to be desired, I am always interested in the possibility of introducing the approach just described. However, sometimes an API device, even if inconvenient, pursues some important goals, or changing the server API is simply not possible. In this case, I resort to the following approach.

Solution # 2: BFF Pattern


This is about the good old pattern BFF ( Backend-For-the-Frontend ). Using this pattern, you can abstract away from the entangled REST universal endpoints and give the frontend exactly what it needs. Here is a schematic representation of such a solution.


Application of the BFF pattern

The meaning of the existence of the BFF layer is to satisfy the needs of the frontend. Perhaps it will use additional REST endpoints, or GraphQL services, or web sockets, or anything else. Its main goal is to do everything possible for the convenience of the client part of the application.

My favorite architecture is NodeJS BFF, using which front-end developers can do what they need by creating excellent APIs for the client applications they develop. Ideally, the corresponding code is in the same repository as the code of the frontend itself, which makes it easy to share the code, for example, to check the sent data, both on the client and on the server.

In addition, this means that tasks requiring changes to the client part of the application and its server API are performed in the same repository. Trifle, as they say, but nice.

However, BFF may not always be used. And this fact leads us to another solution to the problem of conveniently using bad server APIs.

Solution # 3: BIF Pattern


The BIF (Backend In the Frontend) pattern uses the same logic that can be used when using BFF (a combination of several APIs and data cleansing), but this logic moves to the client side. In fact, the idea is not new, it could be seen twenty years ago, but this approach can help in dealing with poorly organized server APIs, so we are talking about it. Here's what it looks like.


Applying the BIF pattern

▍What is BIF?


As can be seen from the previous section, BIF is a pattern, that is, an approach to understanding the code and its organization. Its use does not lead to the need to remove some logic from the project. It only separates the logic of one type (modification of data structures) from the logic of another type (the formation of the user interface). This is similar to the idea of ​​"sharing of responsibility", which is widely known.

Here I would like to note that, although it is impossible to call this a catastrophe, I often had to see illiterate implementations of BIF. Therefore, it seems to me that many will be interested to hear a story about how to correctly implement this pattern.

BIF code should be considered as a code that can be taken and transferred to the Node.js server once, after which everything will work the same way as before. Or even transfer it to a private NPM package that will be used in several front-end projects within the same company, which is simply amazing.

Recall that above we discussed the main problems encountered when working with a failed server API. Among them - too frequent access to the API and the fact that the data returned by them do not meet the needs of the frontend.

We will break the solution to each of these problems into separate blocks of code, each of which will be placed in its own file. As a result, the BIF layer of the client part of the application will consist of two files. In addition, a test file will be attached to them.

▍ Combining API calls


Performing a set of calls to the server APIs in our client code is not such a serious problem. However, I would like to abstract it, make it so that you can execute a single “request” (from the application code to the BIF layer), and get exactly what you need in response.

Of course, in our case, the execution of three HTTP requests to the server cannot be avoided, but the application does not need to know about it.

The API of my BIF layer is represented as functions. Therefore, when the application needs some data about the user, it will call a function getUser()that will return this data to it. This is what this function looks like:

import parseUserData from './parseUserData';
import fetchJson from './fetchJson';
export const getUser = async () => {
  const auth = await fetchJson('/auth');
  const [ profile, notifications ] = await Promise.all([
    fetchJson(`/profile/${auth.userId}`, auth.jwt),
    fetchJson(`/notifications/${auth.userId}`, auth.jwt),
  ]);
  return parseUserData(auth, profile, notifications);
};

Here, first, a request is made to the authentication service to obtain a token, which can be used to authorize the user (we will not talk here about authentication mechanisms, yet our main goal is BIF).

After receiving the token, you can simultaneously perform two requests that receive user profile data and information about unread notifications.

By the way, look at how beautifully the structure looks async/awaitwhen working with it, using Promise.alland applying destructuring assignment.

So, it was the first step, here we abstract from the fact that the appeal to the server includes three requests. However, the deal has not yet been done. Namely, pay attention to the function callparseUserData()which, as can be judged from its name, puts in order the data received from the server. Let's talk about it.

▍Data clearing


I want to immediately give one recommendation, which, as I believe, is capable of seriously affecting a project that previously did not have a BIF layer, in particular, a new project. Try for some time not to think about what exactly you get from the server. Instead, focus on what data your application needs.

In addition, it is best not to try, when designing an application, to take into account its possible future needs, say, relating to 2021. Just try to make the application work exactly the way you need it today. The fact is that excessive enthusiasm for planning and attempts to predict the future are the main reason for the unjustified complication of software projects.

So back to our business. Now we know what the data obtained from the three server APIs look like, and we know what they should turn into after parsing.

It seems that here we have one of those rare cases where the use of TDD really makes sense. Therefore, we write a large long test for the function parseUserData():

import parseUserData from './parseUserData';
it('should parse the data', () => {
  const authApiData = {
    userId: 1234,
    jwt: 'the jwt',
  };
  
  const profileApiData = {
    Profiles: [
      {
        id: 1234,
        Christian_Name: 'David',
        Surname: 'Gilbertson',
        Photographs: [
          {
            Size: 'Medium',
            URLS: [
              '/images/david.png',
            ],
          },
        ],
        Last_Login: '2018-01-01'
      },
    ],
  };
  
  const notificationsApiData = {
    data: {
      'msg-1234': {
        timestamp: '1529739612',
        user: {
          Christian_Name: 'Alice',
          Surname: 'Guthbertson',
          Enhanced: 'True',
          Photographs: [
            {
              Size: 'Medium',
              URLS: [
                '/images/alice.png'
              ]
            }
          ]
        },
        message_summary: 'Hey I like your hair, it re',
        message: 'Hey I like your hair, it really goes nice with your eyes'
      },
      'msg-5678': {
        timestamp: '1529731234',
        user: {
          Christian_Name: 'Bob',
          Surname: 'Smelthsen',
        },
        message_summary: 'I\'m launching my own cryptocu',
        message: 'I\'m launching my own cryptocurrency soon and many thanks for you to look at and talk about'
      },
    },
  };
  const parsedData = parseUserData(authApiData, profileApiData, notificationsApiData);
  expect(parsedData).toEqual({
    jwt: 'the jwt',
    id: '1234',
    name: 'David Gilbertson',
    photoUrl: '/images/david.png',
    notifications: [
      {
        id: 'msg-1234',
        dateTime: expect.any(Date),
        name: 'Alice Guthbertson',
        premiumMember: true,
        photoUrl: '/images/alice.png',
        message: 'Hey I like your hair, it really goes nice with your eyes'
      },
      {
        id: 'msg-5678',
        dateTime: expect.any(Date),
        name: 'Bob Smelthsen',
        premiumMember: false,
        photoUrl: '/images/placeholder.jpg',
        message: 'I\'m launching my own cryptocurrency soon and many thanks for you to look at and talk about'
      },
    ],
  });
});

But the code of the function itself:

const getPhotoFromProfile = profile => {
  try {
    return profile.Photographs[0].URLS[0];
  } catch (err) {
    return '/images/placeholder.jpg'; // стандартное изображение
  }
};
const getFullNameFromProfile = profile => `${profile.Christian_Name} ${profile.Surname}`;
export default function parseUserData(authApiData, profileApiData, notificationsApiData) {
  const profile = profileApiData.Profiles[0];
  const result = {
    jwt: authApiData.jwt,
    id: authApiData.userId.toString(), // ID всегда должны иметь строковой тип
    name: getFullNameFromProfile(profile),
    photoUrl: getPhotoFromProfile(profile),
    notifications: [], // Массив с уведомлениями должен присутствовать всегда, даже если он пуст
  };
  Object.entries(notificationsApiData.data).forEach(([id, notification]) => {
    result.notifications.push({
      id,
      dateTime: new Date(Number(notification.timestamp) * 1000), // дата, полученная с сервера, выражена в секундах, прошедших с начала эпохи Unix, а не в миллисекундах
      name: getFullNameFromProfile(notification.user),
      photoUrl: getPhotoFromProfile(notification.user),
      message: notification.message,
      premiumMember: notification.user.Enhanced === 'True',
    })
  });
  return result;
}

I would like to note that when it is possible to collect in one place two hundred lines of code responsible for modifying the data scattered throughout the entire application, it causes just wonderful sensations. Now all this is in one file, unit tests are written for this code, and all ambiguous moments are provided with comments.

Above, I said that BFF is my favorite approach to combining and cleaning data, but there is one area in which BIF is superior to BFF. Namely, the data coming from the server may include JavaScript objects that are not supported by JSON, such as objects of type DateorMap(this is probably one of the most underused features of JavaScript). For example, in our case we have to convert the date that came from the server (expressed in seconds, and not in milliseconds) into a JS object of type Date.

Results


If it seems to you that your project has something in common with the one on which we considered the problems of unsuccessful APIs, analyze its code, asking yourself the following questions about the use of data from the server on the client:

  • Do you have to combine properties that are never used separately (for example, the user’s first and last name)?
  • Do you have to work with property names in JS code that are formed in the way that it is not accepted in JS (something like PascalCase)?
  • What are the data types of different identifiers? Maybe sometimes it is a string, sometimes a number?
  • How are dates presented in your project? Maybe, sometimes these are JS objects Date, ready for use in the interface, and sometimes numbers, or even strings?
  • Do you often have to check properties for their existence, or check whether a certain entity is an array, before you start looking through the elements of this entity to form on its basis any fragment of the user interface? Can it happen that this entity is not an array, even if it is empty?
  • When forming the interface, do you have to sort or filter arrays, which, ideally, should already be correctly sorted and filtered?
  • If it turns out that, when checking properties for their existence, there are no properties to be searched for, do you have to switch to using some default values ​​(for example, use a standard image when there is no user photo in the data received from the server)?
  • Are properties uniformly named? Does it happen that the same entity can have different names, which may be caused by the sharing, conditionally speaking, of the “old” and “new” server APIs?
  • Do you have to transfer data along with useful data that is never used, only because it comes from the server API? Does this unused data interfere with debugging?

If you can positively answer one or two questions from this list, then perhaps you should not repair something that is already working properly.

However, if you, reading these questions, find out in each of them the problems of your project, if the device of your code from all this becomes unnecessarily complicated, if it is difficult to perceive and test, if it contains errors that are difficult to detect - look at the BIF pattern.

As a result, I would like to say that when introducing a BIF layer into existing applications, the matter is made easier due to the fact that this can be done in stages, in small steps. Let's say the first version of the function to prepare the data, let's call itparseData(), can simply, without changes, return what comes to its input. Then you can gradually move the logic from the code responsible for the formation of the user interface to this function.

Dear readers! Have you encountered problems that the author of this material suggests using the BIF pattern to solve?


Also popular now: