gothinkster / realworld

"The mother of all demo apps" — Exemplary fullstack Medium.com clone powered by React, Angular, Node, Django, and many more
https://www.realworld.how/
MIT License
80.37k stars 7.31k forks source link

Liaison: A love story between the frontend and the backend #430

Closed mvila closed 3 years ago

mvila commented 4 years ago

After spending some time analyzing a few of the most popular RealWorld example apps implemented in JavaScript, I came to a not-so-bright conclusion. Although carefully crafted, I think they all suffer from some serious issues: code scattering, duplication of knowledge, and too much boilerplate.

Code scattering

Ideally, implementing a new feature would require to modify two files: one file in the frontend, and another file in the backend.

Frontend and backend are natural layers that cannot be avoided. We build a backend mainly because we don't want to expose all data and features to everyone accessing the frontend. So, through an authentication mechanism, we restrict access to some parts of the application. And that can only be achieved on the backend side.

How about all other layers such as the UI, the API client, the API server, and the database? Well, for most applications (including the RealWorld example apps), those extra layers are not actual requirements. We build them because we don't have much choice, and we fall into the trap of accidental complexity. So, the code needed to implement a feature ends up being scattered into many layers, and our well-architected application becomes a hell to maintain.

Sometimes there are valid reasons to split the code into different layers. For example, we plan to build multiple UIs for the same shared domain model, or we want to expose a public REST (or GraphQL) API in addition to the application's internal API. But these extra layers should be built on deliberate choices, and not because we use a technology that forces us to do so.

Duplication of knowledge

Since the code is scattered into many layers, it is challenging to not duplicate knowledge.

Frontend and backend models get duplicated. We duplicate schemas and validation logic. And to make our frontend smoother, we implement optimistic updates that duplicate some business logic.

On the backend side, if we are lucky enough, we use an ORM abstracting the database so we don't have so much duplication.

However, at some point, we must expose the backend to the frontend, so we build a web API, and this is where the duplication occurs.

Too much boilerplate

API client and server are nothing but boilerplate code. They don't provide any features to the application. They are just a necessary evil so the frontend can communicate with the backend.

Using a state manager like Redux sounds appealing at first. Pure functions or immutability satisfy the scientific mind of the engineer, but they generate a lot of boilerplate code.

Binding the domain model to the UI is another source of boilerplate. Some libraries (e.g., MobX) are doing a good job at reducing it as much as possible, but still, since the UI components are living in a completely different world from the domain model, the binding code is quite cumbersome.

So what?

The result is that we waste time, the developer experience is not great, and the risk of producing poor quality software increases.

Don't get me wrong. I am not throwing rocks at the authors of the RealWorld example apps, let alone the authors of the libraries and frameworks.

Considering the paradigms that drive software development today, everyone is doing their very best to build the most appropriate solutions.

So what's wrong? Well, perhaps it is time to reconsider the prevailing paradigms.

Introducing Liaison

I experimented with new approaches for trying to solve the problems mentioned above. The result is a set of libraries gathered into something called Liaison.

Cross-layer class inheritance

First of all, Liaison strives to reconcile the frontend with the backend. Typically, they are living in two very separate worlds, making communication between them difficult. Even if they are implemented with the same language, they cannot communicate directly. They need a web API in between, and that ruins all the developer experience.

So here's the first crazy idea. What if the frontend could somehow inherit some classes from the backend, and the subclasses in the frontend could call the backend directly?

This is precisely what Liaison offers – a mechanism enabling class inheritance across layers.

The backend can expose some classes so they can be used in the context of the frontend as regular JavaScript classes. They can be extended, their methods can be overridden, etc. And when a method is called, it doesn't matter if the execution happens in the frontend or the backend. For the developer, frontend and backend become one unified world.

For example, let's say the frontend is running the following code:

await user.follow(anotherUser);

Depending on the implementation of follow(), the execution can happen in the frontend, the backend, or both. When a method is missing in the frontend, and there is a corresponding method exposed by the backend, this method is automatically executed.

The attributes of the involved instances (user and anotherUser) are transported to the backend, the method is executed, and if some attributes have changed during the execution, the changes are reflected in the frontend.

So there is no need to build a web API anymore. For sure, you can still implement such an API if you need it (for example, you intend to open your application to third party developers), but for all your internal needs, you will greatly benefit from doing without it.

With this approach, we can dramatically reduce code scattering, duplication of knowledge, and boilerplate.

View methods

The second crazy idea is to implement the UI views as methods of the domain models.

Typically, we implement models and views into completely different classes. We used to do so because views were mostly made of imperative code, but now that we have functional UI libraries such as React, why not just implementing the views as methods of the models they rely on?

For example, here is a User model with a Summary view:

class User {
  Summary = () => {
    return <div>{this.firstName} {this.lastName}</div>;
  };
}

Then, to render the summary of a User instance, we can just do:

<user.Summary />

Is there something wrong with it? I don't think so.

Of course, if you don't want to clutter your models with views, or if you need to implement views for different platforms, you are free to subclass your models and implement the views into these subclasses.

Anyway, collocating models and views in the same classes (or in some subclasses) greatly helps reducing code scattering.

Routable methods

The third idea might not be so crazy because it is already present in some popular frameworks such as Spring for Java. However, I haven't seen it yet in the JavaScript ecosystem.

The idea is simple. A route defines an alternate way to call a method.

For example, in our User class, let's decorate the signIn() method with a route:

class User {
  @route('/sign-in') signIn() {
    // ...
  }
}

Now, our method is callable with an URL:

user.$callRoute('/sign-in'); // Equivalent to `user.signIn()`

However, in practice, you will rarely call your methods like that. It's easier to call them directly. Routes are leveraged by @liaison/router that tracks the browser's current location. And when the location changes, the method matching the URL is called automatically.

Routes can include parameters, and since methods can implement views (see above), we get a nice way to organize our frontend pages.

For example, here is how we may implement a user profile page:

class User {
  @route('/users/:username') static Profile({username}) {
    // Fetch the user by username
    // ...
    // Then call the `Profile` instance view
    return <user.Profile>;
  };
}

This way, we don't need to define the routes in a separate file (typically, a central router), so there is no code scattering.

My RealWorld implementation

I implemented the RealWorld example (both frontend and backend) with Liaison.

I might be a bit biased, but the outcome looks quite amazing to me: high code cohesion, 100% DRY, and zero boilerplate.

In terms of the amount of code (in case it matters) my implementation is significantly lighter (see results here) than any other I have examined.

If you can, please have a look at the code. I'd like to know what you guys think.

Liaison is still at the stage of proof of concept though, and quite a lot of work remains so it can be used in production. I am working actively on it, and I hope I'll be able to release a beta version in early 2020.

qiulang commented 1 year ago

I know this is old issue but I have a particular question that I can't find satisfied answer no matter how I google it: I am particularly unhappy with the duplication of code/knowledge about Router logic in both frontend and backend.

Basically both backend (especially REST api server ) and frontend need router codes and most of times (in my app) they are duplicated in many ways. For example the router code in backend with Expressjs (or whatever framework) and the router code in frontend with React/Vue isn't much different. But I can't find any way to reduce the duplication. I google the issue and can't find any articles talking this as if this is not a problem at all.

I would like know what is your thought about this ?

Thanks.

geromegrignon commented 1 year ago

For example the router code in backend with Expressjs (or whatever framework) and the router code in frontend with React/Vue isn't much different. But I can't find any way to reduce the duplication.

Can you share some examples?

qiulang commented 1 year ago

For example, this is one of my projects, the frontend uses vue, its router.js looks like following

const routes = [
    {
        path: '/',
        name: 'home',
        component: Layout,
        redirect: () => {
            return {
                name: 'home-view'
            }
        },
        children: [
            {
                path: '/statistics',
                component: () => import(/* webpackChunkName: "home" */ '../views/home'),
                name: 'home-view'
            },
            {
                path: '/statistics/:id',
                ...
            },
            {
                path: '/call/call_record',
                ...
            },
            {
                path: '/call/call_screen',
                ....
            },
            {
                path: '/call/outcall_task',
                ....
            },

The backend router.js has the similar codes, for each path in frontend, there is a correspond path defined.

geromegrignon commented 1 year ago

Thanks for the details. It only works for simple use cases: at some point, it's pretty common to get a page relying on data retrieved from different endpoints: With RealWorld example, you need data from the user, the tags and the articles on the Home page.

And as your application evolves, the endpoints do too, that's common to use the versioning in the endpoint URL like /api/v2/call/call_record. It would break on the frontend side here.

geromegrignon commented 1 year ago

We had a related discussion about that on the Angular Discord server a few days ago though.

geromegrignon commented 1 year ago

Given your snippet, while there is work in progress to run Angular on the server side with improvements on Angular Universal, other SSR solutions are embracing a common codebase philosophy, like Remix or NextJS, without relying on an interface like a REST API between the 2 layers.

qiulang commented 1 year ago

Thanks for the reply. I think we can further explore what you said, which also happens in my case:

It's pretty common to get a page relying on data retrieved from different endpoints ... that's common to use the versioning in the endpoint URL

When that happen, backend needs to add new APIs and the correspond router settings. But there is alway a need (at least in my case) combine all these APIs together so each frontend page gets all the data it needs. In my case it is the frontend combines/call all APIs together. But actually backend can do this job too, so the frontend page can just call one API (or so) to get the data it needs.

So one of my points is that this knowledge is sharable but unfortunately right now it falls on the hand of who does the combining job. In my team, backend guys often does not have a clear idea which API is for which page or for this particular page what APIs it needs. Documentation is always easier said than done. I can't find an easy solution.

geromegrignon commented 1 year ago

That's the responsibility of the consumer to combine, not the provider. Combining is about more than just getting the data. It's about getting errors too, redirecting to another page if some data is in failure without calling the others, getting some data ahead of time, about having a way to cancel one call only, about using different loaders based on the data, about caching some parts in the browser too.