Por que organizar por features (e não por tipo de arquivo)
Em projetos Ionic com Angular, é comum começar organizando por tipo: pages/, services/, models/. Isso funciona no início, mas tende a gerar acoplamento e dificuldade para evoluir quando o app cresce. A organização por features agrupa tudo que pertence a uma funcionalidade (telas, componentes específicos, serviços, tipos e testes) no mesmo lugar. O resultado é um código mais fácil de encontrar, refatorar e testar.
Uma boa estrutura escalável costuma combinar quatro áreas principais:
- core: infraestrutura e itens globais (config, interceptors, guards avançados, serviços singleton, adaptadores).
- shared: componentes, pipes e utilitários reutilizáveis e sem dependência de uma feature específica.
- features: cada funcionalidade do app (ex.:
auth,orders,profile), contendo UI e lógica daquela área. - assets: imagens, ícones, fontes, etc.
Estrutura de pastas recomendada
Exemplo de estrutura (ajuste conforme o tamanho do projeto):
src/app/ core/ http/ api-client.service.ts http-error.mapper.ts storage/ storage.service.ts auth/ auth.facade.ts token.service.ts guards/ authenticated.guard.ts config/ app-config.ts shared/ ui/ empty-state/ empty-state.component.ts empty-state.component.html empty-state.component.scss empty-state.component.spec.ts app-toolbar/ app-toolbar.component.ts directives/ pipes/ utils/ date.util.ts string.util.ts features/ orders/ data-access/ orders.api.ts orders.repository.ts orders.models.ts ui/ order-card/ order-card.component.ts pages/ orders-list/ orders-list.page.ts orders-list.page.html orders-list.page.scss order-details/ order-details.page.ts orders.routes.ts orders.facade.ts profile/ ... app.routes.tsO que vai em cada pasta
- features/<feature>/pages: páginas (screens) Ionic. Devem conter o mínimo de lógica possível.
- features/<feature>/ui: componentes visuais específicos da feature (cards, modais, listas).
- features/<feature>/data-access: acesso a dados (API, storage, mapeamentos, repositórios).
- features/<feature>/*.facade.ts: camada de orquestração (estado local, chamadas, regras simples), consumida pela UI.
- shared/ui: componentes reutilizáveis (não devem importar coisas de
features). - core: serviços globais e infraestrutura (ex.: cliente HTTP, log, config, auth base).
Padrões de nomenclatura e consistência
Arquivos e classes
- Use kebab-case para nomes de arquivos e pastas:
order-details.page.ts,orders.repository.ts. - Sufixos claros por responsabilidade:
.page.ts,.component.ts,.service.ts,.facade.ts,.repository.ts,.api.ts,.models.ts. - Classes em PascalCase:
OrdersRepository,OrdersFacade. - Interfaces e tipos: prefira nomes descritivos sem prefixo
I(ex.:Order,CreateOrderPayload). Usetypequando fizer sentido (uniões, mapeamentos) einterfacepara contratos extensíveis.
Imports e caminhos
Padronize imports para evitar caminhos relativos longos. Configure aliases no tsconfig (ex.: @core, @shared, @features) e use-os de forma consistente.
// bom (com alias)import { StorageService } from '@core/storage/storage.service';import { EmptyStateComponent } from '@shared/ui/empty-state/empty-state.component';Tipagem consistente
- Evite
any. Prefiraunknownquando necessário e faça narrowing. - Centralize modelos da feature em
*.models.tse reutilize-os em UI e data-access. - Crie tipos específicos para payloads e respostas:
UpdateProfilePayload,OrdersListResponse.
Separação de responsabilidades (UI vs lógica vs dados)
Um padrão prático para apps Ionic é dividir em três camadas:
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
- UI (pages/components): renderiza, captura eventos, exibe loading/erro. Não deve conhecer detalhes de HTTP, storage ou mapeamento.
- Lógica (facade): coordena ações, validações simples, composição de chamadas, estado local (ex.:
loading,error,items). - Acesso a dados (repository/api): conversa com API/Storage e converte DTOs em modelos do domínio.
Exemplo prático: Orders (lista e detalhes)
Imagine uma feature orders com uma lista e um detalhe. A UI chama a facade; a facade chama o repository; o repository chama a API.
1) Modelos da feature
// features/orders/data-access/orders.models.tsexport type OrderStatus = 'open' | 'paid' | 'canceled';export interface Order { id: string; title: string; total: number; status: OrderStatus; createdAt: string;}2) Camada de API (contrato HTTP)
// features/orders/data-access/orders.api.tsimport { Injectable } from '@angular/core';import { HttpClient } from '@angular/common/http';import { Observable } from 'rxjs';@Injectable({ providedIn: 'root' })export class OrdersApi { constructor(private http: HttpClient) {} list(): Observable<unknown> { return this.http.get('/api/orders'); } getById(id: string): Observable<unknown> { return this.http.get(`/api/orders/${id}`); }}Note que a API retorna unknown aqui de propósito: isso força a camada seguinte a mapear/validar. Em projetos menores, você pode tipar diretamente, mas mantenha um ponto único de mapeamento.
3) Repository (mapeamento e regras de dados)
// features/orders/data-access/orders.repository.tsimport { Injectable } from '@angular/core';import { map, Observable } from 'rxjs';import { OrdersApi } from './orders.api';import { Order } from './orders.models';type OrderDto = { id: string; title: string; total: number; status: string; createdAt: string;};function toOrder(dto: OrderDto): Order { // aqui você normaliza e garante consistência return { id: String(dto.id), title: dto.title ?? 'Sem título', total: Number(dto.total ?? 0), status: (dto.status as any) ?? 'open', createdAt: dto.createdAt, };}@Injectable({ providedIn: 'root' })export class OrdersRepository { constructor(private api: OrdersApi) {} list(): Observable<Order[]> { return this.api.list().pipe( map((raw) => (raw as OrderDto[]).map(toOrder)) ); } getById(id: string): Observable<Order> { return this.api.getById(id).pipe( map((raw) => toOrder(raw as OrderDto)) ); }}4) Facade (estado e orquestração)
// features/orders/orders.facade.tsimport { Injectable } from '@angular/core';import { BehaviorSubject, finalize } from 'rxjs';import { OrdersRepository } from './data-access/orders.repository';import { Order } from './data-access/orders.models';@Injectable({ providedIn: 'root' })export class OrdersFacade { private readonly _items = new BehaviorSubject<Order[]>([]); readonly items$ = this._items.asObservable(); private readonly _loading = new BehaviorSubject<boolean>(false); readonly loading$ = this._loading.asObservable(); private readonly _error = new BehaviorSubject<string | null>(null); readonly error$ = this._error.asObservable(); constructor(private repo: OrdersRepository) {} loadList(): void { this._loading.next(true); this._error.next(null); this.repo .list() .pipe(finalize(() => this._loading.next(false))) .subscribe({ next: (items) => this._items.next(items), error: () => this._error.next('Não foi possível carregar os pedidos.'), }); }}Essa facade facilita testes (você pode mockar o repository) e evita que a página tenha lógica de carregamento/erro espalhada.
5) Page (UI enxuta)
// features/orders/pages/orders-list/orders-list.page.tsimport { Component, OnInit } from '@angular/core';import { OrdersFacade } from '../../orders.facade';@Component({ selector: 'app-orders-list', templateUrl: './orders-list.page.html', styleUrls: ['./orders-list.page.scss'],})export class OrdersListPage implements OnInit { readonly items$ = this.facade.items$; readonly loading$ = this.facade.loading$; readonly error$ = this.facade.error$; constructor(private facade: OrdersFacade) {} ngOnInit(): void { this.facade.loadList(); } onRefresh(): void { this.facade.loadList(); }}<!-- features/orders/pages/orders-list/orders-list.page.html --><ion-content> <ng-container *ngIf="(loading$ | async) === true"> <ion-spinner></ion-spinner> </ng-container> <ng-container *ngIf="(error$ | async) as error"> <app-empty-state [message]="error"></app-empty-state> </ng-container> <ion-list> <app-order-card *ngFor="let item of (items$ | async)" [order]="item"> </app-order-card> </ion-list></ion-content>Reaproveitamento com Shared Components (sem acoplamento)
Componentes em shared/ui devem ser genéricos e configuráveis por @Input(), sem depender de modelos de uma feature específica. Exemplo: um componente de estado vazio.
// shared/ui/empty-state/empty-state.component.tsimport { Component, Input } from '@angular/core';@Component({ selector: 'app-empty-state', template: '<div class="empty">{{ message }}</div>',})export class EmptyStateComponent { @Input() message = 'Nada por aqui.';}Já um OrderCardComponent é específico de pedidos e deve ficar em features/orders/ui, pois depende do tipo Order e do layout daquela feature.
Passo a passo para refatorar um projeto existente para “por features”
Passo 1: escolha uma feature piloto
Selecione uma área pequena (ex.: profile ou orders). Evite tentar reorganizar tudo de uma vez.
Passo 2: crie a pasta da feature e mova a(s) páginas
- Crie
src/app/features/<feature>/pages. - Mova os arquivos da página para lá.
- Ajuste imports relativos e rotas (se houver arquivo de rotas da feature, como
orders.routes.ts).
Passo 3: extraia lógica da página para uma facade
- Identifique: chamadas de serviço, controle de loading/erro, transformação de dados.
- Crie
<feature>.facade.tse mova essa lógica. - Deixe a página apenas com bindings e handlers simples.
Passo 4: crie data-access (api/repository/models)
- Crie
data-accessdentro da feature. - Separe o contrato HTTP (
*.api.ts) do mapeamento e regras (*.repository.ts). - Centralize tipos em
*.models.ts.
Passo 5: identifique componentes reutilizáveis e mova para shared
- Se um componente é usado em 2+ features e não depende de modelos específicos, mova para
shared/ui. - Se depende de modelos/fluxos de uma feature, mantenha em
features/<feature>/ui.
Passo 6: padronize nomes e contratos
- Renomeie arquivos para sufixos consistentes (
.facade.ts,.repository.ts). - Garanta que a UI não importe diretamente
apiourepository; ela deve falar com afacade.
Como evitar acoplamento (regras práticas)
| Situação | Evite | Prefira |
|---|---|---|
| Page precisa carregar dados | Page chamando HttpClient ou ApiService direto | Page chama Facade |
| Componente shared precisa de dados | Importar Order (feature) em shared | Inputs genéricos (ex.: title, message) |
| Regras de transformação | Mapear DTO na Page | Mapear no Repository |
| Dependências cruzadas | features/orders importando features/profile | Extrair contrato para shared ou criar serviço em core |
Organização de “core” para infraestrutura
Use core para itens que são globais e não pertencem a uma feature:
- Config:
app-config.ts(URLs, flags, chaves). - Storage: um
StorageServicecom métodos genéricos (get/set/remove) e chaves centralizadas. - HTTP: cliente/adapter, mapeamento de erros, helpers de retry/backoff (quando aplicável).
- Auth base: gerenciamento de token, sessão e utilitários de autenticação usados por várias features.
Regra: se algo é usado por várias features e não é UI, provavelmente é core.
Testabilidade e manutenção: como a estrutura ajuda
Com UI enxuta e dependências bem definidas, fica mais simples testar:
- Repository: teste mapeamentos (DTO → Model) e regras de dados isoladamente.
- Facade: teste fluxos (loading/erro/sucesso) mockando o repository.
- UI: teste renderização e interação sem precisar conhecer detalhes de API.
Além disso, mudanças de API tendem a afetar apenas data-access, enquanto mudanças de layout afetam ui/pages, reduzindo efeitos colaterais.