Conoce cómo los microfrontends y la federación de módulos en Angular pueden simplificar el desarrollo de aplicaciones escalables, mejorando la modularidad y facilitando la colaboración entre equipos.

¿Qué es la federación de módulos en Angular y cuál es su rol en los microfrontends?

La federación de módulos es un componente clave en la arquitectura de microfrontends, que permite que distintas aplicaciones (o partes de una aplicación) compartan módulos entre sí en tiempo de ejecución.

Este enfoque permite dividir una aplicación monolítica tradicional en varios microfrontends independientes, cada uno de ellos funcionando como una pequeña aplicación con su propio ciclo de desarrollo y despliegue. Esto no solo mejora la escalabilidad de la aplicación, sino que también facilita la colaboración entre diferentes equipos, lo que permite que puedan trabajar en paralelo y de manera independiente, aislando partes específicas del negocio.

En Angular, la federación de módulos se implementa utilizando Webpack Module Federation, una característica que permite que los microfrontends carguen sus propios módulos y los compartan entre sí en tiempo de ejecución. Este enfoque es fundamental para mantener la independencia de los equipos y, a la vez, garantizar que los diferentes microfrontends puedan comunicarse y funcionar dentro de un mismo ecosistema. Además, permite la reutilización de código entre ellos, optimizando los recursos y reduciendo duplicidades.

Otro beneficio clave es que la federación de módulos permite que cada microfrontend sea desarrollado y mantenido con distintas versiones de Angular, o incluso con diferentes tecnologías, lo que añade flexibilidad al proyecto. Esto permite a los equipos adoptar nuevas versiones o tecnologías gradualmente, sin interrumpir el funcionamiento de otros microfrontends ya en producción.

Diagrama de arquitectura de una aplicación monolítica vs una aplicación de microfrontends.
Diagrama de arquitectura de una aplicación monolítica vs una aplicación de microfrontends.

Componentes clave

Para entender completamente cómo funciona la arquitectura de microfrontends en Angular, es importante conocer las diferentes partes que la componen. Cada una de estas partes tiene un rol específico en la construcción de la aplicación.

Host

El host es la parte principal de la arquitectura de microfrontends. Actúa como el contenedor que carga y gestiona los microfrontends. Sus principales funciones son:

Microfrontends

Cada microfrontend es una aplicación independiente que tiene su propio ciclo de vida, pero está diseñada para integrarse con el host y otros microfrontends. Cada microfrontend puede ser:

Comunicación entre microfrontends

La comunicación es un punto crucial en la arquitectura de microfrontends, ya que cada módulo puede necesitar información de otro. Las técnicas más utilizadas son:

Webpack Module Federation

Webpack Module Federation es la tecnología que permite la federación de módulos. Sus funciones clave son:

Ciclo de CI/CD

Cada microfrontend puede tener su propio pipeline de integración y despliegue continuo (CI/CD). Esto significa que los equipos de desarrollo pueden actualizar y desplegar partes de la aplicación de manera independiente sin afectar al resto.

Casos de uso típicos para microfrontends en Angular

La implementación de microfrontends no es adecuada para todas las aplicaciones, pero puede ser una solución eficaz en ciertos escenarios. A continuación, se presentan algunos casos en los que la arquitectura de microfrontends ofrece claras ventajas sobre las aplicaciones monolíticas, facilitando el desarrollo y la escalabilidad de aplicaciones complejas:

Desafíos de implementar microfrontends

A pesar de sus beneficios, la implementación de microfrontends conlleva varios desafíos que es importante tener en cuenta:

Ejemplo práctico de implementación

Para comprender cómo funcionan los microfrontends en Angular, haremos un ejemplo práctico utilizando Webpack Module Federation. Configuraremos un proyecto en el que un host cargará dinámicamente dos microfrontends independientes, uno para el listado de productos y otro para la cesta de compra, cada uno de ellos con su propio ciclo de vida, pero con elementos compartidos a través del host.

Creación del host y de los microfrontends

Primero, creamos la aplicación host con el siguiente comando:

ng new host-app --routing --style=scss
cd host-app

Luego, instalamos y configuramos Webpack Module Federation que se encargará de crear los archivos de configuración necesarios y de modificar los archivos main.ts y bootstrap.ts para habilitar la carga dinámica de módulos:

ng add @angular-architects/module-federation --project host-app --port 4200

Este comando realizará los siguientes cambios en el proyecto:

A continuación, repetimos un proceso similar para crear los microfrontends. Cada microfrontend tendrá su propio proyecto, con las configuraciones necesarias para integrarse en el host.

Creamos el primer microfrontend para el listado de productos:

ng new products-app --routing --style=scss
cd products-app

Luego, configuramos Webpack Module Federation para este microfrontend:

ng add @angular-architects/module-federation --project products-app --port 4201

Al igual que con el host, este comando añadirá la configuración de Webpack y modificará main.ts y bootstrap.ts para permitir la carga dinámica y la integración con el host.

Después, crearemos el segundo microfrontend para la cesta de compra:

ng new cart-app --routing --style=scss
cd cart-app

Instalamos y configuramos Webpack Module Federation para este microfrontend ejecutando:

ng add @angular-architects/module-federation --project cart-app --port 4202

Configuración del microfrontend listado de productos (MF1)

En este microfrontend, vamos a crear un módulo y un componente que se encargará de mostrar un listado de productos. Este listado cargará la información de un archivo de constantes en el proyecto.

Comencemos creando el módulo y el componente principal del listado de productos:

cd products-app
ng generate module modules/products --routing
ng generate component modules/products/components/products --flat

El template del componente ProductsComponent tendrá una tabla que mostrará todos los productos con columnas para el nombre, el precio y una acción para añadir el producto a la cesta:

<div class="catalog__container">
 <h2 class="catalog__title">Catálogo de productos</h2>


 <table class="table">
   <thead class="table-head">
     <tr>
       <th class="table-cell">Producto</th>
       <th class="table-cell">Precio</th>
       <th class="table-cell">Acción</th>
     </tr>
   </thead>
   <tbody>
     <tr *ngFor="let product of products" class="table-row">
       <td class="table-cell">{{ product.name }}</td>
       <td class="table-cell">{{ product.price | currency:'EUR' }}</td>
       <td class="table-cell">
         <button class="button" (click)="addToCart(product)">Añadir a la cesta</button>
       </td>
     </tr>
   </tbody>
 </table>
</div>


<dialog #confirmationDialog>
 {{ dialogMessage }}
 <button (click)="closeDialog(confirmationDialog)">Cerrar</button>
</dialog>

En cuanto a la funcionalidad del componente, va a permitir añadir productos a la cesta. La función addToCart almacenará los productos en el localStorage, incrementando la cantidad si el producto ya existe en la cesta. Además, mostrará un diálogo de confirmación al añadir un producto:

import { Component } from '@angular/core';
import { PRODUCTS_CATALOG } from '../../../shared/constants/products.contants';


@Component({
 selector: 'app-products',
 templateUrl: './products.component.html',
 styleUrls: ['./products.component.scss']
})
export class ProductsComponent {
 products = PRODUCTS_CATALOG;
 dialogMessage: string = '';


 addToCart(product: any): void {
   let cart = JSON.parse(localStorage.getItem('cart') || '[]');


   const productIndex = cart.findIndex((item: any) => item.id === product.id);
   if (productIndex === -1) {
     cart.push({ ...product, quantity: 1 });
   } else {
     cart[productIndex].quantity += 1;
   }


   localStorage.setItem('cart', JSON.stringify(cart));


   this.dialogMessage = `${product.name} añadido a la cesta.`;
   const dialog = document.querySelector('dialog');
   dialog?.showModal();
 }


 closeDialog(dialog: HTMLDialogElement): void {
   dialog.close();
 }
}

Definimos un archivo de constantes para el catálogo de productos en el fichero products.constants.ts:

export const PRODUCTS_CATALOG = [
 { id: 1, name: 'Producto A', price: 29.99, },
 { id: 2, name: 'Producto B', price: 19.99, },
 { id: 3, name: 'Producto C', price: 39.99, },
 { id: 4, name: 'Producto D', price: 12.99, },
 { id: 5, name: 'Producto E', price: 14.99, },
];

Para que el módulo ProductsModule tenga su propia ruta, actualizaremos el archivo products-routing.module.ts para definir la ruta principal del componente:

[...]
import { ProductsComponent } from './components/products.component';
const routes: Routes = [
 {
   path: '',
   component: ProductsComponent,
 },
];
[...]

Asimismo, en el archivo principal de rutas del microfrontend, app-routing.module.ts, configuramos que la aplicación cargue este módulo al inicio:

[...]
const routes: Routes = [
 {
   path: "",
   loadChildren: () =>
     import("./modules/products/products.module").then(
       (m) => m.ProductsModule
     ),
 },
 { path: '**', redirectTo: '', pathMatch: 'full' },
];
[...]

Para que el módulo ProductsModule funcione correctamente dentro de nuestra arquitectura de microfrontends, es necesario ajustar la configuración de enrutamiento en este mismo archivo. Para ello, hay que modificar esta línea:

 imports: [RouterModule.forRoot(routes)],

La configuración forRoot se usa para definir el enrutador principal de la aplicación. Pero como nuestro módulo de productos es solo una parte que se carga dentro de la aplicación principal, no debe ser el enrutador principal. Por eso, cambiamos forRoot a forChild:

 imports: [RouterModule.forChild(routes)],

Al usar forChild, le estamos diciendo a Angular que el módulo de productos es una parte dentro de la aplicación principal, no el enrutador principal. Esto permite que se conecte bien con el enrutador del host sin causar problemas.

Por último, para que otros microfrontends puedan consumir el módulo ProductsModule, modificamos el archivo webpack.config.js, añadiendo el módulo a la configuración de ModuleFederationPlugin en la sección de plugins:

 plugins: [
   new ModuleFederationPlugin({
   [...]
       name: "productsApp",
       filename: "remoteEntry.js",
       exposes: {
         './ProductsModule': './src/app/app.module.ts',
       },
   [...]

En esta configuración, remoteEntry.js se define como el punto de entrada que permitirá a otros microfrontends cargar el módulo de productos cuando lo necesiten. Esto facilita la integración entre microfrontends y garantiza que ProductsModule esté disponible en toda la arquitectura, habilitando el acceso a sus componentes, servicios y cualquier otra funcionalidad que exponga.

Configuración del microfrontend cesta de la compra (MF2)

En este microfrontend, vamos a crear un módulo y un componente que se encargará de mostrar la cesta de la compra, el precio total de los productos y una opción para vaciar la cesta. La información se obtendrá del localStorage.

Comenzamos creando el módulo y el componente principal de la cesta de la compra:

cd cart-app
ng generate module modules/cart --routing
ng generate component modules/cart/components/cart --flat

El template del componente CartComponent tendrá una tabla que va a mostrar los productos en la cesta con columnas para el nombre, el precio, la cantidad y el total. Además, incluirá un botón para vaciar la cesta y un resumen del precio total:

<div class="cart__container">
 <h2 class="cart__title">Tu cesta</h2>


 <button (click)="clearCart()" class="cart__clear-button">
   <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
     [...]
   </svg>
   Vaciar cesta
 </button>


 <table class="cart__table table">
   <thead class="table-head">
     <tr>
       <th class="table-cell">Producto</th>
       <th class="table-cell">Precio</th>
       <th class="table-cell">Cantidad</th>
       <th class="table-cell">Total</th>
     </tr>
   </thead>
   <tbody>
     <tr *ngFor="let product of products" class="table-row">
       <td class="table-cell">{{ product.name }}</td>
       <td class="table-cell">{{ product.price | currency:'EUR' }}</td>
       <td class="table-cell">{{ product.quantity }}</td>
       <td class="table-cell">{{ (product.price * product.quantity) | currency:'EUR' }}</td>
     </tr>
   </tbody>
 </table>


 <div class="cart__total-container">
   <h3 class="cart__total-text">Total: {{ getTotalPrice() | currency:'EUR' }}</h3>
 </div>
</div>

En cuanto a la funcionalidad del componente CartComponent, cargará la cesta desde el localStorage, calculará el precio total de los productos y permitirá vaciar la cesta:

import { Component, OnInit } from '@angular/core';


@Component({
 selector: 'app-cart',
 templateUrl: './cart.component.html',
 styleUrls: ['./cart.component.scss']
})
export class CartComponent implements OnInit {
 products: any[] = [];
  ngOnInit(): void {
   this.loadCart();
 }


 loadCart(): void {
   const cart = JSON.parse(localStorage.getItem('cart') || '[]');
   this.products = cart;
 }


 getTotalPrice(): number {
   return this.products.reduce((total, product) => total + product.price * product.quantity, 0);
 }


 clearCart(): void {
   localStorage.removeItem('cart');
   this.products = [];
 }
}

Para que el módulo CartModule tenga su propia ruta, vamos a configurar el archivo cart-routing.module.ts para definir la ruta principal del componente:

[...]
const routes: Routes = [
 {
   path: '',
   component: CartComponent,
 },
];
[...]

En el archivo principal de rutas del microfrontend, app-routing.module.ts, configuramos el módulo CartModule para que se cargue al iniciar la aplicación:

[...]
const routes: Routes = [
 {
   path: "",
   loadChildren: () =>
     import("./modules/cart/cart.module").then(
       (m) => m.CartModule
     ),
 },
 { path: '**', redirectTo: '', pathMatch: 'full' },
];
[...]
 imports: [RouterModule.forChild(routes)],
[...]

Además, ajustamos RouterModule para usar forChild, como hicimos en el MF1, asegurando que el módulo CartModule se comporte correctamente dentro de la aplicación principal.

Por último, debemos exponer CartModule para que otros microfrontends puedan acceder a él. Para ello, añadimos la configuración en webpack.config.js:

 plugins: [
   new ModuleFederationPlugin({
   [...]
       name: "cartApp",
       filename: "remoteEntry.js",
       exposes: {
         './CartModule': './src/app/app.module.ts',
       },
   [...]

Aquí, remoteEntry.js actúa nuevamente como el punto de entrada remoto, permitiendo que el módulo CartModule esté disponible por otros microfrontends dentro de nuestra arquitectura.

Configuración del host

Ahora que tenemos los microfrontends configurados y expuestos, vamos a ajustar las rutas del host para cargarlos dinámicamente y, además, añadiremos un componente header, un módulo home, un módulo shared y un componente layout para completar la estructura de la aplicación.

Comenzamos creando el módulo home que será la página de inicio de la aplicación. Este módulo incluirá un componente HomeComponent que mostrará una bienvenida, una breve descripción y algunos productos destacados.

En el template vamos a mostrar los productos destacados en una tabla y vamos a permitir al usuario añadirlos a la cesta mediante el botón “Añadir a la cesta”. Al hacer clic, se abre un diálogo de confirmación:

<div class="home__container">
   <h1 class="home__title">¡Bienvenido a nuestra tienda en línea!</h1>
   <p class="home__description">
     Explora nuestro catálogo de productos y añade tus favoritos a la cesta.
   
    <div class="home__featured">
     <h2 class="home__featured-title">Productos destacados</h2>
      <table class="table">
       <thead class="table-head">
         <tr>
           <th class="table-cell">Producto</th>
           <th class="table-cell">Precio</th>
           <th class="table-cell">Acción</th>
         </tr>
       </thead>
       <tbody>
         <tr *ngFor="let product of featuredProducts" class="table-row">
           <td class="table-cell">{{ product.name }}</td>
           <td class="table-cell">{{ product.price | currency:'EUR' }}</td>
           <td class="table-cell">
             <button class="button" (click)="addToCart(product)">Añadir a la cesta</button>
           </td>
         </tr>
       </tbody>
     </table>
   </div>
 </div>
 
 <dialog #confirmationDialog>
   {{ dialogMessage }}
   <button (click)="closeDialog(confirmationDialog)">Cerrar</button>
 </dialog>

En el código del componente, vamos a importar el catálogo de productos y a definir la lógica para añadir productos a la cesta, mostrando un mensaje de confirmación en un diálogo cuando se añade un producto:

import { Component } from '@angular/core';
import { PRODUCTS_CATALOG } from '../../../shared/constants/products.contants';


@Component({
 selector: 'app-home',
 templateUrl: './home.component.html',
 styleUrls: ['./home.component.scss'],
})
export class HomeComponent {
 featuredProducts = PRODUCTS_CATALOG.slice(0, 2);
 dialogMessage: string = '';


 addToCart(product: any): void {
   let cart = JSON.parse(localStorage.getItem('cart') || '[]');
   const productIndex = cart.findIndex((item: any) => item.id === product.id);
   if (productIndex === -1) {
     cart.push({ ...product, quantity: 1 });
   } else {
     cart[productIndex].quantity += 1;
   }
   localStorage.setItem('cart', JSON.stringify(cart));


   this.dialogMessage = `${product.name} añadido a la cesta.`;
   const dialog = document.querySelector('dialog');
   dialog?.showModal();
 }


 closeDialog(dialog: HTMLDialogElement): void {
   dialog.close();
 }
}

Para que el módulo HomeModule tenga su propia ruta, vamos a configurar el archivo home-routing.module.ts para definir la ruta principal del componente:

[...]
const routes: Routes = [
 {
   path: '',
   component: HomeComponent,
   pathMatch: 'full',
 },
];
[...]

Definimos un archivo de constantes products.contants.ts para simular los productos destacados:

export const PRODUCTS_CATALOG = [
 { id: 1, name: 'Producto A', price: 29.99 },
 { id: 2, name: 'Producto B', price: 19.99 },
 { id: 3, name: 'Producto C', price: 39.99 },
];

También vamos a crear otro fichero de constantes para tener definidas las rutas de nuestra aplicación llamado routes-path.ts:

export enum RoutesPath {
 ERROR = 'error',
 HOME = 'home',
 PRODUCTS = 'products',
 CART = 'cart',
}

Para navegar en nuestra aplicación entre los diferentes microfrontends, vamos a crear un módulo y componente para la cabecera:

ng generate module shared/modules/header
ng generate component shared/modules/header

En el template vamos a incluir enlaces de navegación hacia la home, el listado de productos y la cesta. Además, cada enlace utiliza iconos SVG para mejorar la interfaz visual:

<nav class="header">
   <ul class="header__nav">
     <li class="header__item">
       <a [routerLink]="RoutesPath.HOME" class="header__link">
           <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
             [...]
           </svg>
           Home
         </a>
     </li>
     <li class="header__item">
       <a [routerLink]="RoutesPath.PRODUCTS" class="header__link">
           <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
              [...]
           </svg>
           Productos
       </a>
     </li>
     <li class="header__item">
       <a [routerLink]="RoutesPath.CART" class="header__link">
           <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
        [...]
           </svg>
           Cesta
       </a>
     </li>
   </ul>
 </nav>

En el HeaderModule, importamos RouterModule para habilitar la navegación entre rutas:

[...]
import { RouterModule } from '@angular/router';


@NgModule({
 declarations: [HeaderComponent],
 imports: [CommonModule, RouterModule],
 exports: [HeaderComponent]
})
export class HeaderModule {}

Y en el componente HeaderComponent importamos el fichero de constantes de las rutas para que pueda ser utilizado en el template:

[...]
import { RoutesPath } from 'src/app/shared/constants/routes-path';
[...]
export class HeaderComponent {
 RoutesPath = RoutesPath;
}

Creamos el LayoutModule que incluirá un componente LayoutComponent para estructurar la aplicación y mostrar el header en cada vista:

ng generate module shared/modules/layout --routing
ng generate component shared/modules/layout

Añadimos el componente HeaderComponent en el template:

<app-header></app-header>
<router-outlet></router-outlet>

Importamos el módulo de RouterModule para habilitar la navegación entre rutas:

[...]
import { RouterModule } from '@angular/router';


@NgModule({
 imports: [RouterModule, LayoutRoutingModule, CommonModule],
})
export class LayoutModule {}

A continuación, para habilitar la integración de los microfrontends en el host, configuramos las rutas del LayoutRoutingModule de manera que cada microfrontend pueda cargarse dinámicamente según la ruta definida:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { loadRemoteModule } from '@angular-architects/module-federation';
import { LayoutComponent } from './layout.component';
import { RoutesPath } from '../../constants/routes-path';


const routes: Routes = [
 {
   path: '',
   component: LayoutComponent,
   children: [
     {
       path: `${RoutesPath.HOME}`,
       loadChildren: () =>
         import(
           '../../../modules/home/home.module'
         ).then((m) => m.HomeModule),
     },
     {
       path: `${RoutesPath.PRODUCTS}`,
       loadChildren: () =>
         loadRemoteModule({
           type: 'module',
           remoteEntry: 'http://localhost:4201/remoteEntry.js',
           exposedModule: './ProductsModule',
         }).then(m => m.AppModule),
     },
     {
       path: `${RoutesPath.CART}`,
       loadChildren: () =>
         loadRemoteModule({
           type: 'module',
           remoteEntry: 'http://localhost:4202/remoteEntry.js',
           exposedModule: './CartModule',
         }).then(m => m.AppModule),
     },
     { path: '', pathMatch: 'full', redirectTo: `${RoutesPath.HOME}` },
   ],
 },
];


@NgModule({
 imports: [RouterModule.forChild(routes)],
 exports: [RouterModule],
})
export class LayoutRoutingModule {}

Creamos el módulo shared para agrupar módulos compartidos como el del layout y el header. Esto facilita la importación en el módulo principal de la aplicación:

ng generate module shared/modules/shared --flat

En este módulo declaramos el componente LayoutComponent e importamos el módulo HeaderModule:

import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';


import { LayoutComponent } from './layout/layout.component';
import { HeaderModule } from './header/header.module';


@NgModule({
 declarations: [
   LayoutComponent,
 ],
 imports: [
   RouterModule,
   CommonModule,
   HeaderModule,
 ],
 exports: [
   RouterModule,
   CommonModule,
   HeaderModule,
 ],
})
export class SharedModule {}

Finalmente, configuramos el AppRoutingModule para que el layout actúe como la estructura base de la aplicación:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';


const routes: Routes = [
 {
   path: '',
   loadChildren: () =>
     import(
       './shared/modules/layout/layout-routing.module'
     ).then((m) => m.LayoutRoutingModule),
 },
];


@NgModule({
 imports: [RouterModule.forRoot(routes)],
 exports: [RouterModule]
})
export class AppRoutingModule { }

También modificamos el AppModule para que use SharedModule y AppRoutingModule:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';


import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { SharedModule } from './shared/modules/shared.module';


@NgModule({
 declarations: [
   AppComponent,
 ],
 imports: [
   BrowserModule,
   BrowserAnimationsModule,
   SharedModule,
   AppRoutingModule
 ],
 providers: [],
 bootstrap: [AppComponent]
})
export class AppModule { }

Para finalizar, en cada app.component.html nos aseguramos que nuestro template solo tenga <router-outlet></router-outlet> para cargar las vistas de cada módulo:

<router-outlet></router-outlet>

Para facilitar la legibilidad del artículo no se han incluido los archivos de estilos, pero puedes encontrarlos junto con el resto del código en los siguientes repositorios: angular-microfrontends-host, angular-microfrontends-products y angular-microfrontends-cart.

Conclusión

La arquitectura de microfrontends con Webpack Module Federation en Angular permite escalar y modularizar aplicaciones complejas, dividiéndolas en componentes independientes que facilitan el desarrollo en equipos grandes. Esta estructura optimiza la colaboración, ya que permite que los equipos trabajen en paralelo, cada uno con su ciclo de desarrollo y despliegue, sin interferir en el trabajo de los demás.

Al adoptar esta arquitectura, los proyectos ganan en flexibilidad y adaptabilidad, lo que simplifica el mantenimiento del código a largo plazo. No obstante, es importante considerar que los microfrontends no siempre son la solución ideal para todas las aplicaciones; su implementación es especialmente útil en proyectos que requieren la integración de múltiples tecnologías y módulos, y que necesitan una experiencia de usuario unificada.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete