Versión: 1.0
Fecha: 8 de Marzo, 2026
Estado: PROPUESTA TÉCNICA
Arquitecto: Carlos Alberto Torres Camargo
Elección: Nx Monorepo con Angular 21
Se evaluaron 3 opciones para la arquitectura frontend del ERP Nebula:
| Criterio | Single App (Mono) | Microfrontends | Nx Monorepo |
|---|---|---|---|
| Complejidad Setup | Baja | Muy Alta | Media |
| Escalabilidad Equipo | Limitada | Alta | Alta |
| Deploys Independientes | No | Sí | Sí (por librería) |
| Consistencia UI | Natural | Difícil | Natural + Enforced |
| Performance | Buena | Overhead Runtime | Óptima (Tree-shaking) |
| Curva de Aprendizaje | Baja | Muy Alta | Media |
| Adopción Industria ERP | Alta | 76% abandono* | Standard en Enterprise |
| Time-to-Market | Rápido (inicio) | Lento | Medio-Rápido |
*Fuente: ThoughtWorks Technology Radar 2025 - Microfrontends reportan 76% de abandono en proyectos empresariales < 20 devs.
app.routes.ts o shared/, los conflictos se multiplicannx affected:build)La arquitectura frontend se compone de 2 repositorios independientes:
| Proyecto | Repo GitLab | Propósito | Publicación |
|---|---|---|---|
nebula-ui-kit |
https://gitlab.centricasoluciones.com/nebula/frontend/nebula-ui-kit.git |
Component Library reutilizable (DataTable, FormField, Modal, Sidebar, Toolbar, etc.) | Publicado en Nexus como @nebula/ui-kit (paquete npm) |
nebula-erp |
https://gitlab.centricasoluciones.com/nebula/frontend/nebula-erp.git |
Nx Monorepo con dominios de negocio (Contabilidad, Tesorería, Comercio, etc.) | Deploy directo desde CI/CD |
Relación: nebula-erp declara @nebula/ui-kit como dependencia npm en su package.json. No hay acoplamiento de código fuente entre los dos repos.
¿Por qué separar la Component Library?
@nebula/ui-kit cuando estén listos@nebula/ui-kit sin depender del monorepo ERPEl backend de Nebula define 6 dominios de negocio con 16 microservicios + 6 transversales. El frontend refleja esta estructura 1:1 en el monorepo Nx.
BACKEND (Microservicios) → FRONTEND (Nx Libraries por Dominio)
═══════════════════════════════ → ════════════════════════════════════
→
TRANSVERSAL → @nebula/shared-*
├── nebula-masters → ├── shared-masters (data-access + feature)
├── nebula-file-storage → ├── shared-file-storage (data-access + ui)
└── nebula-notifications → └── shared-notifications (data-access + ui)
→
CONTABILIDAD → @nebula/accounting-*
├── nebula-accounting-core → ├── accounting-feature-plan-cuentas
│ → ├── accounting-feature-asientos
│ → ├── accounting-feature-centros-costo
│ → ├── accounting-data-access
│ → └── accounting-domain
└── nebula-treasury → @nebula/treasury-*
(bancos, cajas, conciliación) → ├── treasury-feature-bancos
→ ├── treasury-feature-conciliacion
→ ├── treasury-data-access
→ └── treasury-domain
→
INVENTARIO → @nebula/inventory-*
├── nebula-product-catalog → ├── inventory-feature-catalogo
├── nebula-inventory-ops → ├── inventory-feature-kardex
├── nebula-purchasing → ├── purchasing-feature-ordenes
└── nebula-fixed-assets → └── assets-feature-activos
→
COMERCIO / FACTURACIÓN → @nebula/commerce-*
├── nebula-invoicing → ├── commerce-feature-facturas
│ → ├── commerce-feature-cotizaciones
│ → ├── commerce-feature-cartera
└── nebula-electronic-billing → └── commerce-feature-facturacion-e
→
PERSONAS / RRHH → @nebula/people-*
├── nebula-hr-core → ├── people-feature-empleados
│ → ├── people-feature-capacitacion
└── nebula-payroll-settlement → └── payroll-feature-liquidacion
→
GOBIERNO / PRESUPUESTO → @nebula/government-*
├── nebula-budget-core → ├── budget-feature-planeacion
├── nebula-budget-execution → ├── budget-feature-ejecucion
├── nebula-contracts-core → ├── contracts-feature-paa
└── nebula-contracts-management → └── contracts-feature-gestion
→
FINANZAS / TRIBUTARIO → @nebula/finance-*
├── nebula-tax-core → ├── finance-feature-impuestos
└── nebula-tax-collection → └── finance-feature-recaudo
Regla: Las flechas indican dependencia permitida. Cualquier otra importación será bloqueada por ESLint.
nebula-ui-kit — Component Library (Repo Independiente)nebula-ui-kit/ # git clone https://gitlab.centricasoluciones.com/nebula/frontend/nebula-ui-kit.git
├── src/lib/ # Componentes reutilizables
│ ├── data-table/ # Smart Table (sort, filter, paginate)
│ │ ├── data-table.component.ts
│ │ ├── data-table.component.html
│ │ └── data-table.component.scss
│ ├── form-field/ # Input wrappers con validación
│ │ ├── form-field.component.ts
│ │ └── form-field.component.html
│ ├── modal/ # Diálogos modales
│ ├── sidebar-menu/ # Menú lateral
│ ├── toolbar/ # Barra de herramientas
│ ├── breadcrumb/ # Navegación breadcrumb
│ ├── notification-toast/ # Notificaciones toast
│ ├── confirm-dialog/ # Diálogo de confirmación
│ ├── file-upload/ # Carga de archivos
│ ├── empty-state/ # Estado vacío
│ ├── button/ # Botones estándar
│ ├── input/ # Inputs estándar
│ ├── select/ # Select/Dropdown
│ └── page-layout/ # Layout de página
├── src/styles/ # Estilos base (Bootstrap + manual UX/UI)
│ ├── _variables.scss
│ ├── _mixins.scss
│ └── _theme.scss
├── src/public-api.ts # Barrel exports
├── package.json # Nombre: @nebula/ui-kit
├── ng-package.json # Config ng-packagr para build
├── tsconfig.json
├── .eslintrc.json
└── README.md
Publicación: Se compila con
ng-packagry se publica en Nexus como@nebula/ui-kit.
Consumo:nebula-erplo instala connpm install @nebula/ui-kity lo importa comoimport { DataTableComponent } from '@nebula/ui-kit';
Versionado: Semver independiente. Un cambio en un componente UI no requiere rebuild del ERP.
nebula-erp — Monorepo Nx de Dominios de Negocionebula-erp/ # git clone https://gitlab.centricasoluciones.com/nebula/frontend/nebula-erp.git
├── apps/ # APLICACIONES DESPLEGABLES
│ ├── nebula-shell/ # App principal (shell)
│ │ ├── src/
│ │ │ ├── app/
│ │ │ │ ├── app.component.ts # Root component
│ │ │ │ ├── app.config.ts # Providers, interceptors
│ │ │ │ └── app.routes.ts # Lazy-loaded domain routes
│ │ │ ├── assets/
│ │ │ └── environments/
│ │ └── project.json
│ │
│ └── nebula-e2e/ # Tests E2E (Cypress/Playwright)
│
├── libs/ # LIBRERÍAS Nx (el corazón)
│ │
│ ├── shared/ # ── COMPARTIDAS (scope:shared) ──
│ │ │ ⚠️ NO hay shared/ui aquí. Los componentes UI vienen de
│ │ │ @nebula/ui-kit (paquete npm externo).
│ │ │
│ │ ├── util/ # Utilidades puras (pipes, helpers)
│ │ │ ├── src/lib/
│ │ │ │ ├── pipes/
│ │ │ │ │ ├── currency.pipe.ts
│ │ │ │ │ ├── date-format.pipe.ts
│ │ │ │ │ └── nit-format.pipe.ts
│ │ │ │ ├── validators/
│ │ │ │ │ ├── nit.validator.ts
│ │ │ │ │ └── numeric-range.validator.ts
│ │ │ │ ├── helpers/
│ │ │ │ │ ├── date.helper.ts
│ │ │ │ │ └── number.helper.ts
│ │ │ │ └── constants/
│ │ │ │ └── api-endpoints.ts
│ │ │ └── project.json # tags: ["scope:shared", "type:util"]
│ │ │
│ │ ├── data-access/ # Auth, HTTP, tenant context
│ │ │ ├── src/lib/
│ │ │ │ ├── auth/
│ │ │ │ │ ├── auth.service.ts
│ │ │ │ │ ├── auth.guard.ts
│ │ │ │ │ └── token.interceptor.ts
│ │ │ │ ├── http/
│ │ │ │ │ ├── api.service.ts # HttpClient wrapper
│ │ │ │ │ └── error.interceptor.ts
│ │ │ │ └── tenant/
│ │ │ │ └── tenant-context.service.ts
│ │ │ └── project.json # tags: ["scope:shared", "type:data-access"]
│ │ │
│ │ ├── domain/ # Interfaces compartidas cross-domain
│ │ │ ├── src/lib/
│ │ │ │ ├── base.dto.ts
│ │ │ │ ├── pagination.model.ts
│ │ │ │ ├── api-response.model.ts
│ │ │ │ └── audit-fields.model.ts
│ │ │ └── project.json # tags: ["scope:shared", "type:domain"]
│ │ │
│ │ └── masters/ # Maestros compartidos
│ │ ├── src/lib/
│ │ │ ├── data-access/
│ │ │ │ ├── paises.service.ts
│ │ │ │ ├── ciudades.service.ts
│ │ │ │ ├── tipos-documento.service.ts
│ │ │ │ └── impuestos.service.ts
│ │ │ ├── models/
│ │ │ │ ├── pais.model.ts
│ │ │ │ └── ciudad.model.ts
│ │ │ └── ui/
│ │ │ ├── pais-selector/
│ │ │ └── ciudad-selector/
│ │ └── project.json # tags: ["scope:shared", "type:feature"]
│ │
│ ├── accounting/ # ── CONTABILIDAD (scope:accounting) ──
│ │ ├── domain/ # Interfaces y tipos del dominio
│ │ │ ├── src/lib/
│ │ │ │ ├── cuenta-contable.model.ts
│ │ │ │ ├── asiento-contable.model.ts
│ │ │ │ ├── centro-costos.model.ts
│ │ │ │ ├── tipo-centro-costos.model.ts
│ │ │ │ ├── periodo-contable.model.ts
│ │ │ │ └── accounting.events.ts # Eventos cross-domain
│ │ │ └── project.json # tags: ["scope:accounting", "type:domain"]
│ │ │
│ │ ├── data-access/ # Servicios HTTP + State
│ │ │ ├── src/lib/
│ │ │ │ ├── cuenta-contable.service.ts
│ │ │ │ ├── asiento-contable.service.ts
│ │ │ │ ├── centro-costos.service.ts
│ │ │ │ └── accounting.store.ts # NgRx Signal Store
│ │ │ └── project.json # tags: ["scope:accounting", "type:data-access"]
│ │ │
│ │ ├── feature-plan-cuentas/ # Feature: Gestión Plan de Cuentas
│ │ │ ├── src/lib/
│ │ │ │ ├── plan-cuentas-list/
│ │ │ │ │ ├── plan-cuentas-list.component.ts # Smart
│ │ │ │ │ └── plan-cuentas-list.component.html
│ │ │ │ ├── plan-cuentas-new/
│ │ │ │ ├── plan-cuentas-edit/
│ │ │ │ ├── plan-cuentas-tree/ # Vista árbol jerárquico
│ │ │ │ └── plan-cuentas.routes.ts
│ │ │ └── project.json # tags: ["scope:accounting", "type:feature"]
│ │ │
│ │ ├── feature-asientos/ # Feature: Asientos Contables
│ │ │ ├── src/lib/
│ │ │ │ ├── asiento-list/
│ │ │ │ ├── asiento-new/ # Formulario débito/crédito
│ │ │ │ ├── asiento-detail/
│ │ │ │ └── asientos.routes.ts
│ │ │ └── project.json # tags: ["scope:accounting", "type:feature"]
│ │ │
│ │ ├── feature-centros-costo/ # Feature: Centros de Costo
│ │ │ └── ...
│ │ │
│ │ ├── api/ # API pública del dominio (Facade)
│ │ │ ├── src/lib/
│ │ │ │ └── accounting-api.facade.ts # Lo que otros dominios pueden consumir
│ │ │ └── project.json # tags: ["scope:accounting", "type:api"]
│ │ │
│ │ └── shell/ # Shell del dominio (routing)
│ │ ├── src/lib/
│ │ │ └── accounting-shell.routes.ts # Lazy-loads todas las features
│ │ └── project.json # tags: ["scope:accounting", "type:shell"]
│ │
│ ├── treasury/ # ── TESORERÍA (scope:treasury) ──
│ │ ├── domain/
│ │ ├── data-access/
│ │ ├── feature-bancos/
│ │ ├── feature-cajas/
│ │ ├── feature-conciliacion/
│ │ ├── api/
│ │ └── shell/
│ │
│ ├── commerce/ # ── COMERCIO/FACTURACIÓN (scope:commerce) ──
│ │ ├── domain/
│ │ ├── data-access/
│ │ ├── feature-facturas/
│ │ ├── feature-cotizaciones/
│ │ ├── feature-pedidos/
│ │ ├── feature-cartera/ # CXC
│ │ ├── api/
│ │ └── shell/
│ │
│ ├── inventory/ # ── INVENTARIO (scope:inventory) ──
│ │ ├── domain/
│ │ ├── data-access/
│ │ ├── feature-catalogo/ # Productos, servicios, kits
│ │ ├── feature-kardex/ # Movimientos, existencias
│ │ ├── feature-bodegas/
│ │ ├── api/
│ │ └── shell/
│ │
│ ├── purchasing/ # ── COMPRAS (scope:purchasing) ──
│ │ ├── domain/
│ │ ├── data-access/
│ │ ├── feature-requisiciones/
│ │ ├── feature-ordenes-compra/
│ │ ├── feature-recepcion/
│ │ ├── feature-cxp/ # Cuentas por Pagar
│ │ ├── api/
│ │ └── shell/
│ │
│ ├── assets/ # ── ACTIVOS FIJOS (scope:assets) ──
│ │ ├── domain/
│ │ ├── data-access/
│ │ ├── feature-inventario-activos/
│ │ ├── feature-depreciacion/
│ │ ├── api/
│ │ └── shell/
│ │
│ ├── people/ # ── RRHH (scope:people) ──
│ │ ├── domain/
│ │ ├── data-access/
│ │ ├── feature-empleados/
│ │ ├── feature-capacitacion/
│ │ ├── feature-evaluacion/
│ │ ├── api/
│ │ └── shell/
│ │
│ ├── payroll/ # ── NÓMINA (scope:payroll) ──
│ │ ├── domain/
│ │ ├── data-access/
│ │ ├── feature-liquidacion/
│ │ ├── feature-seguridad-social/
│ │ ├── feature-prestaciones/
│ │ ├── api/
│ │ └── shell/
│ │
│ ├── budget/ # ── PRESUPUESTO (scope:budget) ──
│ │ ├── domain/
│ │ ├── data-access/
│ │ ├── feature-planeacion/
│ │ ├── feature-ejecucion/ # CDP, RP, obligaciones, pagos
│ │ ├── api/
│ │ └── shell/
│ │
│ ├── contracts/ # ── CONTRATACIÓN (scope:contracts) ──
│ │ ├── domain/
│ │ ├── data-access/
│ │ ├── feature-paa/ # Plan Anual Adquisiciones
│ │ ├── feature-procesos/
│ │ ├── feature-gestion/
│ │ ├── api/
│ │ └── shell/
│ │
│ └── finance/ # ── TRIBUTARIO (scope:finance) ──
│ ├── domain/
│ ├── data-access/
│ ├── feature-impuestos/
│ ├── feature-recaudo/
│ ├── api/
│ └── shell/
│
├── package.json # Dependencia: "@nebula/ui-kit": "^1.0.0"
├── nx.json # Configuración Nx
├── tsconfig.base.json # TypeScript paths aliases
├── .eslintrc.json # Boundary enforcement rules
└── angular.json / project.json
nebula-erp)| Dominio | domain | data-access | feature-* | api | shell | Total |
|---|---|---|---|---|---|---|
| shared | 1 | 1 | 1 (masters) | - | - | 3 |
| shared/util | - | - | - | - | - | 1 |
| accounting | 1 | 1 | 3 | 1 | 1 | 7 |
| treasury | 1 | 1 | 3 | 1 | 1 | 7 |
| commerce | 1 | 1 | 4 | 1 | 1 | 8 |
| inventory | 1 | 1 | 3 | 1 | 1 | 7 |
| purchasing | 1 | 1 | 4 | 1 | 1 | 8 |
| assets | 1 | 1 | 2 | 1 | 1 | 6 |
| people | 1 | 1 | 3 | 1 | 1 | 7 |
| payroll | 1 | 1 | 3 | 1 | 1 | 7 |
| budget | 1 | 1 | 2 | 1 | 1 | 6 |
| contracts | 1 | 1 | 3 | 1 | 1 | 7 |
| finance | 1 | 1 | 2 | 1 | 1 | 6 |
| TOTAL | 12 | 12 | 33 | 11 | 11 | ~80 |
~80 librerías Nx en
nebula-erp. Esto NO significa 80 proyectos separados ni complejidad excesiva. Son carpetas con unproject.jsonque define tags y dependencias. Nx las gestiona automáticamente.Nota: La columna
uifue eliminada del monorepo. Los componentes UI reutilizables (DataTable, Modal, FormField, etc.) viven en el proyecto separadonebula-ui-kit, consumido como@nebula/ui-kit(paquete npm desde Nexus).
Cada librería en el monorepo tiene un type que define su responsabilidad y restricciones:
┌──────────────────────────────────────────────────────────┐
│ SHELL (Routing) │
│ Lazy-loads features, configura rutas del dominio │
│ ┌──────────────────────────────────────────────────┐ │
│ │ FEATURE (Smart Components) │ │
│ │ Páginas completas, inyecta services │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ DATA-ACCESS (Services + State) │ │ │
│ │ │ HttpClient, Signals, NgRx Signal Store │ │ │
│ │ │ ┌──────────────────────────────────┐ │ │ │
│ │ │ │ DOMAIN (Interfaces) │ │ │ │
│ │ │ │ TypeScript types, enums, events │ │ │ │
│ │ │ └──────────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ UI (Dumb) │ │ UTIL │ │ API (Facade) │ │
│ │ Presentational│ │ Pipes, │ │ Public API │ │
│ │ Components │ │ Validators │ │ cross-domain │ │
│ └───────────────┘ └────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────┘
| Tipo | Responsabilidad | Puede Importar | NO Puede Importar |
|---|---|---|---|
| shell | Configura rutas, lazy-load features | feature, data-access, domain | Otros shells |
| feature | Páginas/vistas, Smart Components | data-access, domain, ui, util | Otros features, shell |
| data-access | HTTP, state management, stores | domain, util | feature, shell, ui |
| domain | Interfaces TS, tipos, enums | util | Todo lo demás |
| ui | Componentes presentacionales (dumb) | domain, util | feature, data-access, shell |
| util | Funciones puras, pipes, validators | Nada (leaf node) | Todo |
| api | Facade pública para otros dominios | data-access, domain | feature, shell |
// libs/accounting/domain/src/lib/cuenta-contable.model.ts
export interface CuentaContable {
id: number;
codigo: string;
nombre: string;
tipo: 'ACTIVO' | 'PASIVO' | 'PATRIMONIO' | 'INGRESO' | 'GASTO';
naturaleza: 'DEBITO' | 'CREDITO';
nivel: number;
cuentaPadreId?: number;
activo: boolean;
// Audit fields (extends BaseDto from shared/domain)
createdAt?: string;
updatedAt?: string;
createdBy?: string;
}
export interface AsientoContable {
id: number;
numeroAsiento: string;
fechaAsiento: string; // ISO date
periodo: string; // "2026-03"
debito: number;
credito: number;
descripcion: string;
detalles: DetalleAsiento[];
}
export interface DetalleAsiento {
cuentaContableId: number;
cuentaCodigo: string;
cuentaNombre: string;
debito: number;
credito: number;
terceroId?: number;
centroCostosId?: number;
}
// libs/accounting/data-access/src/lib/cuenta-contable.service.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { CuentaContable } from '@nebula/accounting/domain';
@Injectable({ providedIn: 'root' })
export class CuentaContableService {
private http = inject(HttpClient);
private baseUrl = '/api/v1/accounting/cuenta-contable';
// State: Signals
private _cuentas = signal<CuentaContable[]>([]);
private _loading = signal(false);
private _error = signal<string | null>(null);
// Public readonly signals
readonly cuentas = this._cuentas.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
// Computed
readonly cuentasActivas = computed(() =>
this._cuentas().filter(c => c.activo)
);
readonly arbolCuentas = computed(() =>
this.buildTree(this._cuentas())
);
loadAll(): void {
this._loading.set(true);
this.http.get<CuentaContable[]>(`${this.baseUrl}/read`)
.subscribe({
next: data => {
this._cuentas.set(data);
this._loading.set(false);
},
error: err => {
this._error.set(err.message);
this._loading.set(false);
}
});
}
create(cuenta: Partial<CuentaContable>): void {
this.http.post<CuentaContable>(`${this.baseUrl}/create`, cuenta)
.subscribe({
next: created => {
this._cuentas.update(list => [...list, created]);
},
error: err => this._error.set(err.message)
});
}
private buildTree(flat: CuentaContable[]): TreeNode<CuentaContable>[] {
// Construir árbol jerárquico desde lista plana
// ...
}
}
// libs/accounting/feature-plan-cuentas/src/lib/plan-cuentas-list/plan-cuentas-list.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { CuentaContableService } from '@nebula/accounting/data-access';
import { DataTableComponent } from '@nebula/ui-kit'; // Paquete npm externo (no librería Nx)
import { CurrencyPipe } from '@nebula/shared/util';
@Component({
standalone: true,
imports: [DataTableComponent, CurrencyPipe],
template: `
@if (service.loading()) {
<app-loading-spinner />
} @else {
<app-data-table
[data]="service.cuentasActivas()"
[columns]="columns"
(rowClick)="onEdit($event)"
(create)="onCreate()"
/>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PlanCuentasListComponent implements OnInit {
protected service = inject(CuentaContableService);
columns = [
{ field: 'codigo', header: 'Código' },
{ field: 'nombre', header: 'Nombre' },
{ field: 'tipo', header: 'Tipo' },
{ field: 'naturaleza', header: 'Naturaleza' }
];
ngOnInit() {
this.service.loadAll();
}
onEdit(cuenta: CuentaContable) { /* navigate to edit */ }
onCreate() { /* navigate to new */ }
}
// libs/accounting/api/src/lib/accounting-api.facade.ts
import { Injectable, inject } from '@angular/core';
import { CuentaContableService } from '@nebula/accounting/data-access';
import { CuentaContable } from '@nebula/accounting/domain';
/**
* FACADE PÚBLICA del dominio Contabilidad.
* Otros dominios (Commerce, Payroll, etc.) usan SOLO este servicio.
* NUNCA importan directamente data-access o feature de accounting.
*/
@Injectable({ providedIn: 'root' })
export class AccountingApiFacade {
private cuentaService = inject(CuentaContableService);
/** Obtener cuentas contables activas (para selectores en otros dominios) */
getCuentasActivas(): CuentaContable[] {
return this.cuentaService.cuentasActivas();
}
/** Obtener cuenta por código (para validaciones cross-domain) */
getCuentaByCodigo(codigo: string): CuentaContable | undefined {
return this.cuentaService.cuentas().find(c => c.codigo === codigo);
}
}
// libs/accounting/shell/src/lib/accounting-shell.routes.ts
import { Routes } from '@angular/router';
export const ACCOUNTING_ROUTES: Routes = [
{
path: '',
children: [
{
path: 'plan-cuentas',
loadChildren: () =>
import('@nebula/accounting/feature-plan-cuentas')
.then(m => m.PLAN_CUENTAS_ROUTES)
},
{
path: 'asientos',
loadChildren: () =>
import('@nebula/accounting/feature-asientos')
.then(m => m.ASIENTOS_ROUTES)
},
{
path: 'centros-costo',
loadChildren: () =>
import('@nebula/accounting/feature-centros-costo')
.then(m => m.CENTROS_COSTO_ROUTES)
},
{ path: '', redirectTo: 'plan-cuentas', pathMatch: 'full' }
]
}
];
Cada librería tiene tags en su project.json que definen su scope (dominio) y type (tipo):
// libs/accounting/data-access/project.json
{
"name": "accounting-data-access",
"tags": ["scope:accounting", "type:data-access"]
}
{
"root": true,
"overrides": [
{
"files": ["*.ts"],
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
// ═══ REGLAS POR TIPO ═══
// shell → puede importar feature, data-access, domain
{
"sourceTag": "type:shell",
"onlyDependOnLibsWithTags": [
"type:feature", "type:data-access", "type:domain",
"type:ui", "type:util"
]
},
// feature → puede importar data-access, domain, ui, util
{
"sourceTag": "type:feature",
"onlyDependOnLibsWithTags": [
"type:data-access", "type:domain",
"type:ui", "type:util", "type:api"
]
},
// data-access → puede importar domain, util
{
"sourceTag": "type:data-access",
"onlyDependOnLibsWithTags": [
"type:domain", "type:util"
]
},
// domain → solo util (leaf casi puro)
{
"sourceTag": "type:domain",
"onlyDependOnLibsWithTags": ["type:util"]
},
// ui → solo domain y util (componentes tontos)
{
"sourceTag": "type:ui",
"onlyDependOnLibsWithTags": [
"type:domain", "type:util"
]
},
// util → nada (leaf node puro)
{
"sourceTag": "type:util",
"onlyDependOnLibsWithTags": []
},
// api → data-access y domain (facade pública)
{
"sourceTag": "type:api",
"onlyDependOnLibsWithTags": [
"type:data-access", "type:domain", "type:util"
]
},
// ═══ REGLAS POR SCOPE (DOMINIO) ═══
// Cada dominio solo puede importar de sí mismo + shared
{
"sourceTag": "scope:accounting",
"onlyDependOnLibsWithTags": [
"scope:accounting", "scope:shared"
]
},
{
"sourceTag": "scope:treasury",
"onlyDependOnLibsWithTags": [
"scope:treasury", "scope:shared",
"scope:accounting"
]
},
{
"sourceTag": "scope:commerce",
"onlyDependOnLibsWithTags": [
"scope:commerce", "scope:shared",
"scope:accounting", "scope:inventory"
]
},
{
"sourceTag": "scope:inventory",
"onlyDependOnLibsWithTags": [
"scope:inventory", "scope:shared"
]
},
{
"sourceTag": "scope:purchasing",
"onlyDependOnLibsWithTags": [
"scope:purchasing", "scope:shared",
"scope:inventory", "scope:accounting"
]
},
{
"sourceTag": "scope:assets",
"onlyDependOnLibsWithTags": [
"scope:assets", "scope:shared",
"scope:accounting", "scope:purchasing"
]
},
{
"sourceTag": "scope:people",
"onlyDependOnLibsWithTags": [
"scope:people", "scope:shared"
]
},
{
"sourceTag": "scope:payroll",
"onlyDependOnLibsWithTags": [
"scope:payroll", "scope:shared",
"scope:people", "scope:accounting"
]
},
{
"sourceTag": "scope:budget",
"onlyDependOnLibsWithTags": [
"scope:budget", "scope:shared",
"scope:accounting"
]
},
{
"sourceTag": "scope:contracts",
"onlyDependOnLibsWithTags": [
"scope:contracts", "scope:shared",
"scope:budget"
]
},
{
"sourceTag": "scope:finance",
"onlyDependOnLibsWithTags": [
"scope:finance", "scope:shared",
"scope:accounting"
]
},
// shared puede importar solo de shared
{
"sourceTag": "scope:shared",
"onlyDependOnLibsWithTags": ["scope:shared"]
}
]
}
]
}
}
]
}
$ nx lint accounting-feature-plan-cuentas
ERROR: libs/accounting/feature-plan-cuentas/src/lib/plan-cuentas-list.component.ts
A project tagged with "scope:accounting" can only depend on libs tagged with
"scope:accounting", "scope:shared"
You are importing from "@nebula/commerce/data-access" which is tagged with "scope:commerce"
FIX: Use the API facade instead:
import { CommerceApiFacade } from '@nebula/commerce/api';
El build FALLA. No hay forma de "saltarse" la regla sin modificar el .eslintrc.json, lo cual requiere aprobación del Arquitecto.
Cuando un dominio necesita datos de otro, usa su API Facade, nunca importa directamente.
┌─────────────────┐ ┌─────────────────┐
│ COMMERCE │ │ ACCOUNTING │
│ │ │ │
│ feature-factura │ │ api/ │
│ ↓ │ usa │ accounting-api │
│ data-access ─┼─────────►│ .facade.ts │
│ │ │ ↓ │
│ │ │ data-access/ │
│ │ │ cuenta.service │
└─────────────────┘ └─────────────────┘
// En commerce/feature-facturas (CORRECTO)
import { AccountingApiFacade } from '@nebula/accounting/api';
@Component({ /* ... */ })
export class FacturaNewComponent {
private accountingApi = inject(AccountingApiFacade);
cuentas = this.accountingApi.getCuentasActivas();
}
// En commerce/feature-facturas (PROHIBIDO - ESLint bloqueará)
import { CuentaContableService } from '@nebula/accounting/data-access'; // ❌ ERROR
Para comunicación desacoplada entre dominios (ej: "Se creó una factura → Contabilidad debe crear asiento automático"):
// libs/shared/util/src/lib/event-bus.service.ts
import { Injectable, signal } from '@angular/core';
export interface DomainEvent {
type: string;
payload: unknown;
source: string;
timestamp: Date;
}
@Injectable({ providedIn: 'root' })
export class EventBusService {
private _events = signal<DomainEvent[]>([]);
emit(event: Omit<DomainEvent, 'timestamp'>): void {
this._events.update(events => [
...events,
{ ...event, timestamp: new Date() }
]);
}
on(eventType: string, callback: (event: DomainEvent) => void): void {
// Implementación con effect() para reaccionar a nuevos eventos
}
}
// En commerce/data-access (Emisor)
this.eventBus.emit({
type: 'INVOICE_CREATED',
payload: { invoiceId: 123, total: 1500000 },
source: 'commerce'
});
// En accounting/data-access (Receptor)
this.eventBus.on('INVOICE_CREATED', (event) => {
// Crear asiento contable automático
this.createAsientoFromInvoice(event.payload);
});
| Escenario | Patrón | Ejemplo |
|---|---|---|
| Leer datos de otro dominio | API Facade | Commerce lee cuentas de Accounting |
| Componente selector compartido | shared/masters UI | Selector de países en cualquier form |
| Evento de negocio cross-domain | EventBus | Factura creada → Asiento contable |
| Datos transversales (auth, tenant) | shared/data-access | Token, tenant context |
| Componentes UI reutilizables | @nebula/ui-kit (npm externo) |
DataTable, Modal, FormField |
Nivel 1: COMPONENT STATE (Signals)
├── Estado local del componente
├── Formularios, toggles, filtros
└── signal(), computed()
Nivel 2: DOMAIN STATE (NgRx Signal Store)
├── Estado del dominio/feature
├── Datos cargados del backend
├── Caché de listas, entidad seleccionada
└── signalStore(), patchState()
Nivel 3: GLOBAL STATE (Services en shared/data-access)
├── Auth state (usuario, token, roles)
├── Tenant context (empresa activa)
├── Theme/preferences
└── Injectable({ providedIn: 'root' })
// libs/accounting/data-access/src/lib/accounting.store.ts
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { CuentaContable, AsientoContable } from '@nebula/accounting/domain';
interface AccountingState {
cuentas: CuentaContable[];
asientos: AsientoContable[];
selectedCuenta: CuentaContable | null;
loading: boolean;
error: string | null;
}
const initialState: AccountingState = {
cuentas: [],
asientos: [],
selectedCuenta: null,
loading: false,
error: null
};
export const AccountingStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withMethods((store) => {
const http = inject(HttpClient);
return {
loadCuentas(): void {
patchState(store, { loading: true });
http.get<CuentaContable[]>('/api/v1/accounting/cuenta-contable/read')
.subscribe({
next: (cuentas) => patchState(store, { cuentas, loading: false }),
error: (err) => patchState(store, { error: err.message, loading: false })
});
},
selectCuenta(cuenta: CuentaContable): void {
patchState(store, { selectedCuenta: cuenta });
},
clearError(): void {
patchState(store, { error: null });
}
};
})
);
| Escenario | Nivel | Herramienta |
|---|---|---|
| Toggle de sidebar | 1 (Component) | signal(false) |
| Filtro de búsqueda en tabla | 1 (Component) | signal('') |
| Lista de cuentas contables | 2 (Domain) | NgRx Signal Store |
| Factura en edición | 2 (Domain) | NgRx Signal Store |
| Usuario autenticado | 3 (Global) | Service + Signal |
| Empresa activa (tenant) | 3 (Global) | Service + Signal |
// apps/nebula-shell/src/app/app.routes.ts
import { Routes } from '@angular/router';
import { AuthGuard } from '@nebula/shared/data-access';
import { MainLayoutComponent } from './layout/main-layout.component';
export const APP_ROUTES: Routes = [
{
path: 'login',
loadComponent: () =>
import('./login/login.component').then(c => c.LoginComponent)
},
{
path: '',
component: MainLayoutComponent,
canActivate: [AuthGuard],
children: [
// ─── CORE FINANCIERO (Sprint 1-4) ───
{
path: 'contabilidad',
loadChildren: () =>
import('@nebula/accounting/shell').then(m => m.ACCOUNTING_ROUTES)
},
{
path: 'tesoreria',
loadChildren: () =>
import('@nebula/treasury/shell').then(m => m.TREASURY_ROUTES)
},
{
path: 'facturacion',
loadChildren: () =>
import('@nebula/commerce/shell').then(m => m.COMMERCE_ROUTES)
},
// ─── OPERACIONES (Sprint 5-7) ───
{
path: 'inventario',
loadChildren: () =>
import('@nebula/inventory/shell').then(m => m.INVENTORY_ROUTES)
},
{
path: 'compras',
loadChildren: () =>
import('@nebula/purchasing/shell').then(m => m.PURCHASING_ROUTES)
},
{
path: 'activos-fijos',
loadChildren: () =>
import('@nebula/assets/shell').then(m => m.ASSETS_ROUTES)
},
// ─── PERSONAS (Sprint 6-8) ───
{
path: 'rrhh',
loadChildren: () =>
import('@nebula/people/shell').then(m => m.PEOPLE_ROUTES)
},
{
path: 'nomina',
loadChildren: () =>
import('@nebula/payroll/shell').then(m => m.PAYROLL_ROUTES)
},
// ─── GOBIERNO (Sprint 7-9) ───
{
path: 'presupuesto',
loadChildren: () =>
import('@nebula/budget/shell').then(m => m.BUDGET_ROUTES)
},
{
path: 'contratacion',
loadChildren: () =>
import('@nebula/contracts/shell').then(m => m.CONTRACTS_ROUTES)
},
{
path: 'tributario',
loadChildren: () =>
import('@nebula/finance/shell').then(m => m.FINANCE_ROUTES)
},
{ path: '', redirectTo: 'contabilidad', pathMatch: 'full' }
]
}
];
Cuando el usuario accede a /contabilidad/plan-cuentas:
Bundle inicial (shell + shared): ~250 KB gzipped
+ accounting-shell: ~5 KB
+ accounting-feature-plan-cuentas: ~30 KB
+ accounting-data-access: ~15 KB
+ accounting-domain: ~3 KB
─────────────────────────────────────
Total cargado: ~303 KB
NO se cargan: treasury, commerce, inventory, people, payroll, budget, etc.
Esto garantiza que el Time to Interactive (TTI) sea < 3 segundos incluso en conexiones lentas.
Cada DTO del backend (nebula-models) tiene su interfaz TypeScript exacta en la librería domain del dominio correspondiente:
BACKEND (Java) FRONTEND (TypeScript)
──────────────── ────────────────────
CuentaContableDto.java → cuenta-contable.model.ts
AsientoContableDto.java → asiento-contable.model.ts
TipoCentroCostosDto.java → tipo-centro-costos.model.ts
Regla: El modelo frontend NO se crea hasta que el PR en nebula-models esté mergeado y publicado en Nexus.
// libs/shared/data-access/src/lib/http/api.service.ts
@Injectable({ providedIn: 'root' })
export class ApiService {
private gateway = environment.apiGatewayUrl; // http://gateway:8080
/**
* Construye URL para microservicio Nebula.
* Gateway routea automáticamente vía Eureka.
*/
buildUrl(service: string, endpoint: string): string {
return `${this.gateway}/api/v1/${service}/${endpoint}`;
}
}
// Uso en accounting data-access:
const url = this.api.buildUrl('accounting', 'cuenta-contable/read');
// → http://gateway:8080/api/v1/accounting/cuenta-contable/read
// libs/shared/data-access/src/lib/auth/token.interceptor.ts
export const tokenInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.getToken();
if (token) {
req = req.clone({
setHeaders: {
'Authorization': `Bearer ${token}`,
'X-Tenant-Id': authService.getTenantId() // centrica_dev
}
});
}
return next(req);
};
| Elemento | Convención | Ejemplo |
|---|---|---|
| Librería Nx | {scope}-{type}-{feature} |
accounting-feature-plan-cuentas |
| Componente | {entity}-{action}.component.ts |
plan-cuentas-list.component.ts |
| Servicio | {entity}.service.ts |
cuenta-contable.service.ts |
| Modelo | {entity}.model.ts |
cuenta-contable.model.ts |
| Store | {domain}.store.ts |
accounting.store.ts |
| Rutas | {feature}.routes.ts |
plan-cuentas.routes.ts |
| Facade | {domain}-api.facade.ts |
accounting-api.facade.ts |
standalone: true en TODOS los componentes. Prohibido NgModulesignal(), computed(). RxJS solo para HTTPchangeDetection: ChangeDetectionStrategy.OnPushany: Prohibido. Usar unknown si es absolutamente necesariolist, new, edit, view por entidad@if, @for, @switch (no *ngIf, *ngFor)| Tipo | Testing | Herramienta | Cobertura Mínima |
|---|---|---|---|
| domain | No requiere (interfaces) | - | - |
| util | Unit tests obligatorios | Jest/Vitest | 90% |
| data-access | Unit + integration | Jest + HttpTestingController | 80% |
| ui | Snapshot + interaction | Jest + Testing Library | 70% |
| feature | Integration tests | Cypress Component Testing | 60% |
| shell | E2E routing tests | Playwright | Rutas críticas |
| Tecnología | Versión | Propósito |
|---|---|---|
| Angular | 21 | Framework SPA |
| Nx | 21.x | Monorepo tooling, boundary enforcement |
| TypeScript | 5.8+ | Lenguaje tipado |
| NgRx Signal Store | 19+ | State management (dominio) |
| Angular Signals | Built-in | State management (componente) |
| RxJS | 7.x | Asincronía compleja (HTTP, WebSocket) |
| Tailwind CSS | 4.x | Utility-first CSS |
| PrimeNG | 19+ | Componente library (DataTable, Calendar, etc.) |
| esbuild | Built-in | Bundler (5x más rápido que webpack) |
| Jest/Vitest | Latest | Unit testing |
| Playwright | Latest | E2E testing |
| Storybook | 8.x | UI component documentation |
| Nx Cloud | Latest | Distributed caching |
# Crear workspace Nx con Angular
npx create-nx-workspace@latest nebula-erp \
--preset=angular-monorepo \
--appName=nebula-shell \
--style=scss \
--routing=true \
--standaloneApi=true \
--e2eTestRunner=playwright
# Instalar plugins
npm install -D @nx/angular @ngrx/signals
# ═══ CONTABILIDAD ═══
# Domain (interfaces)
nx g @nx/angular:library domain \
--directory=libs/accounting/domain \
--tags="scope:accounting,type:domain" \
--standalone --skipModule
# Data Access (services + state)
nx g @nx/angular:library data-access \
--directory=libs/accounting/data-access \
--tags="scope:accounting,type:data-access" \
--standalone --skipModule
# Feature: Plan de Cuentas
nx g @nx/angular:library feature-plan-cuentas \
--directory=libs/accounting/feature-plan-cuentas \
--tags="scope:accounting,type:feature" \
--standalone --skipModule --routing --lazy
# Feature: Asientos
nx g @nx/angular:library feature-asientos \
--directory=libs/accounting/feature-asientos \
--tags="scope:accounting,type:feature" \
--standalone --skipModule --routing --lazy
# Feature: Centros de Costo
nx g @nx/angular:library feature-centros-costo \
--directory=libs/accounting/feature-centros-costo \
--tags="scope:accounting,type:feature" \
--standalone --skipModule --routing --lazy
# API (facade)
nx g @nx/angular:library api \
--directory=libs/accounting/api \
--tags="scope:accounting,type:api" \
--standalone --skipModule
# Shell (routing)
nx g @nx/angular:library shell \
--directory=libs/accounting/shell \
--tags="scope:accounting,type:shell" \
--standalone --skipModule --routing
# Build solo lo afectado por cambios
nx affected:build
# Lint con boundary enforcement
nx affected:lint
# Tests solo de lo afectado
nx affected:test
# Grafo de dependencias visual
nx graph
# Build de producción
nx build nebula-shell --configuration=production
Proyecto nebula-ui-kit (repo separado):
https://gitlab.centricasoluciones.com/nebula/frontend/nebula-ui-kit.gitng-packagr para build del paquete@nebula/ui-kit v1.0 en NexusProyecto nebula-erp (Nx Monorepo):
https://gitlab.centricasoluciones.com/nebula/frontend/nebula-erp.git@nebula/ui-kit como dependencia npm| Métrica | Objetivo | Herramienta |
|---|---|---|
| Bundle Size (initial) | < 300 KB gzipped | nx build --stats-json |
| Lighthouse Performance | > 90 | Chrome DevTools |
| TTI (Time to Interactive) | < 3s | Lighthouse |
| Test Coverage | > 70% global | Jest |
| Boundary Violations | 0 | nx lint |
| Build Time (affected) | < 60s | Nx Cloud |
| Build Time (full) | < 5 min | Nx Cloud |
| Version | Fecha | Autor | Descripcion |
|---|---|---|---|
| 1.0.0 | 2026-03-08 | Carlos Torres | Creacion del documento de arquitectura frontend Nebula ERP |