Saltar al contenido
< samuelsantana.dev />
Volver al 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

Estás leyendo la versión en portugués de este artículo. Léelo en Español

Samuel Santana
Publicado el 29 de septiembre 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

Comentarios

Cargando comentarios...