9

I'm using a REST API to retrieve data from my back end at my front end. I'd like to figure out which way of receiving data from the back end is the better one (= clean, robust, and best practiced).

There are the following two ways of retrieving data from the back end (the example code assumes the retrieval of some reservation information from the back end).

  1. Implicitly adopting a data structure from the back end
function queryReservationData(){
  const data = queryBackend('/reservationdata/query');
  return data; 
}
  1. Explicitly adopting a data structure from the back end
function queryReservationData(){
  const backendData = queryBackend('/reservationdata/query');
  const data = {
    name: backendData.name,
    reservationStart: backendData.reservationStart,
    reservationEnd: backendData.reservationEnd,
  };
  return data; 
}
edit: Constraints
  • Backend owner: In my current use case, the backend is owned internally, but an answer for both use cases would be helpful.
Thoughts

I feel approach 1 is not clean or safe, because it couples the back end and front end. I dislike the idea of an implicit data structure that changes as soon as the back end changes (back-end editors might not be aware of their changes).

However, in case of the back end changes, the front end will also have to change. So is there really a benefit of stating everything explicitly as in approach 2?

Concrete environment
  • In my concrete use case, I'm using React and TypeScript for my front end (but this should not influence the answer).
  • The backend is used only of one specific UI. Differentiating the cases in the answers might be helpful.
  • Backend owned internally by different teams.
2
  • 1
    Suggested research topic: DTO (Data Transfer Object)
    – Martijn
    Commented Jan 5, 2023 at 12:05
  • 1
    If you're using Typescript (which you are), and you don't want to set up extra codegen or pipelines - the simplest approach is just number one. I don't have enough rep to answer this question. For the people who say, "it won't scale" - they're wrong. I've scaled it in plenty of enterprise applications. The reason why it's good? It's the simplest. If the BE changes you break either way. So if adding more code doesn't prevent anything from breaking, don't add more code. Potentially related: disable the eslint-camelcase rule globally for your typescript codebase, please. Commented May 25, 2023 at 1:00

7 Answers 7

16

Both are coupled to the back-end model. Just in different ways.

If the back-end model changes, both solutions will break. Gracefully or not but will break anyways. The key is where the front-end code is going to break?.

Being practical, #2 is preferable since the coupling point is all in a single function. It's well-located and easy to find. That's not true for #1 which broadcast the "changes" to any queryReservationData() caller.

The change I would consider on #2 is replacing the mapping (which is useless) with schema validations. In this regard, you might find interesting JSON Schema and JSON Schema validators.

As @gnasher729 says,

Your function should guarantee to either return what you want it to return, or produce some error.

If the function can't guarantee its own contract, for whatever reason, what else can you do than rising an error? Note that, if the backend removes name from the response, your mapping will result in data.name: undefined. In other words, it doesn't solve the problem, it just hides it. It's even worse for reservationStart and reservationEnd, because they are format-bound. If the format changes, none of the options will handle the hit of the change.

6

I never trust any data from any server. The server developers may have changed it without telling me, or it could be hacked.

Your function should guarantee to either return what you want it to return, or produce some error.

(If the server changes enough, then you might need to change what you return, but any changes in that case are indeed needed).

5

The client for the backend is part of the backend API.

Therefore coupling to the data models is expected. You should write and publish the TypeScript client and models as part of the backend project. The front end should just consume the published library.

JSON is designed to make the deserialization of data easy in JavaScript, but if you were using XML or some compressed format you would need that extra layer of model binding in the client. However, it's not needed just so you can have a different model class on the front end.

Unless you are doing OOP and want to add methods to the returned model it’s just extra work, and even then there are probably better ways of doing it by treating the returned data as a DTO.

2

With either approach, backward-incompatible changes in the back-end will have significant repercussions.

In the implicit approach, the front-end simply passes these changes directly to the REST client. So you've effectively made the back-end part of the public API, and back-end developers must take this into account. This is likely to effectively prohibit breaking changes to the back-end.

In the explicit approach, changes to the back-end will require corresponding changes to the front-end. In some cases, the front-end may be able to hide the changes from the REST client. For instance, if the back-end changes from a single "full name" field to separate "first name" and "surname" fields, the front-end could map between them (but since this also negates the benefit of the split, it would presumably be considered a temporary transition feature, to allow REST clients time to revise their code).

But there may be some changes that are difficult to paper over in the front-end, so they'll still percolate through to the public API. So either way, incompatible changes in the back-end create difficulties.

In an ideal world, changes would always be backward-compatible. If the change merely adds new fields to a result, this should rarely have significant impact, and either approach accomodates it well. The explicit approach has the benefit of not increasing the amount of data forwarded to the REST client. It also avoids breaking changes if the client application is sensitive to unrecognized fields in the response. This may be poor design on their part (one of the reasons public APIs use flexible, self-describing formats like JSON and XML, rather than fixed-size C-style structures, is that it's easy to ignore what you don't need), but I'm sure there are applications out there like this.

2

As you control both, the backend and the frontend, I would avoid either approach. They are both weakly linked (breaking change still compiles) and just exhibit slightly different breaking behaviour.

Generally I would recommend to use some kind of code generation or schema to ensure data model consistency. So for example you could make a pipeline so that every backend change regenerates the frontend files and fail if there are changes. This way you are forced to consider the effect of your change onto the frontend and change it accordingly.

What you call explicit has the slight benefit that the frontend has a clear notion of what it expects but it still downloads everything from the backend. Imagine the backend adds a 2MB blob into the requerst, your UI will suffer even though "the addition of a property should be non-breaking". As you control both sides, it makes no sense to send data and then not use it in the frontend.

0

Depending on the complexity of your app, it might be appropriate to publish API versions. An API version is "guaranteed" (insomuch as you choose to provide this guarantee) to have stable properties, because adding/removing/changing properties can/should increase the version number. As a bonus, by versioning your API, code that still relies on the older version won't see any changes. As you retire older versions, you can upgrade the client as necessary.

function queryReservationData() {
  const backendData = queryBackend('/reservationdata/query/1.0');
  const data = {
    name: backendData.name,
    reservationStart: backendData.reservationStart,
    reservationEnd: backendData.reservationEnd,
  };
  return data; 
}

When you later decide to introduce a new version, you increment the counter:

const backendData = queryBackend('/reservationdata/query/2.0');
const data = {
  name: backendData.name,
  reservationStart: backendData.reservationStartDate,
  reservationEnd: backendData.reservationEndDate,
  reservationDuration: backendData.reservationDuration
};

In this example, some of the properties changed, but those were smoothed over here instead of all across the client that needs to use those properties. You should choose to build an API such that minor changes don't affect potentially hundreds, thousands, etc lines of code/clients unnecessarily.

0

I would use a data validation library (like Zod for example) to assert that the data structure is what you expect it to be. In your case the Zod validation object would look like something like this:

import z from zod;
...
const validator = z.object({
    name: z.string().min(1),
    reservationStart: z.date(),
    reservationEnd: z.date(),
  })

... 
// To validate
function queryReservationData() {
  const backendData = queryBackend('/reservationdata/query/1.0');
  const data = validator.parse(backendData);
  return data; 
}

Zod also supports transformations so you could transform a date field which you receive as ISO string into a Javascript Date object

Not the answer you're looking for? Browse other questions tagged or ask your own question.