BFF no NestJS: só DTOs, sem entities por favor



This content originally appeared on DEV Community and was authored by Jackson Smith

Gancho: Se o seu BFF tem uma camada de Entity, talvez ele já não seja mais um BFF.

1. Introdução

Se o seu BFF tem uma camada de Entity, talvez ele já não seja mais um BFF.

A arquitetura em camadas é um padrão de design clássico que organiza o código em responsabilidades distintas. No NestJS, essa abordagem é a espinha dorsal do framework, onde:

  • Controllers lidam com as requisições HTTP e a comunicação com o cliente.
  • Services concentram a lógica de negócio e as interações com outros serviços e APIs externas.
  • Repositories cuidam da persistência, manipulando as Entities, que representam o modelo de domínio.

O BFF (Backend for Frontend), no entanto, subverte essa regra. Ele não deve ter um domínio de negócio nem uma camada de persistência própria. Seu papel é apenas orquestrar serviços e expor contratos (DTOs) limpos e otimizados para cada cliente (web, mobile, desktop). Se o seu BFF contém Entities, ORMs ou migrations, ele está fazendo o trabalho errado.

2. O problema comum

Times transformam o BFF num serviço de domínio e isso gera:

  • Duplicação de regras que já existem nos microsserviços.
  • Acoplamento entre mudanças de domínio e frontends.
  • Complexidade (migrations, modelos, testes difíceis).

O resultado: BFF pesado e difícil de evoluir.

3. O papel real do BFF

O BFF não é sobre modelagem de entidades; ele é um adaptador e orquestrador que opera na fronteira entre os serviços de domínio e a interface do cliente.

Suas funções essenciais são:

  • Orquestração de chamadas: Em vez de o cliente fazer várias chamadas (uma para autenticação, outra para perfil, outra para feature flags), o BFF centraliza a lógica, faz todas as chamadas necessárias e compõe a resposta em um único lugar.
  • Adaptação de contratos: O BFF traduz os contratos dos serviços de domínio para um formato que seja mais conveniente para o cliente. Por exemplo, ele pode converter um name, surname e photo_url vindos de um serviço de perfil em um único objeto user com fullName e pictureUrl para a tela do aplicativo.
  • Composição de DTOs: Este é o coração do BFF. Ele coleta dados de diferentes fontes, como um serviço de Pedidos e um de Produtos, e os junta para criar um OrderDetailsDto otimizado para a exibição no cliente, sem implementar as regras de negócio de cada serviço.

4. Por que usar só DTOs?

No BFF, DTO = contrato. Benefícios:

  • Leveza: sem regras de domínio, sem ORM.
  • Flexibilidade: contratos de resposta diferentes por cliente.
  • Segurança: evita vazar modelos internos.

5. Tipos de DTO que um BFF costuma ter

  1. Controller Input DTO — valida/transforma payload do cliente.
  2. Service Request/Response DTO — como o BFF fala com microserviços.
  3. Aggregate / Composition DTO — junção de várias respostas.
  4. Client Response DTO — contrato final entregue ao cliente.
  5. Error DTO — padroniza erros para o cliente.

Regra: atravesse fronteiras com DTOs. Use pipes e interceptors do NestJS para validar/serializar automaticamente.

6. Exemplo prático — Login (sem mappers explícitos)

Cenário: cliente envia email e password. O BFF:

  1. chama Auth → recebe accessToken, refreshToken, userId;
  2. busca Profile do usuário;
  3. consulta Feature Flags;
  4. compõe e retorna a resposta para o cliente.

6.1 Estrutura sugerida

src/
  auth/
    auth.controller.ts
    auth.orchestrator.service.ts
    dtos/
      login-request.dto.ts         # Controller Input DTO
      auth-service.dto.ts          # Service Request/Response DTO (Auth)
      profile-service.dto.ts       # Service Response DTO (Profile)
      flags-service.dto.ts         # Service Response DTO (Flags)
      login-response.dto.ts        # Client Response DTO
      api-error.dto.ts             # Error DTO
  shared/
    http/http.module.ts            # HttpModule com timeout/retries
main.ts                            # ValidationPipe global

6.2 Configuração global (Validation + transformação)

No main.ts do Nest, habilite validação e transformação globalmente:

// main.ts
import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true, // remove campos não anotados
      transform: true, // transforma payloads para classes DTO
      forbidNonWhitelisted: false,
    })
  );
  await app.listen(3000);
}
bootstrap();

6.3 DTOs (com comentário sobre o tipo)

// dtos/login-request.dto.ts  (Controller Input DTO)
import { IsEmail, IsOptional, IsString, MinLength } from "class-validator";

export class LoginRequestDto {
  @IsEmail()
  email!: string;

  @IsString()
  @MinLength(6)
  password!: string;

  @IsOptional()
  @IsString()
  deviceId?: string;
}
// dtos/auth-service.dto.ts  (Service Request DTO)
import { IsString, IsNumber } from "class-validator";

export class AuthServiceRequestDto {
  @IsString()
  email!: string;

  @IsString()
  password!: string;

  @IsOptional()
  @IsString()
  deviceId?: string;
}

// dtos/auth-service.dto.ts  (Service Response DTO)
export class AuthServiceResponseDto {
  @IsString()
  userId!: string;

  @IsString()
  accessToken!: string;

  @IsString()
  refreshToken!: string;

  @IsNumber()
  expiresIn!: number; // segundos
}
// dtos/profile-service.dto.ts  (Service Response DTO)
import { IsString, IsUrl, IsOptional } from "class-validator";

export class ProfileServiceResponseDto {
  @IsString()
  userId!: string;

  @IsString()
  name!: string;

  @IsOptional()
  @IsUrl()
  pictureUrl?: string;
}
// dtos/flags-service.dto.ts  (Service Response DTO)
import { IsObject } from "class-validator";

export class FlagsServiceResponseDto {
  @IsObject()
  flags!: Record<string, boolean>;
}
// dtos/login-response.dto.ts  (Client Response DTO)
import {
  IsString,
  IsObject,
  ValidateNested,
  IsOptional,
} from "class-validator";
import { Type, Exclude } from "class-transformer";

class UserDto {
  @IsString()
  id!: string;

  @IsString()
  name!: string;

  @IsOptional()
  @IsString()
  pictureUrl?: string;
}

class SessionDto {
  @IsString()
  accessToken!: string;

  @IsString()
  refreshToken!: string;

  @IsString()
  expiresAt!: string; // ISO 8601
}

export class LoginResponseDto {
  @Type(() => UserDto)
  @ValidateNested()
  user!: UserDto;

  @Type(() => SessionDto)
  @ValidateNested()
  session!: SessionDto;

  @IsObject()
  features!: Record<string, boolean>;
}
// dtos/api-error.dto.ts  (Error DTO)
import { IsString, IsOptional } from "class-validator";

export class ApiErrorDto {
  @IsString()
  code!: string; // EX: AUTH_INVALID_CREDENTIALS

  @IsString()
  message!: string;

  @IsOptional()
  details?: unknown;
}

6.4 Orquestração com mapeamento automático

No serviço de orquestração, combine respostas e use plainToInstance do class-transformer para transformar o objeto final em LoginResponseDto. Não é necessário criar funções de mapeamento separadas.

// auth.orchestrator.service.ts
import { Injectable, Logger } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { firstValueFrom, timeout } from "rxjs";
import { ConfigService } from "@nestjs/config";
import { LoginRequestDto } from "./dtos/login-request.dto";
import { AuthServiceResponse } from "./dtos/auth-service.dto";
import { ProfileServiceResponse } from "./dtos/profile-service.dto";
import { FlagsServiceResponse } from "./dtos/flags-service.dto";
import { LoginResponseDto } from "./dtos/login-response.dto";
import { plainToInstance } from "class-transformer";

@Injectable()
export class AuthOrchestratorService {
  private readonly logger = new Logger(AuthOrchestratorService.name);

  constructor(
    private readonly http: HttpService,
    private readonly config: ConfigService
  ) {}

  async login(payload: LoginRequestDto): Promise<LoginResponseDto> {
    const AUTH_URL = this.config.get<string>("SERVICES_AUTH_URL")!;
    const PROFILE_URL = this.config.get<string>("SERVICES_PROFILE_URL")!;
    const FLAGS_URL = this.config.get<string>("SERVICES_FLAGS_URL")!;

    // 1) Auth
    const auth$ = this.http
      .post<AuthServiceResponse>(`${AUTH_URL}/v1/login`, {
        email: payload.email,
        password: payload.password,
        deviceId: payload.deviceId,
      })
      .pipe(timeout({ each: 3000 }));
    const auth = (await firstValueFrom(auth$)).data;

    // 2) Profile e 3) Flags em paralelo
    const profile$ = this.http
      .get<ProfileServiceResponse>(`${PROFILE_URL}/v1/users/${auth.userId}`)
      .pipe(timeout({ each: 3000 }));
    const flags$ = this.http
      .get<FlagsServiceResponse>(`${FLAGS_URL}/v1/users/${auth.userId}/flags`)
      .pipe(timeout({ each: 3000 }));

    const [profileRes, flagsRes] = await Promise.all([
      firstValueFrom(profile$),
      firstValueFrom(flags$),
    ]);

    // Compondo o objeto final (sem funções de mapeamento separadas)
    const expiresAt = new Date(
      Date.now() + auth.expiresIn * 1000
    ).toISOString();

    const plain = {
      user: {
        id: auth.userId,
        name: profileRes.data.name,
        pictureUrl: profileRes.data.pictureUrl,
      },
      session: {
        accessToken: auth.accessToken,
        refreshToken: auth.refreshToken,
        expiresAt,
      },
      features: flagsRes.data.flags ?? {},
    };

    // Transforme em DTO (class-transformer) — útil se você usar @Expose/@Exclude
    return plainToInstance(LoginResponseDto, plain);
  }
}

6.5 Controller (validação automática e serialização)

No controller, use ValidationPipe (global) para transformar LoginRequestDto automaticamente. Para serializar a resposta com class-transformer, habilite ClassSerializerInterceptor ou devolva instâncias DTO (como acima).

// auth.controller.ts
import {
  Body,
  Controller,
  HttpCode,
  HttpStatus,
  Post,
  UseInterceptors,
  ClassSerializerInterceptor,
} from "@nestjs/common";
import {
  ApiBadRequestResponse,
  ApiOkResponse,
  ApiOperation,
  ApiTags,
} from "@nestjs/swagger";
import { AuthOrchestratorService } from "./auth.orchestrator.service";
import { LoginRequestDto } from "./dtos/login-request.dto";
import { LoginResponseDto } from "./dtos/login-response.dto";
import { ApiErrorDto } from "./dtos/api-error.dto";

@ApiTags("Auth (BFF)")
@Controller("bff/auth")
@UseInterceptors(ClassSerializerInterceptor)
export class AuthController {
  constructor(private readonly service: AuthOrchestratorService) {}

  @Post("login")
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: "Login (orquestra Auth + Profile + Flags)" })
  @ApiOkResponse({ type: LoginResponseDto })
  @ApiBadRequestResponse({ type: ApiErrorDto })
  async login(@Body() dto: LoginRequestDto): Promise<LoginResponseDto> {
    return this.service.login(dto);
  }
}

6.6 Exemplo de request / response

Request

POST /bff/auth/login
{
  "email": "ana@exemplo.com",
  "password": "minhasenha",
  "deviceId": "ios-14-pro-123"
}

Response

{
  "user": {
    "id": "u_123",
    "name": "Ana Silva",
    "pictureUrl": "https://cdn.exemplo.com/u_123.png"
  },
  "session": {
    "accessToken": "eyJhbGciOi...",
    "refreshToken": "eyJhbGciOi...",
    "expiresAt": "2025-09-01T12:34:56.000Z"
  },
  "features": {
    "checkout_new_ui": true,
    "beta_reader": false
  }
}

Observação: perceba que não há Entities nem Repositories. Apenas DTOs, orquestração e transformação automática.

7. Anti‑padrões (o que evitar)

  • Entities/ORM dentro do BFF. Se precisa de migrations, está no lugar errado.
  • Regras de domínio no BFF — mantenha no serviço responsável.
  • Persistir estado de domínio no BFF. Tokens efêmeros OK; estado durável não.
  • Expor DTOs internos dos serviços diretamente ao cliente — sempre adapte.

8. O que pode existir no BFF sem virar domínio

  • Cache efêmero (TTL curto) para performance.
  • Timeouts / retries / circuit-breaker nas integrações.
  • Rate limiting, observabilidade, logs e tracing.
  • Formatação/enrich de resposta para o cliente.

Regra de ouro: qualquer estado que descreva o negócio e sobreviva a reinícios deve ir para o serviço de domínio.

9. Testes essenciais

  • Unitários: validate/transform (DTOs) e lógica de orquestração básica.
  • Contract tests (ex.: Pact) entre BFF e microsserviços.
  • E2E focado no contrato com o cliente (payload e erros).

No próximo artigo posso detalhar um guia de testes com exemplos práticos — se lhe interessar, me diga qual abordagem prefere (Pact, Pactflow, sinon/axios mocks, etc.).

10. Conclusão

Um BFF bem feito é leve, previsível e voltado ao cliente. Ele não é um “monólito distribuído” nem um serviço de domínio com outro nome. Ao manter o foco em DTOs, orquestração e composição, você garante que a lógica de negócio viva onde deve — no domínio — enquanto o BFF se mantém como uma ferramenta ágil e flexível para entregar valor de forma rápida e segura aos frontends.

Em resumo, um BFF de sucesso é aquele que conhece o seu lugar: ele não é uma camada de persistência, mas sim a camada de experiência do cliente, projetada para servir e adaptar-se, mantendo a arquitetura de microsserviços limpa e evoluindo de forma independente.

11. Referências


This content originally appeared on DEV Community and was authored by Jackson Smith