Artículos
Patrón arquitectónico para múltiples layouts y temas
¿Cómo debe estructurarse un frontend para admitir más de 100 temas y varios layouts sin que la lógica de personalización se filtre a los componentes compartidos? En este artículo desgloso las capas core / view / theme leaf, la configuración tipada, las pruebas de integridad de la familia de temas y la carga bajo demanda del CSS de cada tema, además de las decisiones propias de cada capa.
· Actualizado · 10 min de lectura
Una vez fui responsable de la reestructuración arquitectónica de una gran plataforma frontend con Nx / Angular. Tenía que admitir más de 100 temas y varios layouts. Además, a menudo otros miembros resolvían las subtareas relacionadas con layouts y temas a su manera, según sus propios hábitos de desarrollo. Con el tiempo, esa acumulación se convertía en deuda técnica: una misma necesidad coexistía con varias lógicas de implementación.
En este artículo explico el diseño central de aquella época: cómo dividirlo en capas, cómo mantener completos más de cien temas y cómo hacer que el navegador descargue solo el CSS necesario. El código original de production no se puede publicar, así que volví a implementar el diseño con código original y tres temas ficticios; lo publiqué en frontend-engineering-case-studies y también hay un Demo en línea que puedes probar directamente. Todo el código del artículo procede de esa versión pública.
La idea principal: separa «cómo se ve» de «qué hace» y deja que el compilador y las pruebas mantengan la coherencia entre más de cien temas.
El problema no es cambiar los colores#
El punto de partida más habitual para admitir varios temas es añadir condiciones dentro de un componente compartido. Por ejemplo, el tema A necesita un botón más, mientras que el tema B debe dirigir a otra página durante la campaña:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
Con solo tres temas, escribirlo así es rápido. Cuando hay cien, cada componente compartido carga con un montón de ramas que «solo se aplican a un tema concreto». El resultado:
- Un cambio para el tema A puede romper silenciosamente el tema B, y nadie se encarga de comprobar B.
- La misma diferencia se implementa una y otra vez en distintos componentes, además de manera distinta.
- Al añadir un tema, nadie sabe con certeza si ha completado todos los cambios necesarios.
Así que el problema no es solo cambiar los colores: hay que permitir diferencias de apariencia y de producto entre temas y, a la vez, mantener toda la familia completa y predecible.
Primero, distingamos dos cosas: el layout y el tema#
La analogía de una casa es la más fácil de entender:
- Estructura e instalaciones: pilares, electricidad y fontanería. Son iguales en todas las viviendas y no deberían cambiar según las preferencias de sus habitantes.
- Layout: la distribución. Dos o tres dormitorios, dónde está el salón. Determina «cómo se organizan las cosas».
- Tema: la decoración. El color de las paredes, los materiales, el tamaño de los bordes redondeados. Determina «qué aspecto tienen las cosas».
En frontend, estos tres conceptos corresponden a capas distintas. Si se mezclan, puede surgir la situación de «cambiar el flujo de datos para cambiar un color».
Estructura de cuatro capas#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
De abajo arriba:
core: solo se encarga de renderizar#
Los componentes core solo reciben datos ya preparados que se puedan renderizar directamente y solo emiten las intenciones del usuario (intent). No saben cuál es el tema actual, no gestionan routing ni el formato de las respuestas de API, y tampoco hacen referencia a recursos de marca.
export class ThemeCardCoreComponent {
readonly appearance = input.required<ThemeAppearance>();
readonly content = input.required<ThemeContent>();
readonly items = input.required<readonly ShowcaseItem[]>();
readonly selectedId = input<string | null>(null);
readonly itemSelected = output<ShowcaseItem>();
readonly selectionCleared = output<void>();
}
core lo comparten todos los temas y tiene el mayor alcance, por eso sus reglas son las más estrictas: solo recibe lo necesario y solo emite intent.
view: se encarga de coordinar#
view hace de intermediario. Obtiene datos de data service y los transforma al formato que necesita core; cuando core emite un intent, se lo pasa a actions facade.
@Component({
selector: 'case-study-theme-card-view',
providers: [FeatureActionsFacade, FeatureDataService],
template: `
<case-study-theme-card-core
[appearance]="appearance()"
[content]="themeContent()"
[items]="data.items()"
[selectedId]="actions.selectedId()"
(itemSelected)="select($event)"
(selectionCleared)="actions.reset()"
/>
`,
})
export class ThemeCardViewComponent { /* ... */ }
El origen de los datos (data service) y la acción que se ejecuta al pulsar (actions facade) tienen límites claramente definidos, y no están dispersos por los componentes de interfaz.
Esta capa es adecuada para los layouts. Distintas distribuciones pueden implementarse como shared view diferentes: todas usan el mismo data service y facade, pero componen core de formas distintas. El Demo público solo incluye un shared view, por simplicidad; en la reestructuración de production se introdujeron 26.
theme leaf: una hoja por tema#
Cada tema es un componente muy ligero que hace una sola cosa: elegir una config de apariencia completa y pasársela a shared view.
@Component({
selector: 'case-study-theme-aurora',
imports: [ThemeCardViewComponent],
template:
'<case-study-theme-card-view [appearance]="appearance" data-theme-leaf="aurora" />',
})
export class ThemeAuroraComponent {
protected readonly appearance = THEME_APPEARANCES.aurora;
}
No contiene ninguna condición de negocio. En production, tras la refactorización, un theme leaf completo representativo tenía 23 líneas; la implementación anterior de esa misma ruta tenía varios cientos.
selector: enumera explícitamente los miembros de la familia#
selector renderiza el leaf correspondiente según el id del tema en runtime:
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
Parece corriente, pero la clave está en que es explícito: puedes ver de un vistazo qué temas hay y qué leaf corresponde a cada uno, y comprobarlos todos con pruebas.
Deja que el compilador te diga qué falta#
Cuantos más temas hay, más fácil es que falte algo. Al añadir uno, puedes olvidarte de completar su configuración; al cambiar el formato de la configuración, algunos temas antiguos pueden quedarse atrás.
La solución consiste en definir la lista de temas en una única fuente y usar TypeScript satisfies para exigir una configuración completa para cada tema:
export const DEMO_THEMES = ['default', 'aurora', 'summit'] as const;
export type DemoTheme = (typeof DEMO_THEMES)[number];
export interface ThemeAppearance {
readonly density: 'comfortable' | 'compact';
readonly id: DemoTheme;
readonly stylesheetHref: `themes/${string}.css`;
}
export const THEME_APPEARANCES = {
default: { id: 'default', density: 'comfortable', stylesheetHref: 'themes/default.css' },
aurora: { id: 'aurora', density: 'comfortable', stylesheetHref: 'themes/aurora.css' },
summit: { id: 'summit', density: 'compact', stylesheetHref: 'themes/summit.css' },
} satisfies Record<DemoTheme, ThemeAppearance>;
Si añades un tema en DEMO_THEMES y no completas THEME_APPEARANCES, la compilación falla. Lo mismo ocurre si escribes mal un campo o si la ruta CSS no respeta el formato.
La ventaja de satisfies frente a una anotación de tipo directa es que comprueba la forma, pero conserva el tipo concreto de cada valor; al acceder después a THEME_APPEARANCES.aurora, no se ensancha al tipo genérico ThemeAppearance.
Deja que las pruebas protejan a toda la familia#
El compilador puede validar la configuración, pero no que «el tema se renderice de verdad». De eso se encarga una prueba de integridad de la familia que enumera todos los temas declarados:
it.each(DEMO_THEMES)('renders the %s family leaf', (theme) => {
const fixture = TestBed.createComponent(ThemeSelectorComponent);
fixture.componentRef.setInput('theme', theme);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(`[data-theme-leaf="${theme}"]`),
).toBeTruthy();
});
También hay una prueba correspondiente para las configuraciones: el campo id de cada config de tema debe coincidir con la clave que tiene en la tabla de configuración; la ruta CSS también debe cumplir las reglas de la familia.
Estas dos comprobaciones convierten en errores que CI bloquea lo que antes dependía de una revisión manual para detectar si faltaba algún tema hermano. A esta escala, con más de cien temas, no se puede comprobar todo a simple vista.
Descarga solo el CSS del tema que estás usando#
La separación por capas resuelve el problema de mantenimiento, pero los múltiples temas también tienen un coste directo para el usuario: el CSS.
La implementación original agrupaba los 136 stylesheet de tema en un solo paquete, de modo que el usuario tenía que descargarlos y analizarlos todos, usara el tema que usara. Tras pasar a la carga bajo demanda, los recursos de un solo tema bajaron de unos 2.4 MB a 17 KB, aproximadamente un 99% menos.
El mecanismo es sencillo: cada config de tema declara su propio stylesheetHref, y un único loader administra el <link> de tema que hay en la página:
@Injectable()
export class ThemeStylesheetLoader {
readonly #document = inject(DOCUMENT);
load(href: ThemeAppearance['stylesheetHref']): void {
const id = 'case-study-theme-stylesheet';
const existing = this.#document.getElementById(id) as HTMLLinkElement | null;
const link = existing ?? this.#document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
if (link.getAttribute('href') !== href) link.setAttribute('href', href);
if (!existing) this.#document.head.append(link);
}
}
La plataforma lo llama al cambiar de tema:
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
La palabra «único» es clave. Si cada parte inserta <link> por su cuenta, tras varios cambios quedan estilos del tema anterior o dos CSS se pisan entre sí. Las pruebas comprueban que, después de varios cambios, siga habiendo un solo stylesheet gestionado en la página.
El CSS de cada tema solo define design token (variables CSS); los estilos de core solo leen esas variables:
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
Por eso, cambiar de tema solo sustituye una tabla de variables; los estilos de core no se tocan. El modo claro/oscuro es otro conjunto de variables dentro del mismo archivo.
Compromisos#
Este diseño tiene un coste:
- Más archivos. Un leaf por tema y un view por layout aumentan el número de archivos pequeños. A cambio, la responsabilidad de cada archivo queda clara de un vistazo y se puede comprobar la integridad.
- La config no debe convertirse en un lenguaje de programación. config sirve para describir la apariencia y diferencias acotadas. Si empiezas a incluir condiciones y flujos en la config, solo habrás escondido los if/else en otro sitio.
- core necesita pruebas de contrato más sólidas. Lo comparten todos los temas; un cambio afecta a más de cien.
- El cambio de CSS en runtime necesita un único responsable. Las solicitudes de stylesheet sin coordinación provocan parpadeos o estilos residuales; por eso el loader debe ser único.
- La caché sigue siendo importante. Aunque solo cargues un tema, los nombres de archivo estables y el cache header siguen determinando la velocidad de las visitas posteriores.
Cuándo merece la pena#
Si tu producto solo tiene modo claro y oscuro, basta con usar variables CSS; no necesitas todo este sistema.
Plantéatelo cuando empieces a encontrarte con estas situaciones:
- Hay más de diez temas o versiones de cliente y siguen aumentando.
- Las diferencias entre temas no se limitan al color: también afectan a la maquetación, las funciones activables y los textos.
- Los cambios para un tema suelen afectar por accidente a otro.
- Al añadir un tema, nadie sabe con seguridad qué hay que completar.
Límites de la evidencia#
Conviene precisar el alcance de las cifras de production que aparecen en este artículo:
- 23 líneas se refiere a un theme leaf completo representativo tras la refactorización; no significa que todos los leaf y todos los archivos tengan 23 líneas.
- La migration de entonces incorporó 70 módulos de la nueva arquitectura (24 facade, 10 data service, 10 config de tema y 26 shared view). Este es el alcance de la transformación, no un resultado en rendimiento ni en reducción de código.
- 2.4 MB → 17 KB es una medición en production. Las tres pequeñas hojas CSS del Demo público no reproducen esa cifra.
- En el mismo proyecto de calidad, Lighthouse Performance de las páginas clave medidas subió de 50+ a 90+, pero fue el resultado conjunto de varias mejoras, no solo de la carga de CSS bajo demanda.
El repo público demuestra el mecanismo arquitectónico en sí: contratos tipados, cambio en runtime, pruebas de integridad de la familia, CSS bajo demanda y CI que ejecuta esas comprobaciones. La tabla completa que relaciona las afirmaciones con las pruebas está en la auditoría de los límites de la evidencia del repo.
Si quieres ver el resultado directamente, abre el Demo en línea, cambia entre los tres temas, el idioma y los modos claro y oscuro, y luego compáralo con el código fuente en GitHub.