
Estrutura de pastas em Angular 22: features lazy e fronteiras que o lint garante
Em julho eu escrevi um panorama de arquitetura de pastas para React, Angular e Node.js. A parte de Angular envelheceu mal. Ela recomendava checkout.component.ts e auth.guard.ts, pastas components/ e services/ dentro de cada feature e uma pasta data-access/ global. Parte disso o Angular já tinha mudado quando o texto saiu; outra parte eu nunca tinha testado.
Desta vez eu não confiei na memória. Gerei um projeto com o Angular CLI 22.2.0, a versão mais recente hoje (saiu em 23 de setembro), e passei cada afirmação por ng build, ng test ou ng lint. O que está abaixo é saída desses comandos ou texto da documentação oficial. Quando é opinião minha, eu digo.
O que o CLI 22.2 gera, e por que os nomes mudaram
ng new com rotas e sem SSR cria isto dentro de src/:
src/
├─ index.html
├─ main.ts
├─ styles.css
└─ app/
├─ app.ts
├─ app.html
├─ app.css
├─ app.spec.ts
├─ app.config.ts
└─ app.routes.ts
Nenhum app.component.ts, nenhum AppModule. O componente raiz é a classe App, sem standalone: true no decorator, porque standalone é o padrão desde o Angular 19 (o changelog da 19.0.0 diz: "Angular directives, components and pipes are now standalone by default"). NgModules não acabaram: continuam documentados, na seção "Extended Ecosystem" do angular.dev. Só deixaram de ser o caminho padrão.
Depois rodei ng generate para cada tipo de artefato. O resultado:
ng generate | Arquivo criado | Símbolo exportado |
|---|---|---|
component product-list | product-list.ts (+ .html, .css, .spec.ts) | ProductList |
service product-api | product-api.ts | ProductApi |
directive autofocus | autofocus.ts | Autofocus |
pipe currency-brl | currency-brl-pipe.ts | CurrencyBrlPipe |
guard auth | auth-guard.ts | authGuard |
interceptor auth-token | auth-token-interceptor.ts | authTokenInterceptor |
resolver product | product-resolver.ts | productResolver |
Há um padrão. Componente, diretiva e serviço perderam o tipo no nome do arquivo e na classe. Pipe, guard, interceptor e resolver mantiveram o tipo, mas separado por hífen, não por ponto. Isso segue o guia de estilo atual: "Separate words within a file name with hyphens" e "When the file contains a TypeScript class, the file name should reflect that class name". O próprio ng new --help descreve as duas convenções: o guia "2025" (padrão) usa app.ts; o "2016" põe o tipo no nome, app.component.ts. A opção --file-name-style-guide=2016 existe no ng new desde o CLI 21, para quem quer o formato antigo num projeto novo.
Se o seu CLI ainda gera .component.ts
Não é bug. Quando a convenção mudou, na v20, o ng update passou a rodar uma migration que grava defaults no angular.json para o projeto existente continuar gerando os nomes antigos. Segundo a descrição do commit, são estes:
{
"@schematics/angular:component": { "type": "component" },
"@schematics/angular:directive": { "type": "directive" },
"@schematics/angular:service": { "type": "service" },
"@schematics/angular:guard": { "typeSeparator": "." },
"@schematics/angular:interceptor": { "typeSeparator": "." },
"@schematics/angular:module": { "typeSeparator": "." },
"@schematics/angular:pipe": { "typeSeparator": "." },
"@schematics/angular:resolver": { "typeSeparator": "." }
}
A lista explica o padrão da tabela: type volta o sufixo nos três primeiros, e typeSeparator volta o ponto nos outros cinco. Se o seu projeto veio de atualizações sucessivas, procure a chave schematics no angular.json antes de culpar o CLI. Renomear uma base inteira é outra decisão, e eu não a tomaria só por estética: o guia pede consistência dentro de cada arquivo acima de qualquer regra dele.
O @Service() que o CLI 22 gera
O product-api.ts gerado não usa @Injectable:
import { Service } from '@angular/core';
@Service()
export class ProductApi {}
O @Service entrou no Angular 22.0.0, em junho, segundo o changelog. A documentação de serviços o define como "a modern, ergonomic shorthand for the traditional @Injectable({ providedIn: 'root' }) syntax": singleton, disponível em toda a aplicação e tree-shakable. A mesma página traz a diferença que importa na prática: @Service não suporta injeção pelo construtor, só inject(). Para escopo de rota ou de componente, existe @Service({ autoProvided: false }), que desliga o registro automático e exige um providers explícito. Esse detalhe volta mais à frente, porque é ele que decide onde o estado de uma feature mora.
A árvore que eu uso
Este é o projeto final, com duas features (catálogo e checkout), tudo verificado por build, testes e lint:
src/app/
├─ app.ts app.html app.css app.config.ts app.routes.ts
├─ core/
│ ├─ auth/
│ │ ├─ auth-session.ts # @Service(): quem está logado
│ │ └─ auth-guard.ts # canMatch da rota de checkout
│ └─ http/
│ └─ auth-token-interceptor.ts
├─ shared/
│ ├─ products/
│ │ └─ product.ts # tipo usado pelas duas features
│ └─ ui/
│ └─ currency-brl-pipe.ts
└─ features/
├─ catalog/
│ ├─ catalog.routes.ts # export default das rotas da feature
│ ├─ catalog-store.ts # estado com escopo de rota
│ ├─ product-api.ts
│ ├─ product-list/ # .ts, .html, .css e .spec.ts juntos
│ └─ product-detail/
└─ checkout/
├─ checkout.routes.ts
├─ checkout-store.ts
└─ cart/
Três regras do guia de estilo sustentam essa forma. "Organize your project into subdirectories based on the features of your application", e explicitamente: "avoid creating directories like components, directives, and services". Arquivos de um componente ficam juntos, e o teste fica ao lado do código ("Unit tests should live in the same directory as the code-under-test"). E nada de utils.ts: o guia pede "Avoid overly generic file names like helpers.ts, utils.ts, or common.ts".
core/, shared/ e features/ não estão no guia. São convenção minha, e a razão é concreta: cada uma dessas pastas vira um tipo de elemento na regra de lint que aparece mais adiante. Uma pasta que não é alvo de nenhuma regra é só decoração. core/ guarda o que a aplicação inteira precisa desde o primeiro carregamento. shared/ guarda o que não sabe que feature nenhuma existe. features/ agrupa as fatias que carregam sob demanda.
O nome catalog.routes.ts, com ponto, imita o app.routes.ts que o próprio CLI gera.
Lazy por feature: a pasta vira um chunk
As rotas da aplicação só apontam para as features:
// app.routes.ts
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'catalog' },
{ path: 'catalog', loadChildren: () => import('@features/catalog/catalog.routes') },
{ path: 'checkout', canMatch: [authGuard], loadChildren: () => import('@features/checkout/checkout.routes') },
];
E cada feature exporta as próprias rotas como default, o que dispensa o .then(). A documentação de estratégias de carregamento diz: "If the lazily loaded file uses a default export, you can return the import() promise directly".
// features/catalog/catalog.routes.ts
export default [
{
path: '',
providers: [CatalogStore],
children: [
{ path: '', loadComponent: () => import('./product-list/product-list').then((m) => m.ProductList) },
{ path: ':id', loadComponent: () => import('./product-detail/product-detail').then((m) => m.ProductDetail) },
],
},
] satisfies Routes;
O ng build de produção mostra o resultado:
Initial chunk files | Names | Raw size | Estimated transfer size
main-SGQLTLX3.js | main | 240.39 kB | 64.96 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-CsoRDU0D.js | product-list | 796 bytes | 796 bytes
chunk-CUGTxQp-.js | product-detail | 732 bytes | 732 bytes
chunk-BavZ1Hy7.js | catalog-routes | 634 bytes | 634 bytes
chunk-iqYSogQM.js | checkout-routes | 524 bytes | 524 bytes
chunk-Dny5jqu0.js | cart | 498 bytes | 498 bytes
chunk-B-WAw2NO.js | - | 302 bytes | 302 bytes
chunk-DX5tK5oh.js | - | 274 bytes | 274 bytes
Procurando cada classe nos arquivos gerados, dá para ver onde cada coisa foi parar. O CatalogStore está no chunk catalog-routes, junto com a rota que o provê. O ProductApi, um @Service() de escopo raiz, está num chunk lazy compartilhado (o de 302 bytes), não no main. A documentação promete que um serviço raiz que ninguém usa fica fora do bundle; o build mostra o passo seguinte: um serviço raiz usado só por features lazy vai junto com elas. "Provido na raiz" não quer dizer "no bundle inicial". O pipe de moeda, usado por três componentes lazy, ficou no outro chunk compartilhado. E o AuthSession está no main, porque o guard que o usa roda em app.routes.ts, antes de qualquer feature carregar.
canMatch não baixa a feature; canActivate baixa
A rota de checkout usa canMatch, não canActivate, e a diferença é medível. Num teste com RouterTestingHarness, contei quantas vezes o loadChildren roda quando o guard nega o acesso:
let loads = 0;
const loadFeature = () => {
loads++;
return [{ path: '', component: Home }];
};
it('canMatch: o loader nunca roda', async () => {
expect(await visitWith([{ path: 'feature', canMatch: [() => false], loadChildren: loadFeature }])).toBe(0);
});
it('canActivate: o loader roda mesmo assim', async () => {
expect(await visitWith([{ path: 'feature', canActivate: [() => false], loadChildren: loadFeature }])).toBe(1);
});
Os dois passam. Com canActivate, o código da feature é carregado mesmo com o guard negando o acesso. Um detalhe que o teste revelou: sem uma rota ** de fallback, o canMatch negado termina em NG04002: Cannot match any routes. A documentação de guards explica o motivo: quando canMatch devolve false, "Angular tries other matching routes instead of completely blocking navigation". Se nenhuma outra rota casa, a navegação falha. Por isso o authGuard deste projeto devolve um UrlTree para /catalog em vez de false.
O barrel que desfaz o lazy loading
O post de julho, na parte de React, defendia um index.ts na raiz de cada feature como "API pública" dela. Antes de trazer a ideia para o Angular, eu a testei aqui. Criei features/catalog/index.ts reexportando CatalogStore, ProductList e ProductDetail, e fiz o App importar só o store por ele:
// features/catalog/index.ts
export { CatalogStore } from './catalog-store';
export { ProductList } from './product-list/product-list';
export { ProductDetail } from './product-detail/product-detail';
// app.ts
import { CatalogStore } from '@features/catalog';
O build mudou assim:
Import no App | product-list (lazy) | product-detail (lazy) | Onde está o código dos componentes |
|---|---|---|---|
'@features/catalog' (barrel) | 122 bytes | 122 bytes | no bundle inicial |
'@features/catalog/catalog-store' | 774 bytes | 738 bytes | nos chunks lazy |
Com o barrel, o código dos dois componentes foi para o bundle inicial, e os chunks lazy viraram cascas de 122 bytes. Importando o arquivo direto, eles continuaram lazy. Neste projeto de brinquedo são centenas de bytes; numa feature real, é a feature inteira indo para o primeiro carregamento sem ninguém perceber, porque a rota continua com loadComponent e tudo parece certo no código. Não investiguei por que o bundler não descartou os componentes não usados. Para decidir, o resultado basta: em feature lazy, eu não ponho barrel na raiz.
Onde mora o estado
A documentação de providers lista três lugares para registrar uma dependência: na inicialização da aplicação, num componente ou diretiva, e numa rota. Além deles, há o registro automático na raiz, que é o que @Service() faz sem configuração nenhuma.
Escopo raiz (@Service()). Uma instância para a aplicação inteira. É onde ficam AuthSession e ProductApi. Não precisa de providers, e o código vai para o chunk de quem usa, como o build mostrou.
Escopo de rota (@Service({ autoProvided: false }) mais providers na rota). É onde fica o CatalogStore:
@Service({ autoProvided: false })
export class CatalogStore {
private readonly api = inject(ProductApi);
readonly products = signal<readonly Product[]>(this.api.list());
}
A documentação garante que serviços providos na rota "are available to all components and directives within that route, as well as to its guards and resolvers". O autoProvided: false é o que impede o uso fora dela. Um teste com as duas variações mostra a diferença: fora de qualquer rota, um @Service() comum resolve pelo injector raiz, em silêncio, com uma instância que não é a da feature; o @Service({ autoProvided: false }) falha com NG0201: No provider found. Prefiro o erro.
Escopo de componente (providers no @Component). A instância nasce e morre com o componente: "when the component gets destroyed, the provided service is also destroyed as well". Serve para estado de formulário ou de modal, não de feature.
O injector da rota não morre quando você sai dela
Este comportamento me surpreendeu. Escrevi um teste que entra numa rota com provider próprio, sai para outra e volta, contando quantas instâncias foram criadas e destruídas:
let created = 0;
let destroyed = 0;
@Service({ autoProvided: false })
class FeatureStore {
constructor() {
created++;
inject(DestroyRef).onDestroy(() => destroyed++);
}
}
async function enterLeaveAndReturn(...features) {
TestBed.configureTestingModule({ providers: [provideRouter(routes, ...features)] });
const harness = await RouterTestingHarness.create();
await harness.navigateByUrl('/feature');
await harness.navigateByUrl('/elsewhere');
const destroyedAfterLeaving = destroyed;
await harness.navigateByUrl('/feature');
return { destroyedAfterLeaving, created };
}
it('sobrevivem à rota por padrão', async () => {
expect(await enterLeaveAndReturn()).toEqual({ destroyedAfterLeaving: 0, created: 1 });
});
it('são destruídos ao sair com withAutoCleanupInjectors (e recriados na volta)', async () => {
expect(await enterLeaveAndReturn(withAutoCleanupInjectors())).toEqual({ destroyedAfterLeaving: 1, created: 2 });
});
Os dois passam. Por padrão, o store da rota continua vivo depois que o usuário sai dela, com o estado que tinha. Quem volta encontra a mesma instância. Isso pode ser exatamente o que você quer (um filtro de catálogo preservado) ou um vazamento silencioso (um store pesado que nunca é coletado).
O withAutoCleanupInjectors() foi estabilizado no Angular 22.2.0, lançado em 23 de setembro; até ali existia como withExperimentalAutoCleanupInjectors(), que agora está marcado como deprecated. A referência da API descreve o comportamento: o router destrói os EnvironmentInjectors de rotas que não estão mais ativas nem guardadas pela RouteReuseStrategy. A condição é a RouteReuseStrategy.shouldDestroyInjector devolver true. Conferi no código do @angular/router 22.2.0: a estratégia padrão devolve true, então com a estratégia padrão basta ligar a feature. Com uma estratégia customizada, é preciso implementar esse método, e também retrieveStoredRouteHandles se ela guardar rotas para reuso.
O custo aparece no segundo teste: created: 2. Com a limpeza ligada, voltar à rota cria outro store, e o estado anterior se perde. Eu decido por feature. Estado caro e que o usuário espera reencontrar fica na raiz, de propósito, e não na rota "por acidente".
Fronteiras: a pasta não impede nada, o lint sim
Pastas não têm semântica para o compilador. Para provar, fiz o CheckoutStore importar o CatalogStore e o ProductApi do catálogo, um pelo alias e outro por caminho relativo. O ng build terminou normalmente. A fronteira entre features só existe se alguma ferramenta a verificar.
Usei o eslint-plugin-boundaries 7.2.0 sobre o ESLint que o angular-eslint configura. Cada pasta vira um tipo de elemento, e as políticas dizem quem pode importar quem:
{
files: ['src/**/*.ts'],
plugins: { boundaries },
settings: {
'import/resolver': { typescript: { alwaysTryTypes: true } },
'boundaries/elements': [
{ type: 'feature', pattern: 'src/app/features/*', capture: ['feature'] },
{ type: 'core', pattern: 'src/app/core' },
{ type: 'shared', pattern: 'src/app/shared' },
],
'boundaries/files': [{ category: 'shell', pattern: 'src/app/*.ts' }],
},
rules: {
'boundaries/dependencies': ['error', {
default: 'disallow',
message: '{{from.element.types}} cannot import {{to.element.types}}',
policies: [
{ from: { file: { categories: 'shell' } }, allow: { to: { file: { categories: 'shell' } } } },
{ from: { file: { categories: 'shell' } }, allow: { to: { element: { types: { anyOf: ['feature', 'core', 'shared'] } } } } },
{ from: { element: { type: 'core' } }, allow: { to: { element: { type: 'shared' } } } },
{ from: { element: { type: 'feature' } }, allow: { to: { element: { types: { anyOf: ['core', 'shared'] } } } } },
{
from: { element: { type: 'feature' } },
disallow: { to: { element: { type: 'feature' } } },
message: 'feature "{{from.element.captured.feature}}" cannot import feature "{{to.element.captured.feature}}"; move what both need to shared/',
},
// Por último de propósito: vence a última política que casar, e esta cobre arquivos do mesmo elemento.
{ allow: { dependency: { relationship: { to: 'internal' } } } },
],
}],
},
}
A ordem das políticas não é estética. A documentação do plugin diz que "the final result is determined by the last matching policy". A regra que proíbe feature importar feature também casaria com imports dentro da mesma feature; a política de dependências internas vem depois para vencer nesse caso. A capture: ['feature'] é o que deixa a mensagem dizer qual feature importou qual. E não há política para @angular/* nem para outros pacotes do npm porque não precisa: segundo o guia de migração, "by default, boundaries/dependencies only checks dependencies whose target module origin is local". Eu tinha escrito uma política liberando pacotes externos; tirei, e o lint continuou passando.
Com os imports proibidos no lugar, o ng lint respondeu:
src\app\core\auth\core-leak.ts
1:28 error core cannot import feature boundaries/dependencies
src\app\features\checkout\checkout-store.ts
3:30 error feature "checkout" cannot import feature "catalog"; move what both need to shared/ boundaries/dependencies
4:28 error feature "checkout" cannot import feature "catalog"; move what both need to shared/ boundaries/dependencies
src\app\shared\ui\leak.ts
1:31 error shared cannot import feature boundaries/dependencies
✖ 4 problems (4 errors, 0 warnings)
O alias @features/catalog/... e o caminho relativo ../catalog/... foram pegos do mesmo jeito. Imports dinâmicos também entram: quando tirei a política que libera o shell, o lint acusou as linhas de import() do app.routes.ts.
Duas notas da versão 7, para quem copiar configuração de exemplos antigos. A opção mode dos elementos virou deprecated: o lint avisa e manda usar boundaries/files no lugar de mode: 'file', que é o que a configuração acima faz para os arquivos da raiz de app/. E, segundo o guia de migração da v6 para a v7, a opção rules da regra foi renomeada para policies, e os presets agora só ligam boundaries/dependencies; a antiga boundaries/element-types já estava deprecated.
Quando o checkout precisa do produto
O erro manda mover para shared/ o que as duas features precisam, e a pergunta é o que mover. No projeto, o checkout só precisava do formato de um produto, então foi isso que subiu: a interface Product, em shared/products/product.ts. O CatalogStore e o ProductApi continuaram no catálogo. Minha regra é subir a menor coisa que resolve. Se o que as duas features precisam é um store inteiro, eu desconfio da divisão das features antes de mover o store.
Path aliases sem baseUrl
Os aliases @core, @shared e @features estão no tsconfig.json sem baseUrl:
"paths": {
"@core/*": ["./src/app/core/*"],
"@shared/*": ["./src/app/shared/*"],
"@features/*": ["./src/app/features/*"]
}
Não é preferência. O projeto gerado usa TypeScript 6.0, e quando eu acrescentei "baseUrl": "./", o tsc recusou:
error TS5101: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0.
Specify compilerOption '"ignoreDeprecations": "6.0"' to silence this error.
As notas do TypeScript 6.0 explicam que baseUrl também funcionava como raiz de busca de módulos, o que fazia imports que nunca funcionariam em runtime parecerem válidos, e mandam pôr o prefixo direto em cada entrada de paths. Do lado do lint, o eslint-import-resolver-typescript "automatically detects custom path mappings defined in your tsconfig", segundo a documentação do plugin, então o lint e o compilador enxergam os mesmos caminhos.
O que mudou desde o post de julho
- Nomes:
checkout.component.tseauth.guard.tsviraramcheckout.tseauth-guard.tsnos projetos novos desde a v20. Nos antigos, a migration manteve o formato velho peloangular.json. - Pastas por tipo dentro da feature:
components/eservices/são exatamente o que o guia pede para evitar. data-access/global: o acesso a dados fica dentro da feature que o usa; só sobe parashared/o que duas features precisam.- Barrel na raiz da feature (defendido na parte de React): numa feature lazy de Angular, como medido acima, ele leva para o bundle inicial código que deveria ser lazy.
- Fronteiras "privadas": o post dizia que os componentes de uma feature eram privados. Sem lint, isso é só intenção.
Por que isso importa
Uma estrutura de pastas é uma afirmação sobre dependências: o que está em features/checkout não depende de features/catalog, e o que está em shared/ não depende de feature nenhuma. Ninguém verifica essa afirmação por padrão. O compilador aceita qualquer import, o build continua verde, e a estrutura vai se desfazendo um import de cada vez até as pastas virarem só nomes.
O que mantém a afirmação verdadeira são três verificações. A saída do ng build mostra um chunk por feature, e um barrel ou import errado aparece ali como chunk encolhido. O lint de fronteiras, rodando no CI, quebra o pull request que cruza features. E os nomes de arquivo seguem o que o CLI gera, para que o próximo ng generate não crie um segundo padrão no meio do primeiro. O resto, core ou shared, features/ ou não, é convenção. Escolha a sua, mas ligue as três verificações.
Referências
- Angular — Style Guide
- Angular — Creating and using services (
@Service) - Angular — Defining dependency providers
- Angular — Route loading strategies
- Angular — Route guards
- Angular — API
withAutoCleanupInjectors - Angular — API
RouteReuseStrategy - Angular —
ng new - Angular — CHANGELOG (19.0.0, 22.0.0, 22.2.0)
- Angular CLI — migration "keep previous style guide generation behavior" (v20)
- TypeScript 6.0 — release notes (
baseUrldeprecated) - eslint-plugin-boundaries — Policies
- eslint-plugin-boundaries — TypeScript support
- eslint-plugin-boundaries — migração v6 para v7
Comentários
Carregando comentários...