Pular para o conteúdo
< samuelsantana.dev />
Voltar para o BlogÁrvore src/app do Angular 22 com cada feature ligada ao seu chunk lazy do ng build (product-list 796 bytes, cart 498 bytes) e o import de checkout para catalog barrado pelo lint de fronteiras.

Estrutura de pastas em Angular 22: features lazy e fronteiras que o lint garante

Samuel Santana
Publicado em 29 de setembro de 2026
AngularSoftware ArchitectureTypeScript

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 generateArquivo criadoSímbolo exportado
component product-listproduct-list.ts (+ .html, .css, .spec.ts)ProductList
service product-apiproduct-api.tsProductApi
directive autofocusautofocus.tsAutofocus
pipe currency-brlcurrency-brl-pipe.tsCurrencyBrlPipe
guard authauth-guard.tsauthGuard
interceptor auth-tokenauth-token-interceptor.tsauthTokenInterceptor
resolver productproduct-resolver.tsproductResolver

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 Appproduct-list (lazy)product-detail (lazy)Onde está o código dos componentes
'@features/catalog' (barrel)122 bytes122 bytesno bundle inicial
'@features/catalog/catalog-store'774 bytes738 bytesnos 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.ts e auth.guard.ts viraram checkout.ts e auth-guard.ts nos projetos novos desde a v20. Nos antigos, a migration manteve o formato velho pelo angular.json.
  • Pastas por tipo dentro da feature: components/ e services/ 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 para shared/ 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

Comentários

Carregando comentários...