Artículos
Patrón de arquitectura para múltiples layouts y temas
¿Cómo puede una plataforma frontend soportar más de 100 temas y varios layouts sin que la lógica personalizada 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 y la carga bajo demanda del CSS de cada tema, junto con las decisiones y concesiones de cada capa.
· Actualizado · 10 min de lectura
Estuve a cargo de reestructurar la arquitectura de una gran plataforma frontend con Nx / Angular. Tenía que admitir más de 100 temas y varios layouts. Además, con frecuencia otros miembros completaban las subtareas de layouts y temas múltiples según sus propios hábitos de desarrollo. Con el tiempo, eso se convierte en deuda técnica: un mismo requisito acaba con varias lógicas de implementación al mismo tiempo.
Aquí explico con claridad las decisiones clave de aquel diseño: cómo separar las capas, cómo mantener completos más de 100 temas y cómo hacer que el navegador descargue solo el CSS que necesita. No puedo publicar el código original de production, así que volví a implementarlo con código original y tres temas ficticios. Está disponible en frontend-engineering-case-studies y también hay un Demo en línea que puedes probar. Todo el código del artículo proviene de esta versión pública.
Empecemos por la conclusión: separa «cómo se ve» de «qué hace» y deja que el compilador y las pruebas mantengan la coherencia de más de 100 temas.
El problema no es cambiar colores#
El punto de partida más común para manejar varios temas es agregar condiciones dentro de un componente compartido. Por ejemplo, el tema A necesita un botón más, mientras que el tema B debe redirigir a otra página durante la campaña:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
Con solo 3 temas, escribirlo así es rápido. Pero cuando se convierten en 100, cada componente compartido termina cargando un montón de ramas que «solo aplican a cierto tema». Las consecuencias son:
- Un cambio para el tema A puede romper silenciosamente el tema B, y nadie se encarga de revisar B.
- La misma diferencia se implementa una vez en cada componente, y de formas distintas.
- Al agregar un tema, nadie puede confirmar que se hayan completado todos los cambios necesarios.
Así que el problema no es solo cambiar colores, sino permitir que cada tema tenga diferencias visuales y de producto, mientras toda la familia de temas se mantiene completa y predecible.
Primero, aclaremos: layout y tema son dos cosas distintas#
La analogía de una casa es la más fácil de entender:
- Estructura e instalaciones: columnas, electricidad y tuberías. Son iguales en todas las viviendas y no deberían cambiar según las preferencias de quien las habita.
- Layout: la distribución. Dos o tres recámaras, dónde está la sala. Define «cómo se organizan las cosas».
- Tema: la decoración. El color de las paredes, los materiales, el tamaño de las esquinas redondeadas. Define «qué aspecto tienen las cosas».
En frontend, cada una de estas cosas corresponde a una capa distinta. Si se mezclan, acabamos en situaciones como «cambiar el flujo de datos para modificar 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 hacia arriba:
core: solo se encarga de renderizar#
Los componentes core reciben datos «listos para usar y renderizar» y solo emiten las intenciones del usuario (intent). No saben qué tema está activo, no se ocupan del routing ni del formato de respuesta de la 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>();
}
Todos los temas comparten core, así que su alcance es el más amplio y sus reglas son las más estrictas: solo recibe lo necesario y solo emite intenciones.
view: se encarga de coordinar#
view es el intermediario. Obtiene datos del data service, los transforma a la forma que necesita core y, cuando core emite una intención, la delega al 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 lo que debe pasar al hacer clic (actions facade) tienen límites claros. No se dispersan por los componentes de pantalla.
Esta es la capa adecuada para el layout. Las distintas distribuciones pueden ser diferentes shared view. Todas usan el mismo data service y facade, pero combinan los componentes core de maneras distintas. El Demo público tiene un solo shared view para mantenerlo sencillo; la reestructuración de production incorporó 26.
theme leaf: un nodo hoja por tema#
Cada tema es un componente muy ligero que hace una sola cosa: elegir una configuración visual completa y entregársela al 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 lógica de negocio. En production, un theme leaf completo representativo quedó en 23 líneas después de la refactorización; la misma ruta en la implementación anterior tenía varios cientos de líneas.
selector: enumera claramente los integrantes 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 /> }
}
A simple vista parece algo común; lo importante es que sea «explícito»: puedes ver de inmediato qué temas hay y qué leaf corresponde a cada uno, y las pruebas pueden revisarlos uno por uno.
Deja que el compilador te diga qué falta#
Cuando hay muchos temas, lo que más preocupa es que falte algo: agregar uno y olvidar su configuración, o cambiar el formato de la configuración y que algunos temas existentes no se actualicen.
La solución es definir la lista de temas en una única fuente y luego usar satisfies de TypeScript para exigir que cada tema tenga una configuración completa:
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 agregas un tema a DEMO_THEMES y no lo incluyes en THEME_APPEARANCES, la compilación falla. Lo mismo ocurre si escribes mal un campo o si el formato de la ruta CSS es incorrecto.
La ventaja de satisfies frente a anotar el tipo directamente es que verifica la estructura, pero conserva el tipo específico de cada valor. Así, al acceder a THEME_APPEARANCES.aurora más adelante, no se amplía a un ThemeAppearance genérico.
Usa pruebas para proteger a toda la familia#
El compilador puede revisar la configuración, pero no puede confirmar que «este tema realmente se pueda renderizar». Para eso están las pruebas de integridad de la familia, que enumeran uno por uno 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 equivalente para las configuraciones: el campo id de cada tema debe coincidir con su key en la tabla de configuración, y la ruta CSS debe cumplir las reglas de la familia.
En conjunto, estas dos comprobaciones convierten la revisión manual de «¿falta algún tema sibling?» en un error que CI bloquea. A esa escala, con más de 100 temas, una persona no puede revisarlos todos a simple vista.
Descarga solo el CSS del tema que estás usando#
La separación por capas resuelve problemas de mantenimiento, pero los temas múltiples también tienen un costo que afecta directamente a los usuarios: el CSS.
La implementación original empaquetaba juntas las 136 hojas de estilo de los temas. Sin importar cuál usaran, los usuarios tenían que descargar y analizar todas. Al cambiar a la carga bajo demanda, los recursos de un solo tema bajaron de aproximadamente 2.4 MB a 17 KB, una reducción de alrededor del 99%.
El mecanismo es sencillo: cada configuración de tema declara su propio stylesheetHref y un único loader administra el <link> del tema 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);
});
Lo importante es que sea «único». Si cada lugar inserta su propio <link>, después de varios cambios pueden quedar estilos del tema anterior o dos archivos CSS pueden sobrescribirse entre sí. Las pruebas confirman que, incluso después de varios cambios, la página siga teniendo una sola hoja de estilo administrada.
El CSS del tema solo define design token (variables CSS), y 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 reemplaza una tabla de variables; los estilos de core no tienen que cambiar. El modo claro y el oscuro son otro conjunto de variables dentro del mismo archivo.
Concesiones#
Este diseño tiene sus costos:
- Aumenta la cantidad de archivos. Hay un leaf por tema y un view por layout, así que crece la cantidad de archivos pequeños. A cambio, la responsabilidad de cada archivo se entiende de inmediato y se puede verificar su integridad.
- La configuración no debe convertirse en un lenguaje de programación. config sirve para describir la apariencia y diferencias acotadas. Si empiezas a escribir condiciones o flujos en la configuración, solo estás escondiendo los if/else en otro lugar.
- core necesita pruebas de contrato más estrictas. Lo comparten todos los temas, así que un cambio afecta a más de 100.
- El cambio de CSS en runtime necesita un único owner. Las solicitudes de stylesheet sin coordinación pueden causar parpadeos o dejar estilos residuales; por eso el loader debe ser único.
- La caché sigue siendo importante. Incluso cuando solo se carga un tema, los nombres de archivo estables y el cache header siguen determinando la velocidad de las visitas posteriores.
Cuándo vale la pena#
Si tu producto solo tiene modo claro y oscuro, bastan las variables CSS; no necesitas toda esta arquitectura.
Vale la pena considerarla cuando empiezas a encontrar estas situaciones:
- Tienes más de 10 temas o versiones para clientes, y la lista sigue creciendo.
- Las diferencias entre temas no se limitan a los colores; también incluyen la distribución, las funciones disponibles y los textos.
- Un cambio para un tema suele afectar accidentalmente a otro.
- Al agregar un tema, nadie sabe con certeza qué partes hay que actualizar.
Alcance de la evidencia#
Conviene precisar el alcance de las cifras de production que menciono en este artículo:
- 23 líneas corresponde a un theme leaf completo y representativo después de la refactorización; no significa que todos los leaf ni todos los archivos tengan 23 líneas.
- Esa migration incorporó 70 módulos de la nueva arquitectura: 24 facade, 10 data service, 10 configuraciones de tema y 26 shared view. Esa es la escala de la reestructuración, no una mejora de rendimiento ni una reducción de código.
- 2.4 MB → 17 KB es una medición real de production. Los tres archivos CSS pequeños del Demo público no reproducen esa cifra.
- En el mismo proyecto de calidad, el puntaje Lighthouse Performance de la página clave medida subió de 50+ a 90+, pero ese resultado suma varias mejoras y no se debe solo a la carga de CSS bajo demanda.
El repo público demuestra los mecanismos de la arquitectura: contratos tipados, cambios en runtime, pruebas de integridad de la familia, CSS bajo demanda y el CI que ejecuta estas comprobaciones. La tabla completa que compara las afirmaciones con la evidencia está en la auditoría del alcance de la evidencia del repo.
Si quieres ver cómo funciona, abre el Demo en línea, cambia entre los tres temas, los idiomas y los modos claro y oscuro, y luego compáralo con el código fuente en GitHub.