Artigos
Padrão de arquitetura para múltiplos layouts e temas
Como estruturar um frontend para dar conta de 100+ temas e vários layouts sem deixar a lógica de personalização invadir os componentes compartilhados? Neste artigo, detalho a divisão em core / view / theme leaf, as configurações tipadas, os testes de integridade da família e o carregamento sob demanda do CSS dos temas, além das escolhas e limitações de cada camada.
· Atualizado · 10 min de leitura
Já fui responsável por reestruturar a arquitetura de uma grande plataforma frontend em Nx / Angular. Ela precisava oferecer suporte a mais de 100 temas e vários layouts. Com frequência, outros membros implementavam as subtarefas de múltiplos layouts e temas à sua maneira, de acordo com seus próprios hábitos de desenvolvimento. Com o tempo, isso virava dívida técnica: a mesma necessidade passava a ter várias lógicas de implementação ao mesmo tempo.
Neste artigo, explico o projeto central da época: como dividir em camadas, manter mais de cem temas completos e fazer o navegador baixar apenas o CSS necessário. O código original de production não pode ser divulgado, então fiz uma nova implementação com código original e três temas fictícios, disponível em estudos de caso de engenharia frontend, com uma Demo online que você pode experimentar. Todo o código do artigo vem dessa versão pública.
A conclusão, desde já: separe “a aparência” de “o que a interface faz” e deixe o compilador e os testes manterem a consistência de mais de cem temas.
O problema não é trocar as cores#
O ponto de partida mais comum para dar suporte a vários temas é adicionar condições dentro de um componente compartilhado. Por exemplo, o tema A precisa de mais um botão, enquanto o tema B precisa direcionar para outra página durante a campanha:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
Com apenas três temas, essa abordagem é rápida. Quando passam a ser cem, cada componente compartilhado acaba carregando vários caminhos condicionais que “só fazem sentido em um tema específico”. O resultado:
- Uma alteração para o tema A pode quebrar silenciosamente o tema B, sem que ninguém seja responsável por verificar B.
- A mesma diferença é implementada em componentes diferentes, e ainda por cima de formas distintas.
- Ao adicionar um tema, ninguém consegue ter certeza de que todos os pontos necessários foram atualizados.
Portanto, o problema não é apenas trocar as cores: é permitir diferenças visuais e de produto entre os temas e, ao mesmo tempo, manter toda a família completa e previsível.
Primeiro, entenda: layout e tema são coisas diferentes#
A analogia com uma casa é a mais fácil de entender:
- Estrutura e instalações: pilares, água e eletricidade. São iguais em todas as unidades e não devem mudar conforme a preferência dos moradores.
- Layout: a planta. Dois ou três quartos, onde fica a sala. Define “como as coisas são organizadas”.
- Tema: a decoração. Cor das paredes, materiais, tamanho dos cantos arredondados. Define “como as coisas parecem”.
No frontend, esses três aspectos correspondem a camadas diferentes. Quando se misturam, acabamos “alterando o fluxo de dados só para trocar uma cor”.
Estrutura em quatro camadas#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
De baixo para cima:
core: só se encarrega de renderizar#
Os componentes core recebem apenas dados já preparados, prontos para renderizar, e emitem as intenções (intent) do usuário. Eles não sabem qual tema está ativo, não lidam com routing nem com o formato das respostas da API e não fazem referência 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>();
}
O core é compartilhado por todos os temas e, por isso, tem o maior alcance de impacto e as regras mais rígidas: recebe apenas o necessário e emite apenas intenções.
view: responsável pela coordenação#
A view atua como intermediária. Ela obtém os dados do data service e os transforma no formato esperado pelo core; quando o core emite uma intenção, ela a encaminha para o 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 { /* ... */ }
A origem dos dados (data service) e o que fazer quando alguém clica (actions facade) têm limites bem definidos. Essas responsabilidades não ficam espalhadas pelos componentes da interface.
O layout se encaixa bem nesta camada. Plantas diferentes podem ser implementadas como shared views distintas. Todas usam o mesmo data service e facade; o que muda é como compõem o core. A Demo pública tem apenas uma shared view, para manter a simplicidade; a reestruturação de production introduziu 26.
theme leaf: um nó folha para cada tema#
Cada tema é um componente bem enxuto que faz apenas uma coisa: escolhe uma configuração completa de aparência e a entrega à 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;
}
Ele não contém nenhuma regra de negócio. Em production, após a refatoração, um theme leaf completo representativo tinha 23 linhas; a versão antiga tinha várias centenas de linhas para o mesmo fluxo.
selector: lista explicitamente os membros da família#
O selector renderiza o leaf correspondente ao id do tema em runtime:
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
Ele parece muito simples, mas o importante é ser “explícito”: fica claro quais temas existem e qual leaf corresponde a cada um, e os testes podem verificá-los individualmente.
Deixe o compilador apontar o que falta#
Quando há muitos temas, o maior risco é deixar algo de fora. Adicionamos um tema e esquecemos de incluir a configuração; mudamos o formato da configuração e alguns temas antigos ficam para trás.
A solução é definir a lista de temas em uma única fonte e usar o satisfies do TypeScript para exigir uma configuração 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>;
Quando um novo tema é adicionado em DEMO_THEMES, mas não é incluído em THEME_APPEARANCES, a compilação falha. O mesmo acontece se o nome de um campo estiver incorreto ou se o formato do caminho CSS não for válido.
A vantagem de satisfies em relação a declarar o tipo diretamente é que ele verifica a estrutura, mas preserva o tipo específico de cada valor. Assim, ao usar THEME_APPEARANCES.aurora depois, o tipo não é ampliado para um ThemeAppearance genérico.
Use testes para manter toda a família íntegra#
O compilador consegue verificar as configurações, mas não se “este tema realmente consegue ser renderizado”. Essa parte fica a cargo dos testes de integridade da família, que enumeram cada tema declarado:
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();
});
Também há um teste correspondente para as configurações: o campo id de cada tema deve ser igual à chave correspondente na tabela de configurações; os caminhos CSS também precisam seguir as regras da família.
Juntas, essas duas verificações transformam o trabalho que antes dependia de alguém revisar manualmente se algum tema irmão ficou de fora em um erro que o CI bloqueia. É impossível conferir mais de cem temas a olho.
Baixe apenas o CSS do tema que você está usando#
A divisão em camadas resolve problemas de manutenção, mas os vários temas também têm um custo que afeta diretamente o usuário: CSS.
A abordagem original empacotava juntas as 136 folhas de stylesheet dos temas. Independentemente do tema usado, o usuário precisava baixar e analisar todas elas. Com o carregamento sob demanda, os recursos de um tema passaram de cerca de 2.4 MB para 17 KB, uma redução de aproximadamente 99%.
O mecanismo em si é simples: cada configuração de tema declara seu próprio stylesheetHref, e um único loader gerencia o <link> daquele tema na 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);
}
}
A plataforma chama o loader ao trocar de tema:
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
“Único” é o ponto principal. Se cada parte da aplicação inserir seu próprio <link>, após várias trocas sobram estilos do tema anterior ou as duas folhas de CSS acabam se sobrepondo. Os testes confirmam que, mesmo depois de várias trocas, a página continua com apenas um stylesheet gerenciado.
O CSS do tema define apenas design token (variáveis CSS); os estilos do core apenas leem essas variáveis:
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
Assim, trocar de tema é apenas trocar uma tabela de variáveis; os estilos do core não precisam mudar. O modo claro/escuro usa outro conjunto de variáveis no mesmo arquivo.
Compensações#
Esse projeto tem seus custos:
- Mais arquivos. Um leaf para cada tema e uma view para cada layout aumentam a quantidade de arquivos pequenos. Em troca, a responsabilidade de cada arquivo fica clara e sua integridade pode ser verificada.
- A configuração não pode virar uma linguagem de programação. config é adequada para descrever a aparência e diferenças bem delimitadas. Se você começa a escrever condições e fluxos dentro da configuração, apenas esconde os if/else em outro lugar.
- O core precisa de testes de contrato mais rigorosos. Ele é compartilhado por todos os temas, então uma alteração afeta mais de cem deles.
- A troca de CSS em runtime precisa de um único owner. Solicitações de stylesheet sem coordenação podem causar cintilação ou deixar estilos residuais. É por isso que o loader precisa ser único.
- O cache continua importante. Mesmo que apenas um tema seja carregado, nomes de arquivo estáveis e cache header ainda determinam a velocidade nas visitas seguintes.
Quando vale a pena adotar isso#
Se o seu produto só tem os modos claro e escuro, variáveis CSS bastam; você não precisa de toda essa estrutura.
Considere essa abordagem quando começar a enfrentar estas situações:
- Há mais de dez temas ou versões por cliente, e o número continua aumentando.
- As diferenças entre temas vão além das cores e incluem layout, recursos ativáveis e textos.
- Uma alteração para um tema frequentemente afeta outro sem querer.
- Ao adicionar um tema, ninguém tem certeza dos pontos que precisam ser atualizados.
Limites das evidências#
É importante esclarecer o escopo dos números de production mencionados neste artigo:
- 23 linhas se refere a um theme leaf completo representativo após a refatoração; não significa que cada leaf ou arquivo tenha 23 linhas.
- A migração introduziu 70 módulos na nova arquitetura (24 facades, 10 data services, 10 configurações de tema e 26 shared views). Esse é o escopo da mudança, não um resultado de desempenho ou de redução de código.
- 2.4 MB → 17 KB são medições feitas em production. Os três pequenos arquivos CSS da Demo pública não reproduzem esses números.
- No mesmo projeto de qualidade, o Lighthouse Performance de uma página importante, usada nas medições, passou de 50+ para 90+, mas esse resultado veio da soma de várias melhorias, não apenas do carregamento sob demanda de CSS.
O repo público demonstra os mecanismos da arquitetura: contratos tipados, troca em runtime, testes de integridade da família, CSS sob demanda e o CI que executa essas verificações. A tabela completa que compara as afirmações com as evidências está na auditoria dos limites das evidências do repo.
Para ver o resultado, abra a Demo online, alterne entre os três temas, idiomas e modos claro/escuro, e depois confira o código-fonte no GitHub.