Arquitectura unificada: una forma más sencilla de crear aplicaciones de pila completa

Las aplicaciones modernas de pila completa, como las aplicaciones de una sola página o las aplicaciones móviles, suelen tener seis capas

  • acceso a los datos
  • modelo de backend
  • Servidor API
  • Cliente API
  • modelo de interfaz
  • e interfaz de usuario.

Al diseñar la arquitectura de esta manera, puede lograr algunas características de una aplicación bien diseñada, como la separación de preocupaciones o el acoplamiento flojo.

Pero esto no está exento de inconvenientes. Por lo general, se produce a costa de otras características importantes, como la simplicidad, la cohesión y la agilidad.

Parece que no podemos tenerlo todo. Tenemos que comprometernos.

El problema es que los desarrolladores suelen construir cada capa como un mundo completamente diferente por sí solo.

Incluso si implementa las capas con el mismo idioma, no se pueden comunicar entre sí con mucha facilidad.

Necesitaría mucho código de pegamento para conectarlos a todos, y el modelo de dominio se duplica en la pila. Como resultado, su agilidad de desarrollo sufre dramáticamente.

Por ejemplo, agregar un campo simple a un modelo a menudo requiere modificar todas las capas de la pila. Esto puede parecer un poco ridículo.

Bueno, he estado pensando mucho sobre este problema recientemente. Y creo que encontré una salida.

Aquí está el truco: seguro, las capas de una aplicación deben estar separadas "físicamente". Pero no es necesario que estén separados "lógicamente".

La Arquitectura Unificada

Arquitectura tradicional vs unificada

En la programación orientada a objetos, cuando usamos la herencia, obtenemos algunas clases que se pueden ver de dos formas: física y lógica. ¿Qué quiero decir con eso?

Imaginemos que tenemos una clase Bque hereda de una clase A. Entonces, Ay Bpueden verse como dos clases físicas. Pero lógicamente, no están separados y Bpueden verse como una clase lógica que compone las propiedades de Acon sus propias propiedades.

Por ejemplo, cuando llamamos a un método en una clase, no tenemos que preocuparnos si el método está implementado en esta clase o en una clase principal. Desde la perspectiva de la persona que llama, solo hay una clase de la que preocuparse. Padre e hijo están unificados en una sola clase lógica.

¿Qué tal aplicar el mismo enfoque a las capas de una aplicación? ¿No sería genial si, por ejemplo, el frontend pudiera heredar de alguna manera del backend?

Al hacerlo, el frontend y el backend se unificarían en una sola capa lógica. Y eso eliminaría todos los problemas de comunicación y uso compartido. De hecho, las clases, los atributos y los métodos de backend serían directamente accesibles desde el frontend.

Por supuesto, normalmente no queremos exponer todo el backend al frontend. Pero lo mismo ocurre con la herencia de clases, y existe una solución elegante que se llama "propiedades privadas". De manera similar, el backend podría exponer selectivamente algunos atributos y métodos.

Ser capaz de captar todas las capas de una aplicación de un solo mundo unificado no es poca cosa. Cambia el juego por completo. Es como pasar de un mundo 3D a un mundo 2D. Todo se vuelve mucho más fácil.

La herencia no es mala. Sí, se puede usar incorrectamente y, en algunos idiomas, puede ser bastante rígido. Pero cuando se usa correctamente, es un mecanismo invaluable en nuestra caja de herramientas.

Sin embargo, tenemos un problema. Hasta donde yo sé, no existe un lenguaje que nos permita heredar clases en múltiples entornos de ejecución. Pero somos programadores, ¿no? Podemos construir todo lo que queramos y podemos extender el lenguaje para brindar nuevas capacidades.

Pero antes de llegar a eso, analicemos la pila para ver cómo encaja cada capa en una arquitectura unificada.

Acceso a los datos

Para la mayoría de aplicaciones, la base de datos se puede abstraer usando algún tipo de ORM. Entonces, desde la perspectiva del desarrollador, no hay una capa de acceso a datos de la que preocuparse.

Para aplicaciones más ambiciosas, es posible que tengamos que optimizar los esquemas y las solicitudes de la base de datos. Pero no queremos saturar el modelo de backend con estas preocupaciones, y aquí es donde una capa adicional puede ser apropiada.

Creamos una capa de acceso a datos para implementar las preocupaciones de optimización, y esto generalmente ocurre al final del ciclo de desarrollo, si es que alguna vez ocurre.

De todos modos, si necesitamos una capa así, podemos construirla más tarde. Con la herencia entre capas, podemos agregar una capa de acceso a los datos encima de la capa del modelo de backend casi sin cambios en el código existente.

Modelo de backend

Normalmente, una capa de modelo de backend maneja las siguientes responsabilidades:

  • Dar forma al modelo de dominio.
  • Implementación de lógica empresarial.
  • Manejo de los mecanismos de autorización.

Para la mayoría de los backends, está bien implementarlos todos en una sola capa. Pero, si queremos manejar algunas inquietudes por separado, por ejemplo, queremos separar la autorización de la lógica empresarial, podemos implementarlas en dos capas que se heredan entre sí.

Capas API

Para conectar el frontend y el backend, generalmente construimos una API web (REST, GraphQL, etc.), y eso complica todo.

La API web debe implementarse en ambos lados: un cliente API en el frontend y un servidor API en el backend. Son dos capas adicionales de las que preocuparse y, por lo general, lleva a duplicar todo el modelo de dominio.

Una API web no es más que un código adhesivo, y es un dolor de cabeza construir. Entonces, si podemos evitarlo, es una gran mejora.

Afortunadamente, podemos volver a aprovechar la herencia entre capas. En una arquitectura unificada, no hay ninguna API web que crear. Todo lo que tenemos que hacer es heredar el modelo de frontend del modelo de backend y listo.

Sin embargo, todavía hay algunos buenos casos de uso para crear una API web. Ahí es cuando necesitamos exponer un backend a algunos desarrolladores externos, o cuando necesitamos integrarnos con algunos sistemas heredados.

Pero seamos honestos, la mayoría de las aplicaciones no tienen ese requisito. Y cuando lo hacen, es fácil de manejar después. Simplemente podemos implementar la API web en una nueva capa que hereda de la capa del modelo de backend.

Se puede encontrar más información sobre este tema en este artículo.

Modelo de interfaz

Since the backend is the source of truth, it should implement all the business logic, and the frontend should not implement any. So, the frontend model is simply inherited from the backend model, with almost no additions.

User Interface

We usually implement the frontend model and the UI in two separate layers. But as I showed in this article, it is not mandatory.

When the frontend model is made of classes, it is possible to encapsulate the views as simple methods. Don't worry if you don't see what I mean right now, it will become clearer in the example later on.

Since the frontend model is basically empty (see above), it is fine to implement the UI directly into it, so there is no user interface layer per se.

Implementing the UI in a separate layer is still needed when we want to support multiple platforms (e.g., a web app and a mobile app). But, since it is just a matter of inheriting a layer, that can come later in the development roadmap.

Putting Everything Together

The unified architecture allowed us to unify six physical layers into one single logical layer:

  • In a minimal implementation, data access is encapsulated into the backend model, and the same goes for UI that is encapsulated into the frontend model.
  • The frontend model inherits from the backend model.
  • The API layers are not required anymore.

Again, here's what the resulting implementation looks like:

Arquitectura tradicional vs unificada

That's pretty spectacular, don't you think?

Liaison

To implement a unified architecture, all we need is cross-layer inheritance, and I started building Liaison to achieve exactly that.

You can see Liaison as a framework if you wish, but I prefer to describe it as a language extension because all its features lie at the lowest possible level — the programming language level.

So, Liaison does not lock you into a predefined framework, and a whole universe can be created on top of it. You can read more on this topic in this article.

Behind the scene, Liaison relies on an RPC mechanism. So, superficially, it can be seen as something like CORBA, Java RMI, or .NET CWF.

But Liaison is radically different:

  • It is not a distributed object system. Indeed, a Liaison backend is stateless, so there are no shared objects across layers.
  • It is implemented at the language-level (see above).
  • Its design is straightforward and it exposes a minimal API.
  • It doesn't involve any boilerplate code, generated code, configuration files, or artifacts.
  • It uses a simple but powerful serialization protocol (Deepr) that enables unique features, such as chained invocation, automatic batching, or partial execution.

Liaison starts its journey in JavaScript, but the problem it tackles is universal, and it could be ported to any object-oriented language without too much trouble.

Hello Counter

Let's illustrate how Liaison works by implementing the classic "Counter" example as a single-page application.

First, we need some shared code between the frontend and the backend:

// shared.js import {Model, field} from '@liaison/liaison'; export class Counter extends Model { // The shared class defines a field to keep track of the counter's value @field('number') value = 0; } 

Then, let's build the backend to implement the business logic:

// backend.js import {Layer, expose} from '@liaison/liaison'; import {Counter as BaseCounter} from './shared'; class Counter extends BaseCounter { // We expose the `value` field to the frontend @expose({get: true, set: true}) value; // And we expose the `increment()` method as well @expose({call: true}) increment() { this.value++; } } // We register the backend class into an exported layer export const backendLayer = new Layer({Counter}); 

Finally, let's build the frontend:

// frontend.js import {Layer} from '@liaison/liaison'; import {Counter as BaseCounter} from './shared'; import {backendLayer} from './backend'; class Counter extends BaseCounter { // For now, the frontend class is just inheriting the shared class } // We register the frontend class into a layer that inherits from the backend layer const frontendLayer = new Layer({Counter}, {parent: backendLayer}); // Lastly, we can instantiate a counter const counter = new frontendLayer.Counter(); // And play with it await counter.increment(); console.log(counter.value); // => 1 

What's going on? By invoking counter.increment(), we got the counter's value incremented. Notice that the increment() method is neither implemented in the frontend class nor in the shared class. It only exists in the backend.

So, how is it possible that we could call it from the frontend? This is because the frontend class is registered in a layer that inherits from the backend layer. So, when a method is missing in the frontend class, and a method with the same name is exposed in the backend class, it is automatically invoked.

From the frontend point of view, the operation is transparent. It doesn't need to know that a method is invoked remotely. It just works.

The current state of an instance (i.e., counter's attributes) is automatically transported back and forth. When a method is executed in the backend, the attributes that have been modified in the frontend are sent. And inversely, when some attributes change in the backend, they are reflected in the frontend.

Note that in this simple example, the backend is not exactly remote. Both the frontend and the backend run in the same JavaScript runtime. To make the backend truly remote, we can easily expose it through HTTP. See an example here.

How about passing/returning values to/from a remotely invoked method? It is possible to pass/return anything that is serializable, including class instances. As long as a class is registered with the same name in both the frontend and the backend, its instances can be automatically transported.

How about overriding a method across the frontend and the backend? It is no different than with regular JavaScript – we can use super. For example, we can override the increment() method to run additional code in the context of the frontend:

// frontend.js class Counter extends BaseCounter { async increment() { await super.increment(); // Backend's `increment()` method is invoked console.log(this.value); // Additional code is executed in the frontend } } 

Now, let's build a user interface with React and the encapsulated approach shown earlier:

// frontend.js import React from 'react'; import {view} from '@liaison/react-integration'; class Counter extends BaseCounter { // We use the `@view()` decorator to observe the model and re-render the view when needed @view() View() { return ( {this.value}  this.increment()}>+ ); } } 

Finally, to display the counter, all we need is:

Voilà! We built a single-page application with two unified layers and an encapsulated UI.

Proof of Concept

To experiment with the unified architecture, I built a RealWorld example app with Liaison.

I might be biased, but the outcome looks pretty amazing to me: simple implementation, high code cohesion, 100% DRY, and no glue code.

In terms of the amount of code, my implementation is significantly lighter than any other one I have examined. Check out the results here.

Certainly, the RealWorld example is a small application, but since it covers the most important concepts that are common to all applications, I'm confident that a unified architecture can scale up to more ambitious applications.

Conclusion

Separation of concerns, loose coupling, simplicity, cohesion, and agility.

It seems we get it all, finally.

If you are an experienced developer, I guess you feel a bit skeptical at this point, and this is totally fine. It is hard to leave behind years of established practices.

If object-oriented programming is not your cup of tea, you will not want to use Liaison, and this is totally fine as well.

Pero si te gusta la POO, mantén una pequeña ventana abierta en tu mente y la próxima vez que tengas que crear una aplicación de pila completa, intenta ver cómo encajaría en una arquitectura unificada.

Liaison aún se encuentra en una etapa temprana, pero estoy trabajando activamente en ello y espero lanzar la primera versión beta a principios de 2020.

Si está interesado, destaque el repositorio y manténgase actualizado siguiendo el blog o suscribiéndose al boletín.

Discuta este artículo sobre Changelog News .