Angular

Escopo: TypeScript. Guia baseado em Angular 21 com Standalone API e Signals.

Angular é um framework completo: roteamento, injeção de dependência, formulários reativos e comunicação HTTP (HyperText Transfer Protocol · Protocolo de Transferência de Hipertexto) fazem parte do core. Este guia mostra como implementar os contratos de operation-flow.md e frontend-flow.md com Angular moderno.

Conceitos fundamentais

ConceitoO que é
Signal (sinal reativo)Valor reativo do Angular: notifica dependentes quando muda, sem RxJS para estado local
Computed (derivado)Signal calculado a partir de outros signals: recalcula automaticamente quando os inputs mudam
Effect (efeito colateral)Função que executa quando um Signal muda: sincronização com sistemas externos
Standalone Component (componente autônomo)Componente sem NgModule: declara suas dependências diretamente em imports
DI (Dependency Injection · Injeção de Dependência)Mecanismo do Angular que fornece dependências via inject() sem instanciação direta
Smart Component (componente inteligente)Componente que orquestra dados e estado; delega renderização a Dumb Components
Dumb Component (componente de apresentação)Componente que recebe dados via @Input() e emite eventos via @Output(); sem lógica de negócio
Guard (proteção de rota)Verificação de autorização executada durante a resolução da rota, antes de qualquer componente montar
Resolver (carregador de dados)Busca os dados da rota durante a resolução, antes do componente montar

Fluxo de Operação

URL → app.routes.ts → Guard → Resolver → Smart Component → Service → Interceptor → HttpClient → API

PassoO que fazDomínio
URLNavegação do usuário inicia a resolução da rotanavegador
app.routes.tsMapeia a URL para guard, resolver e componente destinocore/
GuardVerifica autenticação ou papel; redireciona se não autorizado, antes de qualquer componente montarcore/
ResolverBusca os dados da rota; o componente recebe dados prontos, sem estado de loading internofeatures/
Smart ComponentOrquestra estado com signals; delega renderização a Dumb Components via @Input()features/
ServiceEncapsula a lógica de acesso HTTP e retorna Observable<T>features/
InterceptorProcessa todas as requisições (injeta token) e respostas (trata 401, 500) de forma centralizadacore/
HttpClientInjectable do framework: configurado em app.config.ts, injetado nos Services via inject(HttpClient)core/ · features/
APIO limite entre o frontend e o backendbackend

Estrutura de pastas

Angular deixa a estrutura de pastas por sua conta, e o roteamento é declarado em código, dentro de app.routes.ts. Como o caminho da URL não depende de onde o arquivo está, as pastas ficam livres para agrupar por funcionalidade: cada fatia reúne as páginas, os componentes, os services e os resolvers do mesmo assunto. Guards e interceptors ficam em core/, porque são infraestrutura que atende todas as fatias.

src/app/
├── features/
│   └── orders/                              ← slice: tudo relativo a orders
│       ├── pages/order-detail.page.tsSmart Component: orquestra dados e estado
│       ├── components/order-list.component.tsDumb Component: @Input(), @Output(), sem lógica
│       ├── services/order.service.tsInjectable: HttpClient, retorna Observable<T>
│       └── resolvers/order-detail.resolver.tsResolveFn: busca dados antes do componente montar
├── core/
│   ├── guards/
│   │   ├── auth.guard.tsCanActivateFn: verifica autenticação
│   │   └── role.guard.tsCanActivateFn: verifica papel do usuário
│   ├── interceptors/
│   │   ├── auth.interceptor.tsHttpInterceptorFn: injeta token nas requisições
│   │   └── error.interceptor.tsHttpInterceptorFn: trata 401 e 500 globalmente
│   ├── app.routes.ts                        → rotas: guards, resolvers, lazy load
│   └── app.config.ts                        → providers: HttpClient, interceptors

Componentes standalone

O componente standalone é o padrão. Ele declara em imports as dependências que usa, e dispensa o NgModule, que era um segundo arquivo por onde cada componente precisava passar para existir. Com ele, a lista do que o componente usa fica no próprio componente, e não em um registro à parte que ninguém lembra de atualizar.

❌ Ruim: o componente precisa ser registrado em um NgModule
@NgModule({
  declarations: [UserCardComponent],
  imports: [CommonModule],
  exports: [UserCardComponent],
})
export class UserCardModule {}

@Component({
  selector: "app-user-card",
  templateUrl: "./user-card.component.html",
})
export class UserCardComponent {
  @Input() user!: User;
}
✅ Bom: standalone com imports e @Input({ required: true })
import { Component, input } from "@angular/core";
import { CurrencyPipe } from "@angular/common";

@Component({
  selector: "app-user-card",
  standalone: true,
  imports: [CurrencyPipe],
  template: `
    <div class="user-card">
      <h2>{{ user().name }}</h2>
      <p>{{ user().balance | currency }}</p>
    </div>
  `,
})

export class UserCardComponent {
  readonly user = input.required<User>();
}

Signals: o estado local do componente

Para o estado que vive dentro de um componente, o Signal substitui o BehaviorSubject do RxJS. A diferença prática está no que ele dispensa: a leitura é direta, sem subscribe, e sem o cancelamento que todo subscribe exige quando o componente é destruído, sob pena de vazar memória.

A divisão é esta: signal() guarda um valor que muda, computed() calcula um valor a partir de outros signals, e effect() reage a uma mudança para falar com o mundo de fora (o DOM, o localStorage, o analytics). Usar effect() para manter dois signals em sincronia é reescrever à mão o que o computed() faz sozinho.

❌ Ruim: BehaviorSubject para um estado local simples
@Component({ /* ... */ })
export class CartComponent implements OnInit, OnDestroy {
  private readonly itemsSubject = new BehaviorSubject<CartItem[]>([]);
  readonly items$ = this.itemsSubject.asObservable();
  readonly total$ = this.items$.pipe(
    map(items => items.reduce((sum, item) => sum + item.price * item.qty, 0))
  );

  private readonly destroy$ = new Subject<void>();

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}
✅ Bom: signal e computed para estado local
import { Component, signal, computed } from "@angular/core";
import { CurrencyPipe } from "@angular/common";

@Component({
  selector: "app-cart",
  standalone: true,
  imports: [CurrencyPipe],
  template: `
    <p>Total: {{ total() | currency }}</p>
    <button (click)="clearCart()">Limpar</button>
  `,
})

export class CartComponent {
  readonly items = signal<CartItem[]>([]);
  readonly total = computed(() =>
    this.items().reduce((sum, item) => sum + item.price * item.qty, 0)
  );

  addItem(item: CartItem): void {
    this.items.update(current => [...current, item]);
  }

  clearCart(): void {
    this.items.set([]);
  }
}

Smart e Dumb Components

O pipeline de operation-flow.md se mapeia diretamente: Smart Component chama o Service, que chama o HttpClient. Dumb Component recebe dados por @Input() e emite eventos por @Output().

Fluxo: Smart → @Input() → Dumb → @Output() → Smart

❌ Ruim: o componente de lista carrega a regra de negócio dentro dele
@Component({
  selector: "app-order-list",
  standalone: true,
  template: `...`,
})
export class OrderListComponent implements OnInit {
  orders: Order[] = [];

  constructor(private orderService: OrderService) {}

  ngOnInit(): void {
    this.orderService.listOrders().subscribe(orders => {
      this.orders = orders.filter(o => o.status !== "cancelled");
    });
  }

  cancelOrder(id: string): void {
    this.orderService.cancel(id).subscribe(() => {
      this.orders = this.orders.filter(o => o.id !== id);
    });
  }
}
✅ Bom: Smart orquestra com signals; Dumb apresenta
// Smart Component
import { Component, computed, inject } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { OrderListComponent } from "./order-list.component";

@Component({
  selector: "app-orders-page",
  standalone: true,
  imports: [OrderListComponent],
  template: `
    <app-order-list
      [orders]="activeOrders()"
      (cancel)="handleCancel($event)"
    />
  `,
})

export class OrdersPageComponent {
  private readonly orderService = inject(OrderService);

  private readonly orders$ = this.orderService.listOrders();
  readonly orders = toSignal(this.orders$, { initialValue: [] });
  readonly activeOrders = computed(() =>
    this.orders().filter(order => order.status !== "cancelled")
  );

  handleCancel(id: string): void {
    this.orderService.cancel(id).subscribe();
  }
}
// Dumb Component
@Component({
  selector: "app-order-list",
  standalone: true,
  imports: [CurrencyPipe],
  template: `
    <ul>
      @for (order of orders(); track order.id) {
        <li>
          {{ order.id }}: {{ order.total | currency }}
          <button (click)="cancel.emit(order.id)">Cancelar</button>
        </li>
      }
    </ul>
  `,
})

export class OrderListComponent {
  readonly orders = input.required<Order[]>();
  readonly cancel = output<string>();
}

Services e injeção de dependência

O Service guarda a regra de negócio e o acesso a dados, e recebe suas dependências por inject(). A função dispensa o construtor cheio de parâmetros e funciona também fora de classes, em guards e resolvers escritos como função. Todo método público declara o tipo de retorno.

providedIn: "root" cria uma instância única para a aplicação inteira, e é o padrão. Um escopo menor só se justifica quando o Service guarda estado que precisa morrer junto com a tela.

❌ Ruim: dependências pelo construtor, e o retorno sem tipo declarado
@Injectable({ providedIn: "root" })
export class OrderService {
  constructor(private http: HttpClient, private auth: AuthService) {}

  getOrders() {
    return this.http.get("/api/orders");
  }
}
✅ Bom: inject(), return type explícito, tipos genéricos no HttpClient
import { Injectable, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";

@Injectable({ providedIn: "root" })
export class OrderService {
  private readonly http = inject(HttpClient);

  listOrders(): Observable<Order[]> {
    const orders = this.http.get<Order[]>("/api/orders");
    return orders;
  }

  findById(id: string): Observable<Order> {
    const order = this.http.get<Order>(`/api/orders/${id}`);
    return order;
  }

  cancel(id: string): Observable<void> {
    const cancellation = this.http.delete<void>(`/api/orders/${id}`);
    return cancellation;
  }
}

A checagem de acesso mora na rota, antes de qualquer render

A verificação de permissão fica na definição da rota, e roda antes de o componente montar, como manda o frontend-flow.md.

Feita no ngOnInit, ela chega tarde. O componente já montou, o template já foi pintado, e o redirecionamento acontece depois. Nesse intervalo o conteúdo restrito aparece na tela para quem não deveria vê-lo.

Rotas que exigem o mesmo papel ficam agrupadas sob um guard só, no nó pai. Ele roda uma vez para o grupo inteiro, e não a cada rota filha.

❌ Ruim: a checagem no ngOnInit, depois de o componente já ter montado
@Component({ /* ... */ })
export class DashboardComponent implements OnInit {
  private readonly auth = inject(AuthService);
  private readonly router = inject(Router);

  ngOnInit(): void {
    if (!this.auth.isAuthenticated()) {
      this.router.navigate(["/login"]);
    }
  }
}
✅ Bom: CanActivateFn na definição da rota
// guards/auth.guard.ts
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";

export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthService);
  const router = inject(Router);

  if (!auth.isAuthenticated()) {
    const loginUrl = router.createUrlTree(["/login"]);
    return loginUrl;
  }

  const accessGranted = true;
  return accessGranted;
};

// guards/role.guard.ts
export function roleGuard(role: UserRole): CanActivateFn {
  return () => {
    const auth = inject(AuthService);
    const router = inject(Router);

    if (!auth.hasRole(role)) {
      const forbiddenUrl = router.createUrlTree(["/forbidden"]);
      return forbiddenUrl;
    }

    const accessGranted = true;
    return accessGranted;
  };
}
// app.routes.ts
export const routes: Routes = [
  {
    path: "dashboard",
    canActivate: [authGuard],
    children: [
      {
        path: "admin",
        canActivate: [roleGuard("admin")],
        children: [
          { path: "users", component: UsersPageComponent },
        ],
      },
      { path: "settings", component: SettingsPageComponent },
    ],
  },
];

O Resolver busca os dados antes de o componente montar

O Resolver cumpre o papel do Loader de frontend-flow.md: ele busca os dados durante a resolução da rota, e o componente monta com o dado já em mãos, recebido pelo ActivatedRoute.

A alternativa, buscar no ngOnInit, obriga o componente a nascer vazio e a lidar com o intervalo em que o dado ainda não chegou. Aparece o estado de carregamento, aparece o ?. em toda propriedade, e a tela pisca entre o esqueleto e o conteúdo.

❌ Ruim: a busca no ngOnInit, e o componente monta sem dado nenhum
@Component({ /* ... */ })
export class OrderDetailComponent implements OnInit {
  order: Order | null = null;

  private readonly route = inject(ActivatedRoute);
  private readonly orderService = inject(OrderService);

  ngOnInit(): void {
    const id = this.route.snapshot.paramMap.get("id")!;
    this.orderService.findById(id).subscribe(order => {
      this.order = order;
    });
  }
}
✅ Bom: ResolveFn na rota, componente recebe dados prontos via signal
// resolvers/order-detail.resolver.ts
import { inject } from "@angular/core";
import { ResolveFn } from "@angular/router";

export const orderDetailResolver: ResolveFn<Order> = (route) => {
  const id = route.paramMap.get("id")!;
  const orderService = inject(OrderService);
  const order = orderService.findById(id);
  return order;
};
// app.routes.ts
{
  path: "orders/:id",
  component: OrderDetailPageComponent,
  resolve: { order: orderDetailResolver },
}
// pages/order-detail.page.ts
import { Component, inject } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { toSignal } from "@angular/core/rxjs-interop";
import { map } from "rxjs";

@Component({
  selector: "app-order-detail-page",
  standalone: true,
  template: `
    @if (order()) {
      <app-order-detail [order]="order()!" />
    }
  `,
})

export class OrderDetailPageComponent {
  private readonly route = inject(ActivatedRoute);

  private readonly order$ = this.route.data.pipe(
    map(data => data["order"] as Order)
  );

  readonly order = toSignal(this.order$);
}

Formulários reativos com tipo

FormGroup e FormControl aceitam tipo, e o FormBuilder os monta já tipados. O ganho aparece no acesso: form.controls.email.value é uma string, enquanto form.get("email")?.value devolve um valor sem tipo, e ainda aceita um nome de campo que não existe, sem erro de compilação.

As duas validações convivem, e cada uma tem seu papel. O Validators do Angular cuida da experiência do usuário e avisa enquanto ele digita. O schema Zod valida no limite com o servidor, antes da chamada de API, porque a checagem do navegador é conveniência, e conveniência pode ser contornada. Os dois rodam sempre, como manda o frontend-flow.md.

❌ Ruim: o FormGroup não tem tipo, e os campos são acessados por texto
@Component({ /* ... */ })
export class LoginFormComponent {
  readonly form = new FormGroup({
    email: new FormControl(""),
    password: new FormControl(""),
  });

  submit(): void {
    const email = this.form.get("email")?.value; // string | null | undefined
    const password = this.form.get("password")?.value;
  }
}
✅ Bom: FormBuilder tipado, acesso direto aos controls, fieldset disabled
import { Component, inject, signal } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";

@Component({
  selector: "app-login-form",
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">
      <fieldset [disabled]="isSubmitting()">
        <input formControlName="email" type="email" aria-describedby="email-error" />
        @if (form.controls.email.invalid && form.controls.email.touched) {
          <span id="email-error">E-mail inválido.</span>
        }

        <input formControlName="password" type="password" />

        @if (formError()) {
          <p role="alert">{{ formError() }}</p>
        }

        <button type="submit">
          {{ isSubmitting() ? "Entrando..." : "Entrar" }}
        </button>
      </fieldset>
    </form>
  `,
})

export class LoginFormComponent {
  private readonly formBuilder = inject(FormBuilder);
  private readonly authService = inject(AuthService);

  readonly isSubmitting = signal(false);
  readonly formError = signal<string | null>(null);

  readonly form = this.formBuilder.group({
    email: ["", [Validators.required, Validators.email]],
    password: ["", [Validators.required, Validators.minLength(8)]],
  });

  submit(): void {
    if (this.form.invalid) return;

    const { email, password } = this.form.getRawValue();
    const credentials = { email: email!, password: password! };

    this.isSubmitting.set(true);
    this.formError.set(null);

    this.authService.login(credentials).subscribe({
      next: () => this.isSubmitting.set(false),
      error: () => {
        this.formError.set("E-mail ou senha inválidos.");
        this.isSubmitting.set(false);
      },
    });
  }
}

O Interceptor trata o que vale para toda requisição HTTP

O Interceptor (HttpInterceptorFn) fica no caminho de todas as requisições que saem e de todas as respostas que voltam. É o lugar do que vale para todas elas: pendurar o token de autenticação, tratar o 401 que expirou a sessão, decidir o que fazer com o 500.

Sem ele, cada Service repete as mesmas linhas, e a regra passa a existir em vinte lugares. Basta um Service novo esquecer o token para uma tela quebrar, e o motivo não vai estar no Service que quebrou.

Fluxo: Service → Interceptor (auth) → Interceptor (error) → HttpClient → API

❌ Ruim: cada service pendura o token por conta própria
@Injectable({ providedIn: "root" })
export class OrderService {
  private readonly http = inject(HttpClient);
  private readonly auth = inject(AuthService);

  listOrders(): Observable<Order[]> {
    const token = this.auth.getToken();
    const headers = { Authorization: `Bearer ${token}` };
    const orders = this.http.get<Order[]>("/api/orders", { headers });

    return orders;
  }
}
✅ Bom: auth interceptor centraliza o token em todas as requisições
// interceptors/auth.interceptor.ts
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from "@angular/common/http";
import { inject } from "@angular/core";

export const authInterceptor: HttpInterceptorFn = (
  request: HttpRequest<unknown>,
  next: HttpHandlerFn
) => {
  const auth = inject(AuthService);
  const token = auth.getToken();

  if (!token) {
    const unauthenticatedResponse = next(request);
    return unauthenticatedResponse;
  }

  const authenticatedRequest = request.clone({
    headers: request.headers.set("Authorization", `Bearer ${token}`),
  });

  const authenticatedResponse = next(authenticatedRequest);
  return authenticatedResponse;
};
// app.config.ts
import { provideHttpClient, withInterceptors } from "@angular/common/http";

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
};
✅ Bom: error interceptor trata 401 e 500 globalmente
// interceptors/error.interceptor.ts
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn, HttpErrorResponse } from "@angular/common/http";
import { inject } from "@angular/core";
import { catchError, throwError } from "rxjs";

export const errorInterceptor: HttpInterceptorFn = (
  request: HttpRequest<unknown>,
  next: HttpHandlerFn
) => {
  const auth = inject(AuthService);
  const router = inject(Router);

  return next(request).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401) {
        auth.clearSession();
        router.navigate(["/login"]);
      }

      return throwError(() => error);
    })
  );
};

DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.