Na GFT no Brasil, construímos dashboards em tempo real para empresas de investimentos em uma base de código que começou com um shared module. Tudo que não era um feature component ia para o SharedModule. Não demorou para o SharedModule importar e exportar dezenas de coisas, e ninguém conseguia dizer o que pertencia ali versus em uma feature. Desembaraçar tudo nos custou tempo real de sprint. Na Wonderlabs, fomos na direção oposta e colocamos coisas demais nos feature modules, incluindo coisas que várias features precisavam. Acabamos com componentes quase duplicados. A estrutura que uso agora é o resultado dessas duas falhas. Uma nota de escopo antes dos detalhes: a plataforma de solicitação de empréstimos em que trabalhei no BMO era dividida em micro-frontends, que é um problema diferente com regras próprias. Este post é sobre estruturar uma única aplicação Angular, como os produtos independentes da GFT e da Wonderlabs. Os nomes de pastas e features nos exemplos são ilustrativos, não são tirados do codebase de nenhum empregador.
O Modelo de Três Camadas
Core, Shared, Features. Essas três camadas têm regras diferentes e propósitos diferentes. Acertar as regras é o que faz a estrutura escalar.
- Core: serviços singleton, HTTP interceptors, auth guards, estado global da aplicação, layout shell. Instanciado uma vez. Nunca importado por Shared, e nunca reprovido pelas Features.
- Shared: componentes de apresentação, pipes, directives e utilitários sem conhecimento do domínio de negócio. Reutilizável em qualquer projeto. Nenhum serviço com estado.
- Features: fatias verticais de funcionalidade de negócio. Cada feature é dona dos seus componentes, serviços, estado, modelos e rotas. Features não importam umas das outras diretamente.
src/app/
core/
auth/
auth.service.ts
auth.guard.ts
auth.interceptor.ts
http/
api.service.ts
error.interceptor.ts
loading.interceptor.ts
layout/
shell/
header/
sidebar/
store/ # Global NgRx state (app-level only)
app.state.ts
app.effects.ts
shared/
components/
button/
data-table/
modal/
form-field/
pipes/
currency-format.pipe.ts
date-locale.pipe.ts
directives/
tooltip.directive.ts
features/
applications/
lending/
profile/Estrutura de Pastas da Feature
Cada feature é uma fatia vertical autocontida. Um desenvolvedor que nunca viu a base de código deveria conseguir abrir a pasta applications e entender tudo sobre a feature de applications. A estrutura é consistente em todas as features.
features/
applications/
components/
application-list/
application-list.component.ts
application-list.component.html
application-list.component.scss
application-list.component.spec.ts
application-detail/
application-form/
services/
applications.service.ts
applications-api.service.ts
store/
applications.store.ts # NgRx Signal Store
models/
application.model.ts
application-status.enum.ts
applications.routes.ts
index.ts # Public API for cross-feature importsO index.ts na raiz da feature é uma fronteira de API pública deliberada. Ele exporta apenas o que outras features ou o core legitimamente precisam. Todo o resto é interno à feature. Quando você precisa refatorar os internos, você sabe o raio de impacto.
Barrel Files: Use nas Fronteiras, em Nenhum Outro Lugar
Barrel files em cada nível de pasta parecem organizados na teoria. Na prática, eles criam cadeias de dependência circular que o TypeScript resolve de maneiras não óbvias, e eles desaceleram a compilação em projetos grandes porque o compilador TypeScript precisa resolver mais arquivos. Minha regra: um barrel na raiz da feature para a API pública, e nada mais.
// features/applications/index.ts
// Only export what genuinely crosses the feature boundary
export { ApplicationSummaryComponent } from './components/application-summary/application-summary.component';
export { Application } from './models/application.model';
export { ApplicationStatus } from './models/application-status.enum';
export { ApplicationsService } from './services/applications.service';
// Consuming from another feature: use the barrel
import { Application } from '@features/applications';
// Do NOT do this from outside the feature:
import { ApplicationFormComponent } from '@features/applications/components/application-form/application-form.component';Path Aliases
Configure os path aliases do tsconfig.json no primeiro dia. Imports relativos que sobem mais de dois níveis na árvore de diretórios são um sinal de que a estrutura precisa ser corrigida, mas até você corrigir a estrutura, imports relativos tornam a refatoração dolorosa. Aliases também são auto-documentáveis: @core/auth se lê melhor que ../../../core/auth.
{
"compilerOptions": {
"paths": {
"@core/*": ["src/app/core/*"],
"@shared/*": ["src/app/shared/*"],
"@features/*": ["src/app/features/*"],
"@env/*": ["src/environments/*"]
}
}
}Aplicando Fronteiras com ESLint
Estrutura sem aplicação é decoração. Desenvolvedores sob pressão de prazo vão importar diretamente dos internos de outra feature se o caminho funcionar. A regra @nx/enforce-module-boundaries do ESLint previne isso, mas ela exige um workspace Nx e o project graph do Nx para funcionar, já que lê os tags definidos no nx.json e no project.json de cada lib. Se seu repositório não é Nx, a alternativa pragmática é o eslint-plugin-boundaries, que configura os limites direto via globs no .eslintrc sem depender do grafo do Nx.
As fronteiras que aplico: features não podem importar diretamente de outras features (precisam passar pelo index.ts), shared não pode importar de features ou core, core não pode importar componentes de shared. Violações falham no lint no CI antes do PR ser revisado. A ligação não tem nada de especial: as regras de fronteira ficam em error em vez de warn, e o pipeline roda ng lint como um passo próprio antes do build. Uma fronteira violada faz o ESLint sair com código diferente de zero, o passo fica vermelho, e o pull request é bloqueado pela proteção de branch. Sem reporter customizado, sem plugin extra, e sem nada que só funcione em um provedor de CI.
O Que Quebra Sem Essa Estrutura
- Um mega-shared module que incha até ninguém saber o que tem nele: divida shared em ui-components, utilities e models
- Componentes nomeados pelo seu papel visual em vez do domínio: TransactionCard, não CardWithBadge
- Serviços fornecidos em features que são instanciados múltiplas vezes porque não são root-provided
- Configuração de rotas espalhada pelo app.module ou app.config em vez de colocalizada com a feature
- Arquivos de teste em uma pasta tests separada na raiz em vez de colocalizados com o arquivo que testam
O Que Eu Faria Diferente Começando Hoje
Com standalone components como padrão (para projetos novos desde o Angular 17, e no próprio decorator desde o Angular 19), a camada de NgModule desapareceu. O modelo de três camadas se mantém, mas a camada shared agora é apenas standalone components, pipes e directives, não um módulo. Rotas de features são apenas arquivos de rota, não NgModules. A estrutura é a mesma, o boilerplate é menor.
Eu também configuraria as regras de fronteira do ESLint no primeiro dia, antes de haver qualquer código que as viole. Retroalimentar a aplicação de fronteiras em uma base de código que nunca teve isso significa corrigir centenas de violações de import antes que a regra possa ir para error. Configure no primeiro dia e não custa nada.
O teste de uma estrutura é se um desenvolvedor que nunca viu a base de código consegue navegar até o lugar certo em menos de dois minutos. Se não consegue, a estrutura não está se pagando.
O modelo de três camadas com fronteiras aplicadas, path aliases e testes colocalizados se sustentou em cada projeto em que apliquei. Não é empolgante. É apenas consistente, e consistência é o que torna grandes bases de código Angular manteníveis conforme o time e o número de features crescem.
Referências
- Angular project file structure. https://angular.dev/reference/configs/file-structure
- TypeScript path aliases. https://www.typescriptlang.org/tsconfig#paths
- angular-eslint. https://github.com/angular-eslint/angular-eslint
- Nx enforce-module-boundaries. https://nx.dev/nx-api/eslint-plugin/documents/enforce-module-boundaries