Feature-based architecture no Angular Moderno
A pasta que vira labirinto
Todo projeto Angular começa arrumado. Uma pasta components, uma services, uma models. Por um tempo, parece a coisa mais natural do mundo: cada peça no seu lugar, separada por tipo. Os primeiros tutoriais do próprio Angular ensinaram muita gente a pensar exatamente assim. Eu incluído.
Vem devagar. Seis meses depois, sua pasta services guarda quarenta arquivos. Para mexer numa única tela de checkout, você abre algo em components, sobe até services, desce até models, passa por pipes e ainda volta em guards. Cinco pastas. Uma tela. Aquela funcionalidade fica espalhada por diretórios que não compartilham nada além do sufixo no nome. Já perdi tardes assim: dez abas abertas, todas da mesma tela, garimpando linha por linha.
Na essência, é simples. Agrupar por tipo técnico junta o que muda por razões diferentes e afasta o que anda sempre junto. CheckoutComponent e CheckoutService nascem e morrem como par, só que o projeto os joga em andares opostos do prédio.
Organizar por feature, não por tipo de arquivo
Organizar por feature inverte o critério. Em vez de “que tipo de arquivo é esse?”, a pergunta vira outra: “de que parte do produto isso faz parte?”. Cada funcionalidade ganha sua própria pasta, e ali mora tudo de que ela precisa: componentes, serviços, modelos, o que for.
Muda o eixo. Saiu o “onde guardo os services?” e entrou o “o que esse app faz?”. Vem então uma lista de domínios: checkout, catálogo, conta do usuário, pedidos. Esses são suas features. Sobra, no fim, uma árvore que conta a história do produto, não a do Angular.
src/app/
features/
checkout/
catalog/
account/
orders/
shared/
core/
Abre o projeto e, em dez segundos, já sabe o que ele faz. Não é firula estética. É a diferença entre um mapa e uma lista telefônica.
Como uma feature fica por dentro
Toda feature é um mundinho fechado. Por dentro, ela repete em escala menor a mesma ideia de separar por responsabilidade, agora num escopo que faz sentido.
features/checkout/
checkout.routes.ts
data-access/
checkout.service.ts
checkout.store.ts
checkout.models.ts
ui/
order-summary.component.ts
payment-form.component.ts
feature/
checkout-page.component.ts
index.ts
Repare nos nomes das subpastas. Eles carregam um conceito que vale ouro. Foi o pessoal do Nx que popularizou essa divisão em camadas, e ela mata muita briga no berço:
feature: os componentes inteligentes, donos da rota, que orquestram dados e regras. Sabem do mundo.ui: componentes burros e reaproveitáveis, que só recebeminput()e emitemoutput(). Não injetam serviço nenhum, não sabem de onde o dado veio.data-access: serviços, stores, chamadas de API, modelos. A conversa com o backend e o estado.util: funções puras, helpers, validadores. Sem estado, sem efeito colateral.
Entre as camadas, a regra de ouro é a direção da dependência. feature pode usar ui, data-access e util, mas ui nunca enxerga data-access. Um componente de apresentação que injeta serviço de API deixou de ser reaproveitável e virou refém. Manter essa seta apontando para um lado só já é metade do trabalho de deixar o código são.
A feature como rota lazy loading
Aqui mora a recompensa que feature-based entrega quase de graça no Angular moderno. Feature autocontida vira fronteira natural de lazy loading. Usuário que nunca abre o checkout jamais baixa o código dele.
Do Angular 17 pra cá, com standalone component virando o padrão, sumiu o módulo do caminho, e agora é a rota raiz que aponta para o arquivo de rotas de cada feature; o bundler corta exatamente ali:
// app.routes.ts
import { Routes } from "@angular/router";
export const routes: Routes = [
{
path: "checkout",
loadChildren: () =>
import("./features/checkout/checkout.routes").then(
(m) => m.checkoutRoutes,
),
},
{
path: "catalog",
loadChildren: () =>
import("./features/catalog/catalog.routes").then((m) => m.catalogRoutes),
},
];
Dentro da feature, as rotas usam loadComponent para adiar até o último componente:
// features/checkout/checkout.routes.ts
import { Routes } from "@angular/router";
export const checkoutRoutes: Routes = [
{
path: "",
loadComponent: () =>
import("./feature/checkout-page.component").then(
(m) => m.CheckoutPageComponent,
),
},
{
path: "confirmation",
loadComponent: () =>
import("./feature/confirmation-page.component").then(
(m) => m.ConfirmationPageComponent,
),
},
];
Já o componente da feature compõe os pedaços com a API moderna, inject() e control flow no template:
// features/checkout/feature/checkout-page.component.ts
import { Component, inject } from "@angular/core";
import { CheckoutStore } from "../data-access/checkout.store";
import { OrderSummaryComponent } from "../ui/order-summary.component";
import { PaymentFormComponent } from "../ui/payment-form.component";
@Component({
selector: "app-checkout-page",
imports: [OrderSummaryComponent, PaymentFormComponent],
template: `
@if (store.loading()) {
<p>Carregando seu pedido...</p>
} @else {
<app-order-summary [items]="store.items()" [total]="store.total()" />
<app-payment-form (submitted)="store.pay($event)" />
}
`,
})
export class CheckoutPageComponent {
protected readonly store = inject(CheckoutStore);
}
Essa divisão de bundle cai no seu colo pela própria organização das pastas. De graça? Não tanto: você ainda escreve o loadChildren à mão. Mas a estrutura faz o resto, e cada feature acaba embrulhando o próprio chunk.
A porta de entrada de cada feature
Toda feature merece uma fachada: um único ponto por onde o resto do app conversa com ela. Quem faz esse papel é o index.ts, o tal barrel file: exporta o que é público, esconde o resto.
// features/checkout/index.ts
export { checkoutRoutes } from "./checkout.routes";
export type { Order } from "./data-access/checkout.models";
Quem está de fora importa só o que a fachada oferece, e isso fica limpo com path aliases no tsconfig:
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@checkout": ["src/app/features/checkout/index.ts"],
"@catalog": ["src/app/features/catalog/index.ts"],
"@shared/*": ["src/app/shared/*"]
}
}
}
Ganho de fronteira. Quem precisa de algo do checkout escreve import { Order } from '@checkout' e não alcança nenhum arquivo interno que você preferiu esconder. Essa feature ganha superfície pública e tripas privadas, que nem um pacote de verdade.
Onde isso me mordeu
Simples de explicar, feature-based é cheio de armadilhas na prática. As que mais me custaram caro:
Feature que invade as tripas da outra. Acoplamento escondido, o clássico. Alguém precisa de um helper que mora em checkout/util e escreve import { formatMoney } from '../../checkout/util/money'. Pronto. O catálogo agora depende de um detalhe interno dele, e ninguém percebeu. No dia de deletar o checkout, você descobre que metade do app dependia dele por baixo do pano. Se o código é mesmo compartilhado, ele não pertence a feature nenhuma: sobe para shared.
O shared que vira lixeira. Todo projeto por feature ganha um shared, e todo shared quer virar saco de gato. Começa com um botão genérico. Meio ano depois (nunca falha), mora ali dentro regra de negócio de três features diferentes. Segura essa erosão uma pergunta meio sem graça: isso é genérico mesmo, ou eu só não tive paciência de achar o dono certo? Botão e diretiva, tudo bem; lógica de pedido, jamais.
Barrel files demais. Esse index.ts é ótimo na fronteira da feature e perigoso por dentro. Encha o projeto de barrels reexportando barrels e você ganha dois presentes nada bem-vindos: dependências circulares que estouram em runtime de um jeito difícil de rastrear, e um tree-shaking pior, porque o bundler perde a noção do que pode descartar. Um barrel por feature, na borda. Lá dentro, importe direto do arquivo.
A god feature. Aquela que cresceu demais e virou um miniprojeto bagunçado é só o problema original numa pasta menor. Quando checkout passa de uns 60 arquivos, arrisco dizer que ali dentro existem duas ou três features querendo nascer.
O que feature-based não resolve
Nem tudo cabe numa feature. Insistir que cabe é onde o modelo começa a ranger.
Existe o estado que é de todos e de ninguém: autenticação, tema, idioma, usuário logado. Atravessa o app inteiro e não acha dono natural entre as features. Empurrar para dentro de uma delas cria dependência torta, porque de repente orders importa de auth só para descobrir quem está logado. Esse material transversal pede casa própria, em geral um core carregado uma vez só na raiz.
Tem também a fronteira ambígua. Nem todo domínio se recorta limpo. Onde termina o “pedido” e começa o “pagamento”? Confesso que erro essa divisa direto: o corte certo, na minha experiência, só aparece depois de meses de uso, e remanejar de pasta quando ele aparece é absolutamente normal. Errado mesmo é achar que se crava isso de primeira, logo no dia um.
E tem o caso da estrutura que vira puro peso morto: numa interface de cinco telas, montar feature/ui/data-access/util em cada canto é cerimônia que custa mais do que rende. Camada existe para domar complexidade. Sem complexidade para domar, vira só burocracia.
Quando vale a pena
Pende a favor quando o sistema tem vida longa pela frente, mais de uma pessoa no código, ou times diferentes tocando partes distintas do mesmo repositório. Aí as fronteiras de feature viram fronteiras de propriedade. Cada time cuida das próprias pastas e pisa menos no calo dos colegas. Em monorepo isso fica ainda mais natural: ferramentas como o Nx levam essas linhas tão a sério que barram no lint qualquer import que atravesse uma divisa proibida. Na primeira vez que esse lint me travou um commit, eu xinguei; hoje, agradeço.
Num protótipo, num app de fim de semana, numa tela só? A velha organização por tipo resolve, e ainda é mais rápida. Ninguém ganha prêmio por arquitetar demais cedo demais.
No fim, o que ganhei com esse jeito de organizar não foi beleza de diagrama. Foi poder abrir uma pasta e entender um pedaço do sistema sem carregar o mapa inteiro na cabeça, apagar uma feature deletando um diretório, botar gente nova para produzir numa área sem que ela precisasse conhecer o resto inteiro. Bala de prata não é: estrutura nenhuma conserta código mal pensado lá dentro. Mas, quando o projeto fica grande o bastante para doer, agrupar pelo que o software faz, e não pela categoria técnica de cada peça, foi o que me fez largar o vício de perder tardes caçando arquivo.