Ir para o conteúdo
Backend

Engenharia Backend com Node.js e TypeScript: o Guia Definitivo para 2026

Marcos Soares
Atualizado em 
40 minutos de leitura
Ilustracao 3D de torre servidor em camadas de vidro translucido com luz esmeralda representando engenharia backend Node.js
Ouça este artigo
0:00Engenharia Backend com Node.js e TypeScript: o Guia Definitivo para 2026--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

Node.js com TypeScript permite organizar APIs com contratos tipados, operações assíncronas e acesso a dados com Prisma. Este guia discute escolhas de arquitetura, segurança e desempenho; os exemplos precisam ser adaptados ao schema e ao ambiente de cada aplicação.

A seção de diagnóstico de N+1 foi revisada para Prisma 6.19.2. Ela usa Client Extensions e separa as leituras por contexto de requisição, sem depender de APIs removidas ou de campos internos do motor.

Se você está começando no backend: leia primeiro as duas próximas seções, sobre por que o Node.js ainda domina e como se organiza um backend profissional, e depois siga a trilha Backend com Node.js e TypeScript: da primeira API ao deploy, que ordena oito artigos do modelo de execução do JavaScript até o Docker em produção. As seções de pooling, cache e observabilidade fazem mais sentido depois da sua primeira API no ar.

Se você já mantém um backend em produção: as seções que mais rendem aqui são as de connection pooling, caching estratégico, observabilidade e error handling com Result Pattern. O restante revisa fundamentos que você provavelmente já aplica no dia a dia.

Por Que Node.js Ainda Domina o Backend em 2026

Vamos começar com uma verdade inconveniente: a maioria dos desenvolvedores escolhe Node.js pelos motivos errados. "Ah, é JavaScript, já conheço", "É rápido para prototipar", "Tem muitas bibliotecas no npm". Esses são benefícios secundários.

O verdadeiro poder do Node.js está na sua arquitetura event-driven e no modelo de I/O não-bloqueante. Para aplicações web modernas, onde o gargalo raramente é CPU intensiva mas sim I/O (banco de dados, APIs externas, file system), Node.js brilha de forma única.

Para avaliar esse tipo de aplicação, registre latência p50/p95, volume de requisições, número de consultas e recursos consumidos no mesmo intervalo. Contagens de entidades ou modelos, isoladamente, não comprovam desempenho nem capacidade de escala.

O Ecossistema Amadureceu

Em 2026, temos ferramentas que resolveram os principais pain points históricos do Node.js:

TypeScript: Não é mais opcional. É padrão da indústria. Trouxe type safety sem sacrificar a flexibilidade do JavaScript.

Prisma: Cliente tipado para consultas e relações. A estratégia de consultas, os índices e a gestão do schema continuam sendo decisões da aplicação.

Fastify: Performance superior ao Express com developer experience ainda melhor.

Pino: Structured logging que não mata performance.

Node.js 22+: WebStreams nativas, melhorias no garbage collector e no tempo de inicialização; meça no seu app com node --cpu-prof antes de citar um número.

Arquitetura de um Backend Node.js Profissional

Arquitetura de um backend Node.js é a estrutura de camadas, contratos e abstrações que define como dados trafegam entre clientes, aplicação e persistência. Em sistemas profissionais, essa estrutura determina escalabilidade, segurança e velocidade de evolução do código.

A arquitetura é onde separamos os amadores dos profissionais. Não estou falando de over-engineering com 15 camadas de abstração. Estou falando de uma estrutura que permite evolução, testing e debugging eficientes.

Para dominar os patterns que sustentam essa arquitetura (Strategy, Factory, Observer, Decorator), veja nosso guia completo sobre Design Patterns que todo dev sênior deveria dominar em TypeScript.

MERMAID
graph TB
    Client[Client Applications] --> LB[Load Balancer]
    LB --> API1[API Instance 1]
    LB --> API2[API Instance 2]
    LB --> API3[API Instance N]
    
    API1 --> Auth[Auth Service]
    API1 --> Cache[Redis Cache]
    API1 --> Queue[Job Queue]
    API1 --> DB[(PostgreSQL)]
    
    Auth --> AuthDB[(Auth Database)]
    Queue --> Worker1[Background Worker 1]
    Queue --> Worker2[Background Worker N]
    
    API1 --> External[External APIs]
    API1 --> Storage[File Storage]
    
    subgraph "Monitoring"
        Logs[Structured Logs]
        Metrics[Metrics Collection]
        Traces[Distributed Tracing]
    end
    
    API1 --> Logs
    API1 --> Metrics
    API1 --> Traces

Estrutura de Pastas que Escala

Depois de ver dezenas de projetos Node.js crescerem de MVP para sistemas complexos, esta é a estrutura que realmente funciona:

Text
src/
├── application/          # Use cases e business logic
│   ├── services/
│   ├── use-cases/
│   └── interfaces/
├── domain/              # Entidades e regras de negócio
│   ├── entities/
│   ├── value-objects/
│   └── repositories/
├── infrastructure/      # Implementações concretas
│   ├── database/
│   ├── http/
│   ├── cache/
│   └── external-apis/
├── presentation/        # Controllers e middlewares
│   ├── controllers/
│   ├── middlewares/
│   └── validators/
└── shared/             # Utilities e tipos compartilhados
    ├── errors/
    ├── types/
    └── utils/

Esta estrutura segue princípios de Clean Architecture adaptados para a realidade de projetos Node.js. Não é dogma acadêmico; é pragmatismo baseado em experiência.

Configuração Base Bulletproof

TYPESCRIPT
// src/infrastructure/config/index.ts
import { z } from 'zod'
 
const configSchema = z.object({
  NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
  PORT: z.coerce.number().default(3000),
  
  // Database
  DATABASE_URL: z.string().url(),
  DATABASE_POOL_MIN: z.coerce.number().default(2),
  DATABASE_POOL_MAX: z.coerce.number().default(10),
  
  // Redis
  REDIS_URL: z.string().url(),
  REDIS_MAX_RETRIES: z.coerce.number().default(3),
  
  // Auth
  JWT_SECRET: z.string().min(32),
  JWT_EXPIRES_IN: z.string().default('24h'),
  
  // External APIs
  OPENAI_API_KEY: z.string().optional(),
  WEBHOOK_SECRET: z.string(),
  
  // Monitoring
  LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
  SENTRY_DSN: z.string().url().optional(),
})
 
type Config = z.infer<typeof configSchema>
 
class ConfigManager {
  private static instance: Config
  
  static get(): Config {
    if (!this.instance) {
      const result = configSchema.safeParse(process.env)
      
      if (!result.success) {
        console.error('Invalid configuration:', result.error.format())
        process.exit(1)
      }
      
      this.instance = result.data
    }
    
    return this.instance
  }
  
  static isDevelopment(): boolean {
    return this.get().NODE_ENV === 'development'
  }
  
  static isProduction(): boolean {
    return this.get().NODE_ENV === 'production'
  }
}
 
export { ConfigManager }

Validação de configuração não é paranoia. É profissionalismo. Quantas vezes você já viu aplicações quebrando em produção por variáveis de ambiente mal configuradas?

TypeScript Patterns Avançados para APIs Robustas

TypeScript não é só "JavaScript com tipos". Quando usado corretamente, é uma ferramenta poderosa para expressar invariantes de negócio diretamente no sistema de tipos.

Branded Types para Type Safety Real

TYPESCRIPT
// src/domain/value-objects/identifiers.ts
declare const __brand: unique symbol
 
type Brand<T, TBrand> = T & { [__brand]: TBrand }
 
export type UserId = Brand<string, 'UserId'>
export type PostId = Brand<string, 'PostId'>
export type SessionId = Brand<string, 'SessionId'>
 
// Factory functions para criação type-safe
export const UserId = {
  create: (id: string): UserId => {
    if (!id || id.length < 1) {
      throw new Error('Invalid UserId')
    }
    return id as UserId
  },
  
  fromUUID: (uuid: string): UserId => {
    const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
    if (!uuidRegex.test(uuid)) {
      throw new Error('Invalid UUID format for UserId')
    }
    return uuid as UserId
  }
}
 
export const PostId = {
  create: (id: string): PostId => {
    if (!id || id.length < 1) {
      throw new Error('Invalid PostId')
    }
    return id as PostId
  }
}

Discriminated Unions para Estados Complexos

TYPESCRIPT
// src/domain/entities/api-response.ts
export type ApiResponse<T> = 
  | { status: 'success'; data: T; timestamp: Date }
  | { status: 'error'; error: string; code: string; timestamp: Date }
  | { status: 'loading'; progress?: number; timestamp: Date }
 
export type ProcessingJob =
  | { status: 'pending'; queuedAt: Date }
  | { status: 'processing'; startedAt: Date; progress: number }
  | { status: 'completed'; completedAt: Date; result: unknown }
  | { status: 'failed'; failedAt: Date; error: string; retryCount: number }
 
// Type guards para working com unions
export const isSuccess = <T>(response: ApiResponse<T>): response is Extract<ApiResponse<T>, { status: 'success' }> => {
  return response.status === 'success'
}
 
export const isError = <T>(response: ApiResponse<T>): response is Extract<ApiResponse<T>, { status: 'error' }> => {
  return response.status === 'error'
}
 
// Usage em controllers
export class PostController {
  async getPost(req: Request, res: Response): Promise<void> {
    const postId = PostId.create(req.params.id)
    const result = await this.postService.getById(postId)
    
    if (isSuccess(result)) {
      // TypeScript sabe que result.data existe
      res.json(result.data)
    } else if (isError(result)) {
      // TypeScript sabe que result.error existe
      res.status(400).json({ error: result.error, code: result.code })
    }
  }
}

Generic Constraints para Flexibilidade Controlada

TYPESCRIPT
// src/application/interfaces/repository.ts
export interface Entity {
  id: string
  createdAt: Date
  updatedAt: Date
}
 
export interface Repository<T extends Entity> {
  findById(id: string): Promise<T | null>
  findMany(criteria: Partial<T>): Promise<T[]>
  create(data: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<T>
  update(id: string, data: Partial<Omit<T, 'id' | 'createdAt'>>): Promise<T>
  delete(id: string): Promise<void>
}
 
// Implementação concreta
export class PrismaRepository<T extends Entity> implements Repository<T> {
  constructor(
    private readonly model: any, // Prisma model
    private readonly mapper: (raw: any) => T
  ) {}
  
  async findById(id: string): Promise<T | null> {
    const raw = await this.model.findUnique({ where: { id } })
    return raw ? this.mapper(raw) : null
  }
  
  async findMany(criteria: Partial<T>): Promise<T[]> {
    const raw = await this.model.findMany({ where: criteria })
    return raw.map(this.mapper)
  }
  
  // ... outras implementações
}

Autenticação e Autorização: Além do JWT Básico

Autenticação é onde vejo mais gambiarras em produção. JWT jogado na aplicação sem estratégia, sessions mal implementadas, OAuth configurado de qualquer jeito. Vamos fazer direito.

JWT com Refresh Token Strategy

TYPESCRIPT
// src/application/services/auth.service.ts
import jwt from 'jsonwebtoken'
import { randomBytes } from 'crypto'
import { ConfigManager } from '@/infrastructure/config'
 
export interface TokenPayload {
  userId: UserId
  email: string
  roles: string[]
  permissions: string[]
}
 
export interface AuthTokens {
  accessToken: string
  refreshToken: string
  expiresIn: number
}
 
export class AuthService {
  private readonly jwtSecret = ConfigManager.get().JWT_SECRET
  private readonly refreshTokens = new Set<string>() // Em produção: Redis
  
  async generateTokens(payload: TokenPayload): Promise<AuthTokens> {
    const accessToken = jwt.sign(payload, this.jwtSecret, {
      expiresIn: '15m', // Short-lived access token
      issuer: 'vivodecodigo-api',
      audience: 'vivodecodigo-client'
    })
    
    const refreshToken = randomBytes(64).toString('hex')
    
    // Store refresh token (em produção: Redis com TTL)
    this.refreshTokens.add(refreshToken)
    
    return {
      accessToken,
      refreshToken,
      expiresIn: 15 * 60 // 15 minutes
    }
  }
  
  async refreshAccessToken(refreshToken: string): Promise<AuthTokens | null> {
    if (!this.refreshTokens.has(refreshToken)) {
      return null
    }
    
    // Invalidate old refresh token (rotation)
    this.refreshTokens.delete(refreshToken)
    
    // Em produção: buscar user data associada ao refresh token
    const payload: TokenPayload = await this.getUserFromRefreshToken(refreshToken)
    
    return this.generateTokens(payload)
  }
  
  async revokeRefreshToken(refreshToken: string): Promise<void> {
    this.refreshTokens.delete(refreshToken)
  }
  
  verifyAccessToken(token: string): TokenPayload | null {
    try {
      return jwt.verify(token, this.jwtSecret) as TokenPayload
    } catch {
      return null
    }
  }
  
  private async getUserFromRefreshToken(refreshToken: string): Promise<TokenPayload> {
    // Implementação específica para buscar dados do usuário
    // Em produção: query no Redis/Database
    throw new Error('Not implemented')
  }
}

Middleware de Autorização Flexível

TYPESCRIPT
// src/presentation/middlewares/auth.middleware.ts
import { Request, Response, NextFunction } from 'express'
import { AuthService } from '@/application/services/auth.service'
 
export interface AuthenticatedRequest extends Request {
  user: TokenPayload
}
 
export class AuthMiddleware {
  constructor(private readonly authService: AuthService) {}
  
  authenticate() {
    return async (req: Request, res: Response, next: NextFunction) => {
      const authHeader = req.headers.authorization
      
      if (!authHeader?.startsWith('Bearer ')) {
        return res.status(401).json({ error: 'Missing or invalid authorization header' })
      }
      
      const token = authHeader.substring(7)
      const payload = this.authService.verifyAccessToken(token)
      
      if (!payload) {
        return res.status(401).json({ error: 'Invalid or expired token' })
      }
      
      (req as AuthenticatedRequest).user = payload
      next()
    }
  }
  
  requirePermissions(permissions: string[]) {
    return (req: Request, res: Response, next: NextFunction) => {
      const user = (req as AuthenticatedRequest).user
      
      if (!user) {
        return res.status(401).json({ error: 'Authentication required' })
      }
      
      const hasPermission = permissions.every(permission => 
        user.permissions.includes(permission)
      )
      
      if (!hasPermission) {
        return res.status(403).json({ 
          error: 'Insufficient permissions',
          required: permissions,
          current: user.permissions
        })
      }
      
      next()
    }
  }
  
  requireRoles(roles: string[]) {
    return (req: Request, res: Response, next: NextFunction) => {
      const user = (req as AuthenticatedRequest).user
      
      if (!user) {
        return res.status(401).json({ error: 'Authentication required' })
      }
      
      const hasRole = roles.some(role => user.roles.includes(role))
      
      if (!hasRole) {
        return res.status(403).json({ 
          error: 'Insufficient role',
          required: roles,
          current: user.roles
        })
      }
      
      next()
    }
  }
}

A diferença entre JWT e sessions não é técnica, é arquitetural. JWT é stateless, perfeito para microservices e CDNs. Sessions são stateful, melhores para aplicações monolíticas onde você precisa de controle granular sobre sessões ativas.

No Vivo de Código, usamos JWT para a API pública e sessions para o admin dashboard. Cada ferramenta no seu contexto certo.

Connection Pooling e Gerenciamento de Banco de Dados

Database connections são recursos finitos e caros. Mal gerenciados, destroem performance e estabilidade. Bem gerenciados, são invisíveis e confiáveis.

Para um deep dive completo sobre este tópico, veja nosso artigo sobre Como Escalar PostgreSQL com Connection Pooling e PgBouncer.

Configuração Prisma para Produção

No Vivo de Código, o singleton de @/lib/db/prisma usa Prisma 6.19.2 com o driver adapter PostgreSQL. Os limites do pool pertencem à configuração pública de pg.Pool, não a __internal.engine. O objeto abaixo ilustra as opções do pool usado na fábrica do singleton; não crie outro cliente por requisição.

TYPESCRIPT
import type { PoolConfig } from 'pg'
import prisma from '@/lib/db/prisma'
import { getPgSslConfig } from '@/lib/db/pg-ssl'
 
const connectionString = process.env.DATABASE_URL
if (!connectionString) throw new Error('DATABASE_URL ausente')
 
export const productionPoolOptions = {
  connectionString,
  ssl: getPgSslConfig(connectionString),
  max: 1,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 10_000,
  statement_timeout: 30_000,
} satisfies PoolConfig
 
export class DatabaseManager {
  static getInstance() { return prisma }
 
  static async healthCheck(): Promise<boolean> {
    try {
      await prisma.$queryRaw`SELECT 1`
      return true
    } catch {
      return false
    }
  }
}

O limite de uma conexão é a escolha atual deste projeto serverless, não uma recomendação universal. Considere concorrência por instância, quantidade de instâncias e capacidade do pooler antes de alterá-lo. Preserve a verificação TLS: o helper inclui a cadeia exigida pelo Supabase. Métricas de pool devem vir das propriedades públicas totalCount, idleCount e waitingCount da instância de pg.Pool, não de client._engine. Não desconecte o singleton ao terminar cada request.

Para comparar ORMs, consulte a comparação técnica entre Prisma e Drizzle e o roteiro reproduzível de benchmark. Os números antigos daquela comparação foram retirados; o texto não fornece um resultado universal para produção.

Transaction Management com Retry Logic

Transações mal implementadas são a causa número um de connection pool exhaustion em produção. O pattern abaixo encapsula retry com backoff exponencial, timeout por transação e logging estruturado para debugging.

TYPESCRIPT
// src/infrastructure/database/transaction-manager.ts
import { PrismaClient, Prisma } from '@prisma/client'
import { DatabaseManager } from './prisma'
import { Logger } from '@/infrastructure/logging/logger'
 
interface TransactionOptions {
  maxRetries?: number
  timeoutMs?: number
  isolationLevel?: Prisma.TransactionIsolationLevel
}
 
const DEFAULT_OPTIONS: Required<TransactionOptions> = {
  maxRetries: 3,
  timeoutMs: 10000,
  isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
}
 
export class TransactionManager {
  private readonly db: PrismaClient
 
  constructor() {
    this.db = DatabaseManager.getInstance()
  }
 
  async execute<T>(
    operation: (tx: Prisma.TransactionClient) => Promise<T>,
    opts?: TransactionOptions
  ): Promise<T> {
    const { maxRetries, timeoutMs, isolationLevel } = {
      ...DEFAULT_OPTIONS,
      ...opts,
    }
 
    let lastError: Error | null = null
 
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        const result = await this.db.$transaction(operation, {
          maxWait: 5000, // tempo máximo esperando connection do pool
          timeout: timeoutMs,
          isolationLevel,
        })
 
        if (attempt > 1) {
          Logger.info('Transaction succeeded after retry', {
            attempt,
            maxRetries,
          })
        }
 
        return result
      } catch (error) {
        lastError = error as Error
 
        const isRetryable =
          error instanceof Prisma.PrismaClientKnownRequestError &&
          ['P2034', 'P2028'].includes(error.code) // deadlock ou timeout
 
        if (!isRetryable || attempt === maxRetries) {
          Logger.error('Transaction failed permanently', lastError, {
            attempt,
            maxRetries,
            errorCode:
              error instanceof Prisma.PrismaClientKnownRequestError
                ? error.code
                : 'UNKNOWN',
          })
          throw lastError
        }
 
        // Backoff exponencial: 100ms, 200ms, 400ms...
        const backoffMs = Math.min(100 * Math.pow(2, attempt - 1), 2000)
        Logger.warn('Transaction retrying after deadlock/timeout', {
          attempt,
          backoffMs,
          errorCode:
            (error as Prisma.PrismaClientKnownRequestError).code,
        })
 
        await new Promise((resolve) => setTimeout(resolve, backoffMs))
      }
    }
 
    throw lastError!
  }
}
 
// Uso prático: transferência de créditos entre usuários
const txManager = new TransactionManager()
 
const result = await txManager.execute(async (tx) => {
  const sender = await tx.user.update({
    where: { id: senderId },
    data: { credits: { decrement: amount } },
  })
 
  if (sender.credits < 0) {
    throw new Error('Insufficient credits') // rollback automático
  }
 
  const receiver = await tx.user.update({
    where: { id: receiverId },
    data: { credits: { increment: amount } },
  })
 
  await tx.transaction.create({
    data: {
      senderId,
      receiverId,
      amount,
      status: 'completed',
    },
  })
 
  return { sender, receiver }
}, { timeoutMs: 5000, isolationLevel: 'Serializable' })

O segredo está no maxWait vs timeout: o primeiro controla quanto tempo a transação espera por uma connection livre do pool; o segundo limita a duração total da transação. Em produção, monitore a métrica db_query_duration_seconds para calibrar esses valores.

Investigando N+1 com Prisma Client Extensions

N+1 ocorre quando a aplicação busca uma lista e faz novas leituras dentro de um loop. Em Prisma 6.19.2, use Client Extensions. O middleware antigo foi removido no Prisma 6.14; ele não é uma opção para esta versão. Consulte a documentação oficial de extensões de consulta.

O diagnóstico abaixo conta operações de leitura no contexto assíncrono de uma requisição. Ele avisa uma vez ao atingir cinco repetições do mesmo modelo e operação. É uma heurística: várias leituras legítimas também podem dispará-la, operações do Prisma não equivalem necessariamente a consultas SQL, e o aviso sozinho não comprova N+1. Não registra argumentos ou dados pessoais.

TYPESCRIPT
import { AsyncLocalStorage } from 'node:async_hooks'
import { Prisma } from '@prisma/client'
 
const queries = new AsyncLocalStorage<Map<string, number>>()
 
export function withQueryScope<T>(work: () => Promise<T>): Promise<T> {
  return queries.run(new Map(), work)
}
 
export const nPlusOneExtension = Prisma.defineExtension({
  name: 'n-plus-one-diagnostic',
  query: {
    $allModels: {
      async $allOperations({ model, operation, args, query }) {
        const counts = queries.getStore()
        if (counts && ['findUnique', 'findFirst', 'findMany'].includes(operation)) {
          const key = `${model}.${operation}`
          const count = (counts.get(key) ?? 0) + 1
          counts.set(key, count)
          if (count === 5) console.warn(`Revisar repetição de leitura: ${key}`)
        }
        return query(args)
      },
    },
  },
})

Crie o cliente estendido uma vez sobre o singleton e envolva a operação completa em withQueryScope. O retorno de $extends é um cliente novo que deve ser utilizado nas consultas; ele não modifica o cliente original.

TYPESCRIPT
import prisma from '@/lib/db/prisma'
import { nPlusOneExtension, withQueryScope } from './prisma-n-plus-one'
 
const db = prisma.$extends(nPlusOneExtension)
 
export function listWithRepeatedReads() {
  return withQueryScope(async () => {
    const posts = await db.post.findMany({
      where: { status: 'PUBLISHED', category_id: { not: null } },
      orderBy: { id: 'asc' },
      take: 10,
      select: { id: true, category_id: true },
    })
    const result = []
    for (const post of posts) {
      const category = await db.category.findUnique({
        where: { id: post.category_id! },
        select: { id: true, name: true },
      })
      result.push({ ...post, category })
    }
    return result
  })
}

Este exemplo usa os modelos Post e Category do blog. Para remover as leituras por item, faça uma busca em lote das categorias e componha o resultado em memória:

TYPESCRIPT
export function listWithBatchedReads() {
  return withQueryScope(async () => {
    const posts = await db.post.findMany({
      where: { status: 'PUBLISHED', category_id: { not: null } },
      orderBy: { id: 'asc' },
      take: 10,
      select: { id: true, category_id: true },
    })
    const ids = [...new Set(posts.flatMap(post => post.category_id ? [post.category_id] : []))]
    const categories = ids.length ? await db.category.findMany({
      where: { id: { in: ids } },
      select: { id: true, name: true },
    }) : []
    const byId = new Map(categories.map(category => [category.id, category]))
    return posts.map(post => ({
      ...post,
      category: post.category_id ? byId.get(post.category_id) ?? null : null,
    }))
  })
}

O primeiro caminho faz uma operação Prisma para a lista e uma por item; o segundo faz no máximo duas operações Prisma, sem leituras no loop. Confirme também o SQL e o plano no seu ambiente. Usar include é outra opção, mas não implica automaticamente um único JOIN: depende da versão e da estratégia de carregamento de relações. Meça latência e consultas antes e depois com a mesma amostra, sem prometer ganho fixo.

Query Optimization Patterns

TYPESCRIPT
// src/infrastructure/database/repositories/post.repository.ts
import { DatabaseManager } from '../prisma'
import { Post, PostId, UserId } from '@/domain/entities'
 
export class PostRepository {
  private readonly db = DatabaseManager.getInstance()
  
  async findByIdWithAuthor(postId: PostId): Promise<Post | null> {
    // Single query com join otimizado
    const result = await this.db.post.findUnique({
      where: { id: postId },
      include: {
        author: {
          select: { // Apenas campos necessários
            id: true,
            name: true,
            email: true,
            avatar: true
          }
        },
        tags: {
          select: {
            id: true,
            name: true,
            slug: true
          }
        },
        _count: {
          select: {
            comments: true,
            likes: true
          }
        }
      }
    })
    
    return result ? this.mapToEntity(result) : null
  }
  
  async findPopularPosts(limit: number = 10): Promise<Post[]> {
    // Query otimizada com índices apropriados
    const results = await this.db.post.findMany({
      where: {
        publishedAt: { not: null },
        status: 'published'
      },
      orderBy: [
        { viewCount: 'desc' },
        { publishedAt: 'desc' }
      ],
      take: limit,
      include: {
        author: {
          select: { id: true, name: true, avatar: true }
        },
        _count: {
          select: { comments: true, likes: true }
        }
      }
    })
    
    return results.map(this.mapToEntity)
  }
  
  // Bulk operations para performance
  async incrementViewCount(postIds: PostId[]): Promise<void> {
    await this.db.post.updateMany({
      where: {
        id: { in: postIds }
      },
      data: {
        viewCount: { increment: 1 }
      }
    })
  }
  
  private mapToEntity(raw: any): Post {
    // Mapping de Prisma result para domain entity
    return new Post({
      id: PostId.create(raw.id),
      title: raw.title,
      slug: raw.slug,
      content: raw.content,
      authorId: UserId.create(raw.authorId),
      publishedAt: raw.publishedAt,
      createdAt: raw.createdAt,
      updatedAt: raw.updatedAt,
      // ... outros campos
    })
  }
}

Rate Limiting e Proteção Contra Abuso

Rate limiting não é só sobre DoS attacks. É sobre qualidade de serviço, fair usage e proteção de recursos. Uma implementação bem feita é invisível para usuários legítimos e eficaz contra abuso.

Temos um artigo completo sobre Rate Limiting Inteligente com Redis e Node.js que detalha implementações avançadas.

Rate Limiter Multi-Layer

TYPESCRIPT
// src/infrastructure/rate-limiting/rate-limiter.ts
import Redis from 'ioredis'
import { ConfigManager } from '@/infrastructure/config'
 
export interface RateLimitConfig {
  windowMs: number
  maxRequests: number
  keyGenerator: (req: any) => string
  skipSuccessfulRequests?: boolean
  skipFailedRequests?: boolean
}
 
export interface RateLimitResult {
  allowed: boolean
  remaining: number
  resetTime: Date
  totalRequests: number
}
 
export class RateLimiter {
  private readonly redis: Redis
  
  constructor() {
    this.redis = new Redis(ConfigManager.get().REDIS_URL)
  }
  
  async checkLimit(
    identifier: string, 
    config: RateLimitConfig
  ): Promise<RateLimitResult> {
    const key = `rate_limit:${identifier}`
    const now = Date.now()
    const window = Math.floor(now / config.windowMs)
    const windowKey = `${key}:${window}`
    
    // Sliding window com Redis pipeline para atomicidade
    const pipeline = this.redis.pipeline()
    pipeline.incr(windowKey)
    pipeline.expire(windowKey, Math.ceil(config.windowMs / 1000))
    
    const results = await pipeline.exec()
    const currentRequests = results?.[0]?.[1] as number || 0
    
    const allowed = currentRequests <= config.maxRequests
    const remaining = Math.max(0, config.maxRequests - currentRequests)
    const resetTime = new Date((window + 1) * config.windowMs)
    
    return {
      allowed,
      remaining,
      resetTime,
      totalRequests: currentRequests
    }
  }
  
  // Rate limiting adaptativo baseado em load
  async adaptiveLimit(
    identifier: string,
    baseConfig: RateLimitConfig,
    systemLoad: number // 0-1
  ): Promise<RateLimitResult> {
    const adaptedConfig = {
      ...baseConfig,
      maxRequests: Math.floor(baseConfig.maxRequests * (1 - systemLoad * 0.5))
    }
    
    return this.checkLimit(identifier, adaptedConfig)
  }
}
 
// Middleware Express
export class RateLimitMiddleware {
  constructor(private readonly rateLimiter: RateLimiter) {}
  
  create(config: RateLimitConfig) {
    return async (req: Request, res: Response, next: NextFunction) => {
      const identifier = config.keyGenerator(req)
      const result = await this.rateLimiter.checkLimit(identifier, config)
      
      // Headers padronizados
      res.set({
        'X-RateLimit-Limit': config.maxRequests.toString(),
        'X-RateLimit-Remaining': result.remaining.toString(),
        'X-RateLimit-Reset': result.resetTime.getTime().toString()
      })
      
      if (!result.allowed) {
        return res.status(429).json({
          error: 'Too Many Requests',
          message: `Rate limit exceeded. Try again at ${result.resetTime.toISOString()}`,
          retryAfter: Math.ceil((result.resetTime.getTime() - Date.now()) / 1000)
        })
      }
      
      next()
    }
  }
  
  // Rate limiting por endpoint específico
  createEndpointLimiter(endpoint: string, limits: Record<string, RateLimitConfig>) {
    return async (req: Request, res: Response, next: NextFunction) => {
      const config = limits[endpoint]
      if (!config) return next()
      
      const identifier = `${endpoint}:${config.keyGenerator(req)}`
      const result = await this.rateLimiter.checkLimit(identifier, config)
      
      if (!result.allowed) {
        return res.status(429).json({
          error: 'Endpoint rate limit exceeded',
          endpoint,
          retryAfter: Math.ceil((result.resetTime.getTime() - Date.now()) / 1000)
        })
      }
      
      next()
    }
  }
}

Configuração de Rate Limits por Contexto

TYPESCRIPT
// src/infrastructure/rate-limiting/configs.ts
import { Request } from 'express'
import { RateLimitConfig } from './rate-limiter'
 
// Key generators para diferentes contextos
export const keyGenerators = {
  byIP: (req: Request) => req.ip,
  byUser: (req: Request) => (req as any).user?.id || req.ip,
  byAPIKey: (req: Request) => req.headers['x-api-key'] as string || req.ip,
  composite: (req: Request) => `${req.ip}:${(req as any).user?.id || 'anonymous'}`
}
 
// Configurações por tipo de endpoint
export const rateLimitConfigs: Record<string, RateLimitConfig> = {
  // API pública - mais restritiva
  public: {
    windowMs: 15 * 60 * 1000, // 15 minutos
    maxRequests: 100,
    keyGenerator: keyGenerators.byIP
  },
  
  // Usuários autenticados - mais permissiva
  authenticated: {
    windowMs: 15 * 60 * 1000,
    maxRequests: 1000,
    keyGenerator: keyGenerators.byUser,
    skipSuccessfulRequests: true
  },
  
  // Operações críticas - muito restritiva
  critical: {
    windowMs: 60 * 1000, // 1 minuto
    maxRequests: 5,
    keyGenerator: keyGenerators.byUser
  },
  
  // Upload de arquivos - por tamanho e frequência
  upload: {
    windowMs: 60 * 60 * 1000, // 1 hora
    maxRequests: 50,
    keyGenerator: keyGenerators.byUser
  },
  
  // Webhooks externos - por API key
  webhook: {
    windowMs: 60 * 1000,
    maxRequests: 100,
    keyGenerator: keyGenerators.byAPIKey
  }
}

Caching Estratégico: Performance que Escala

Cache não é sobre velocidade. Cache é sobre escala. Um sistema bem cacheado lida com muito mais tráfego com os mesmos recursos, e a proporção depende da taxa de acerto do cache no seu padrão de acesso. Mal implementado, adiciona complexidade sem benefício.

MERMAID
graph LR
    Client[Client] --> CDN[CDN Cache]
    CDN --> LB[Load Balancer]
    LB --> App[Application]
    App --> L1[L1 Cache<br/>Memory]
    App --> L2[L2 Cache<br/>Redis]
    App --> DB[(Database)]
    
    subgraph "Cache Layers"
        CDN
        L1
        L2
    end
    
    subgraph "Cache Strategies"
        CacheAside[Cache-Aside]
        WriteThrough[Write-Through]
        WriteBack[Write-Back]
    end

Cache Manager Inteligente

TYPESCRIPT
// src/infrastructure/cache/cache-manager.ts
import Redis from 'ioredis'
import { LRUCache } from 'lru-cache'
import { ConfigManager } from '@/infrastructure/config'
 
export interface CacheOptions {
  ttl?: number // Time to live em segundos
  tags?: string[] // Para invalidação por tags
  compress?: boolean // Compressão para valores grandes
  serialize?: boolean // Serialização automática
}
 
export interface CacheStats {
  hits: number
  misses: number
  sets: number
  deletes: number
  hitRate: number
}
 
export class CacheManager {
  private readonly redis: Redis
  private readonly l1Cache: LRUCache<string, any>
  private readonly stats: CacheStats
  
  constructor() {
    this.redis = new Redis(ConfigManager.get().REDIS_URL)
    
    // L1 Cache (Memory) para dados hot
    this.l1Cache = new LRUCache({
      max: 1000, // Máximo 1000 items
      ttl: 5 * 60 * 1000, // 5 minutos
      allowStale: false,
      updateAgeOnGet: true
    })
    
    this.stats = {
      hits: 0,
      misses: 0,
      sets: 0,
      deletes: 0,
      hitRate: 0
    }
  }
  
  async get<T>(key: string): Promise<T | null> {
    // Tentar L1 primeiro
    const l1Result = this.l1Cache.get(key)
    if (l1Result !== undefined) {
      this.stats.hits++
      this.updateHitRate()
      return l1Result as T
    }
    
    // Tentar L2 (Redis)
    try {
      const l2Result = await this.redis.get(key)
      if (l2Result !== null) {
        const parsed = JSON.parse(l2Result) as T
        
        // Promover para L1
        this.l1Cache.set(key, parsed)
        
        this.stats.hits++
        this.updateHitRate()
        return parsed
      }
    } catch (error) {
      console.error('Cache L2 error:', error)
    }
    
    this.stats.misses++
    this.updateHitRate()
    return null
  }
  
  async set<T>(key: string, value: T, options: CacheOptions = {}): Promise<void> {
    const serialized = JSON.stringify(value)
    const ttl = options.ttl || 3600 // 1 hora default
    
    // Set em ambos os layers
    this.l1Cache.set(key, value)
    
    try {
      if (options.ttl) {
        await this.redis.setex(key, ttl, serialized)
      } else {
        await this.redis.set(key, serialized)
      }
      
      // Tags para invalidação
      if (options.tags) {
        await this.addToTags(key, options.tags)
      }
      
      this.stats.sets++
    } catch (error) {
      console.error('Cache L2 set error:', error)
    }
  }
  
  async invalidate(key: string): Promise<void> {
    this.l1Cache.delete(key)
    
    try {
      await this.redis.del(key)
      this.stats.deletes++
    } catch (error) {
      console.error('Cache invalidation error:', error)
    }
  }
  
  async invalidateByTag(tag: string): Promise<void> {
    try {
      const keys = await this.redis.smembers(`tag:${tag}`)
      
      if (keys.length > 0) {
        // Invalidar L1
        keys.forEach(key => this.l1Cache.delete(key))
        
        // Invalidar L2
        const pipeline = this.redis.pipeline()
        keys.forEach(key => pipeline.del(key))
        pipeline.del(`tag:${tag}`)
        await pipeline.exec()
        
        this.stats.deletes += keys.length
      }
    } catch (error) {
      console.error('Tag invalidation error:', error)
    }
  }
  
  // Cache-aside pattern helper
  async getOrSet<T>(
    key: string, 
    fetcher: () => Promise<T>, 
    options: CacheOptions = {}
  ): Promise<T> {
    const cached = await this.get<T>(key)
    if (cached !== null) {
      return cached
    }
    
    const fresh = await fetcher()
    await this.set(key, fresh, options)
    return fresh
  }
  
  private async addToTags(key: string, tags: string[]): Promise<void> {
    const pipeline = this.redis.pipeline()
    tags.forEach(tag => {
      pipeline.sadd(`tag:${tag}`, key)
    })
    await pipeline.exec()
  }
  
  private updateHitRate(): void {
    const total = this.stats.hits + this.stats.misses
    this.stats.hitRate = total > 0 ? this.stats.hits / total : 0
  }
  
  getStats(): CacheStats {
    return { ...this.stats }
  }
}

Cache Decorators para Métodos

TYPESCRIPT
// src/shared/decorators/cache.decorator.ts
import 'reflect-metadata'
import { CacheManager } from '@/infrastructure/cache/cache-manager'
 
const cacheManager = new CacheManager()
 
export function Cacheable(options: {
  ttl?: number
  keyPrefix?: string
  tags?: string[]
}) {
  return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
    const method = descriptor.value
    
    descriptor.value = async function (...args: any[]) {
      const keyPrefix = options.keyPrefix || `${target.constructor.name}.${propertyName}`
      const argsKey = JSON.stringify(args)
      const cacheKey = `${keyPrefix}:${argsKey}`
      
      return cacheManager.getOrSet(
        cacheKey,
        () => method.apply(this, args),
        options
      )
    }
    
    return descriptor
  }
}
 
export function CacheInvalidate(tags: string[]) {
  return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
    const method = descriptor.value
    
    descriptor.value = async function (...args: any[]) {
      const result = await method.apply(this, args)
      
      // Invalidar após operação bem-sucedida
      for (const tag of tags) {
        await cacheManager.invalidateByTag(tag)
      }
      
      return result
    }
    
    return descriptor
  }
}
 
// Usage
export class PostService {
  @Cacheable({ 
    ttl: 300, // 5 minutos
    keyPrefix: 'post',
    tags: ['posts', 'content']
  })
  async getPopularPosts(): Promise<Post[]> {
    return this.postRepository.findPopularPosts()
  }
  
  @CacheInvalidate(['posts', 'content'])
  async createPost(data: CreatePostData): Promise<Post> {
    return this.postRepository.create(data)
  }
}

Para estratégias avançadas de caching com Next.js, veja nosso guia sobre Caching no Next.js: ISR, On-Demand e SWR.

WebSockets vs Server-Sent Events: A Decisão Arquitetural

Real-time não é uma feature, é uma arquitetura. A escolha entre WebSockets e Server-Sent Events define como sua aplicação vai escalar, como vai se comportar sob load e quanta complexidade você vai carregar.

Para uma análise detalhada, consulte WebSockets vs Server-Sent Events.

CritérioWebSocketsServer-Sent Events
ComplexidadeAltaBaixa
Bi-direcional✅ Sim❌ Não (apenas server→client)
Fallback HTTP❌ Não✅ Sim
Proxy/CDN⚠️ Complicado✅ Simples
ReconnectionManualAutomática
Binary Data✅ Sim❌ Não
Browser Support✅ Universal✅ Universal
OverheadBaixoMédio
Load BalancingSticky sessionsStateless

Implementação WebSocket Robusta

TYPESCRIPT
// src/infrastructure/websocket/websocket-manager.ts
import { Server as HttpServer } from 'http'
import { Server as SocketServer, Socket } from 'socket.io'
import { Redis } from 'ioredis'
import { createAdapter } from '@socket.io/redis-adapter'
 
export interface WebSocketMessage {
  type: string
  payload: any
  timestamp: Date
  userId?: string
  room?: string
}
 
export class WebSocketManager {
  private io: SocketServer
  private redis: Redis
  private connectedUsers = new Map<string, Socket>()
  
  constructor(httpServer: HttpServer) {
    this.redis = new Redis(process.env.REDIS_URL!)
    
    this.io = new SocketServer(httpServer, {
      cors: {
        origin: process.env.FRONTEND_URL,
        methods: ['GET', 'POST']
      },
      // Connection pooling para WebSockets
      transports: ['websocket', 'polling'],
      pingTimeout: 60000,
      pingInterval: 25000
    })
    
    // Redis adapter para clustering
    const pubClient = new Redis(process.env.REDIS_URL!)
    const subClient = pubClient.duplicate()
    this.io.adapter(createAdapter(pubClient, subClient))
    
    this.setupEventHandlers()
  }
  
  private setupEventHandlers(): void {
    this.io.use(this.authMiddleware.bind(this))
    
    this.io.on('connection', (socket: Socket) => {
      const userId = socket.data.userId
      this.connectedUsers.set(userId, socket)
      
      console.log(`User ${userId} connected`)
      
      // Join user-specific room
      socket.join(`user:${userId}`)
      
      // Handle custom events
      socket.on('join_room', (roomId: string) => {
        socket.join(roomId)
        socket.to(roomId).emit('user_joined', { userId, timestamp: new Date() })
      })
      
      socket.on('leave_room', (roomId: string) => {
        socket.leave(roomId)
        socket.to(roomId).emit('user_left', { userId, timestamp: new Date() })
      })
      
      // Handle messages
      socket.on('message', async (data: WebSocketMessage) => {
        await this.handleMessage(socket, data)
      })
      
      // Cleanup on disconnect
      socket.on('disconnect', (reason) => {
        this.connectedUsers.delete(userId)
        console.log(`User ${userId} disconnected: ${reason}`)
      })
    })
  }
  
  private async authMiddleware(socket: Socket, next: (err?: Error) => void): Promise<void> {
    try {
      const token = socket.handshake.auth.token
      const payload = await this.verifyToken(token)
      
      socket.data.userId = payload.userId
      socket.data.permissions = payload.permissions
      
      next()
    } catch (error) {
      next(new Error('Authentication failed'))
    }
  }
  
  private async handleMessage(socket: Socket, message: WebSocketMessage): Promise<void> {
    // Rate limiting por usuário
    const rateLimitKey = `ws_rate_limit:${socket.data.userId}`
    const currentCount = await this.redis.incr(rateLimitKey)
    
    if (currentCount === 1) {
      await this.redis.expire(rateLimitKey, 60) // 1 minuto
    }
    
    if (currentCount > 100) { // 100 mensagens por minuto
      socket.emit('error', { message: 'Rate limit exceeded' })
      return
    }
    
    // Process message based on type
    switch (message.type) {
      case 'chat_message':
        await this.handleChatMessage(socket, message)
        break
      case 'typing_indicator':
        await this.handleTypingIndicator(socket, message)
        break
      default:
        socket.emit('error', { message: 'Unknown message type' })
    }
  }
  
  // Public methods para enviar mensagens
  async sendToUser(userId: string, message: WebSocketMessage): Promise<void> {
    this.io.to(`user:${userId}`).emit('message', message)
  }
  
  async sendToRoom(roomId: string, message: WebSocketMessage): Promise<void> {
    this.io.to(roomId).emit('message', message)
  }
  
  async broadcast(message: WebSocketMessage): Promise<void> {
    this.io.emit('message', message)
  }
  
  getConnectedUsersCount(): number {
    return this.connectedUsers.size
  }
  
  isUserConnected(userId: string): boolean {
    return this.connectedUsers.has(userId)
  }
}

Server-Sent Events para Notificações

TYPESCRIPT
// src/infrastructure/sse/sse-manager.ts
import { Request, Response } from 'express'
import { EventEmitter } from 'events'
 
export interface SSEClient {
  id: string
  userId: string
  response: Response
  lastEventId?: string
}
 
export interface SSEEvent {
  id?: string
  event?: string
  data: any
  retry?: number
}
 
export class SSEManager extends EventEmitter {
  private clients = new Map<string, SSEClient>()
  private eventHistory = new Map<string, SSEEvent[]>() // Para replay
  
  createConnection(req: Request, res: Response, userId: string): void {
    const clientId = `${userId}_${Date.now()}`
    
    // SSE headers
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Headers': 'Cache-Control'
    })
    
    // Keep-alive ping
    const pingInterval = setInterval(() => {
      res.write(': ping\n\n')
    }, 30000)
    
    // Store client
    const client: SSEClient = {
      id: clientId,
      userId,
      response: res,
      lastEventId: req.headers['last-event-id'] as string
    }
    
    this.clients.set(clientId, client)
    
    // Replay missed events se necessário
    if (client.lastEventId) {
      this.replayEvents(client)
    }
    
    // Send initial connection event
    this.sendToClient(client, {
      event: 'connected',
      data: { clientId, timestamp: new Date() }
    })
    
    // Cleanup on disconnect
    req.on('close', () => {
      clearInterval(pingInterval)
      this.clients.delete(clientId)
      this.emit('client_disconnected', { clientId, userId })
    })
    
    this.emit('client_connected', { clientId, userId })
  }
  
  sendToUser(userId: string, event: SSEEvent): void {
    const userClients = Array.from(this.clients.values())
      .filter(client => client.userId === userId)
    
    userClients.forEach(client => {
      this.sendToClient(client, event)
    })
    
    // Store for replay
    this.storeEvent(userId, event)
  }
  
  broadcast(event: SSEEvent): void {
    this.clients.forEach(client => {
      this.sendToClient(client, event)
    })
    
    // Store for all users (pode ser otimizado)
    this.clients.forEach(client => {
      this.storeEvent(client.userId, event)
    })
  }
  
  private sendToClient(client: SSEClient, event: SSEEvent): void {
    try {
      let message = ''
      
      if (event.id) {
        message += `id: ${event.id}\n`
      }
      
      if (event.event) {
        message += `event: ${event.event}\n`
      }
      
      if (event.retry) {
        message += `retry: ${event.retry}\n`
      }
      
      message += `data: ${JSON.stringify(event.data)}\n\n`
      
      client.response.write(message)
    } catch (error) {
      console.error('Error sending SSE event:', error)
      this.clients.delete(client.id)
    }
  }
  
  private storeEvent(userId: string, event: SSEEvent): void {
    if (!this.eventHistory.has(userId)) {
      this.eventHistory.set(userId, [])
    }
    
    const events = this.eventHistory.get(userId)!
    events.push({ ...event, id: event.id || Date.now().toString() })
    
    // Keep only last 100 events
    if (events.length > 100) {
      events.shift()
    }
  }
  
  private replayEvents(client: SSEClient): void {
    const events = this.eventHistory.get(client.userId) || []
    const lastEventIndex = events.findIndex(event => event.id === client.lastEventId)
    
    if (lastEventIndex !== -1) {
      const missedEvents = events.slice(lastEventIndex + 1)
      missedEvents.forEach(event => {
        this.sendToClient(client, event)
      })
    }
  }
  
  getConnectedClientsCount(): number {
    return this.clients.size
  }
  
  getUserConnectionsCount(userId: string): number {
    return Array.from(this.clients.values())
      .filter(client => client.userId === userId).length
  }
}

Observabilidade: Logs, Métricas e Tracing

Observabilidade não é sobre quantidade de dados coletados. É sobre visibilidade acionável. Em produção, você precisa saber não apenas que algo quebrou, mas por que quebrou e como consertar rapidamente.

Structured Logging com Contexto

TYPESCRIPT
// src/infrastructure/logging/logger.ts
import pino from 'pino'
import { AsyncLocalStorage } from 'async_hooks'
import { ConfigManager } from '@/infrastructure/config'
 
export interface LogContext {
  requestId?: string
  userId?: string
  operation?: string
  metadata?: Record<string, any>
}
 
export class Logger {
  private static instance: pino.Logger
  private static contextStorage = new AsyncLocalStorage<LogContext>()
  
  static getInstance(): pino.Logger {
    if (!this.instance) {
      const config = ConfigManager.get()
      
      this.instance = pino({
        level: config.LOG_LEVEL,
        
        // Structured output para produção
        ...(ConfigManager.isProduction() ? {
          formatters: {
            level: (label) => ({ level: label }),
            log: (object) => ({
              ...object,
              environment: config.NODE_ENV,
              service: 'vivodecodigo-api',
              version: process.env.npm_package_version
            })
          }
        } : {
          // Pretty print para desenvolvimento
          transport: {
            target: 'pino-pretty',
            options: {
              colorize: true,
              translateTime: 'yyyy-mm-dd HH:MM:ss',
              ignore: 'pid,hostname'
            }
          }
        })
      })
    }
    
    return this.instance
  }
  
  static withContext<T>(context: LogContext, fn: () => T): T {
    return this.contextStorage.run(context, fn)
  }
  
  static getContext(): LogContext {
    return this.contextStorage.getStore() || {}
  }
  
  // Métodos de conveniência com contexto automático
  static info(message: string, meta?: object): void {
    const context = this.getContext()
    this.getInstance().info({
      ...context,
      ...meta
    }, message)
  }
  
  static error(message: string, error?: Error, meta?: object): void {
    const context = this.getContext()
    this.getInstance().error({
      ...context,
      ...meta,
      error: error ? {
        name: error.name,
        message: error.message,
        stack: error.stack
      } : undefined
    }, message)
  }
  
  static warn(message: string, meta?: object): void {
    const context = this.getContext()
    this.getInstance().warn({
      ...context,
      ...meta
    }, message)
  }
  
  static debug(message: string, meta?: object): void {
    const context = this.getContext()
    this.getInstance().debug({
      ...context,
      ...meta
    }, message)
  }
  
  // Performance tracking
  static time(label: string): void {
    const context = this.getContext()
    this.getInstance().info({
      ...context,
      performance: { action: 'start', label }
    }, `Timer started: ${label}`)
  }
  
  static timeEnd(label: string, meta?: object): void {
    const context = this.getContext()
    this.getInstance().info({
      ...context,
      performance: { action: 'end', label },
      ...meta
    }, `Timer ended: ${label}`)
  }
}
 
// Middleware para contexto de request
export function requestContextMiddleware() {
  return (req: Request, res: Response, next: NextFunction) => {
    const requestId = req.headers['x-request-id'] as string || 
                      crypto.randomUUID()
    
    const context: LogContext = {
      requestId,
      userId: (req as any).user?.id,
      operation: `${req.method} ${req.path}`
    }
    
    Logger.withContext(context, () => {
      Logger.info('Request started', {
        method: req.method,
        url: req.url,
        userAgent: req.headers['user-agent'],
        ip: req.ip
      })
      
      const startTime = Date.now()
      
      res.on('finish', () => {
        const duration = Date.now() - startTime
        
        Logger.info('Request completed', {
          statusCode: res.statusCode,
          duration,
          contentLength: res.get('content-length')
        })
      })
      
      next()
    })
  }
}

Métricas Customizadas

TYPESCRIPT
// src/infrastructure/monitoring/metrics.ts
import { register, Counter, Histogram, Gauge } from 'prom-client'
 
export class MetricsCollector {
  // HTTP Metrics
  static readonly httpRequestsTotal = new Counter({
    name: 'http_requests_total',
    help: 'Total number of HTTP requests',
    labelNames: ['method', 'route', 'status_code']
  })
  
  static readonly httpRequestDuration = new Histogram({
    name: 'http_request_duration_seconds',
    help: 'HTTP request duration in seconds',
    labelNames: ['method', 'route', 'status_code'],
    buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10]
  })
  
  // Database Metrics
  static readonly dbQueriesTotal = new Counter({
    name: 'db_queries_total',
    help: 'Total number of database queries',
    labelNames: ['operation', 'table', 'status']
  })
  
  static readonly dbQueryDuration = new Histogram({
    name: 'db_query_duration_seconds',
    help: 'Database query duration in seconds',
    labelNames: ['operation', 'table'],
    buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 2, 5]
  })
  
  // Business Metrics
  static readonly activeUsers = new Gauge({
    name: 'active_users_total',
    help: 'Number of active users'
  })
  
  static readonly cacheHitRate = new Gauge({
    name: 'cache_hit_rate',
    help: 'Cache hit rate percentage'
  })
  
  // Rate Limiting Metrics
  static readonly rateLimitHits = new Counter({
    name: 'rate_limit_hits_total',
    help: 'Total number of rate limit hits',
    labelNames: ['endpoint', 'limit_type']
  })
  
  // System Metrics
  static readonly memoryUsage = new Gauge({
    name: 'nodejs_memory_usage_bytes',
    help: 'Node.js memory usage in bytes',
    labelNames: ['type']
  })
  
  static collectDefaultMetrics(): void {
    // Coleta métricas padrão do Node.js
    require('prom-client').collectDefaultMetrics({
      register,
      prefix: 'nodejs_'
    })
    
    // Coleta métricas customizadas periodicamente
    setInterval(() => {
      const memUsage = process.memoryUsage()
      
      this.memoryUsage.set({ type: 'rss' }, memUsage.rss)
      this.memoryUsage.set({ type: 'heap_used' }, memUsage.heapUsed)
      this.memoryUsage.set({ type: 'heap_total' }, memUsage.heapTotal)
      this.memoryUsage.set({ type: 'external' }, memUsage.external)
    }, 10000) // A cada 10 segundos
  }
  
  static getMetrics(): Promise<string> {
    return register.metrics()
  }
}
 
// Middleware para métricas HTTP
export function metricsMiddleware() {
  return (req: Request, res: Response, next: NextFunction) => {
    const startTime = Date.now()
    
    res.on('finish', () => {
      const duration = (Date.now() - startTime) / 1000
      const route = req.route?.path || req.path
      
      MetricsCollector.httpRequestsTotal.inc({
        method: req.method,
        route,
        status_code: res.statusCode.toString()
      })
      
      MetricsCollector.httpRequestDuration.observe({
        method: req.method,
        route,
        status_code: res.statusCode.toString()
      }, duration)
    })
    
    next()
  }
}

Error Handling Robusto: Result Pattern e Error Boundaries

Error handling é onde vejo mais código frágil em produção. Exceptions não tratadas, error states ignorados, stack traces vazando para o cliente. Vamos fazer direito com patterns que realmente funcionam.

Result Pattern para Operações Críticas

TYPESCRIPT
// src/shared/types/result.ts
export type Result<T, E = Error> = Success<T> | Failure<E>
 
export interface Success<T> {
  readonly success: true
  readonly data: T
}
 
export interface Failure<E> {
  readonly success: false
  readonly error: E
}
 
export const Result = {
  success: <T>(data: T): Success<T> => ({
    success: true,
    data
  }),
  
  failure: <E>(error: E): Failure<E> => ({
    success: false,
    error
  }),
  
  from: async <T>(promise: Promise<T>): Promise<Result<T, Error>> => {
    try {
      const data = await promise
      return Result.success(data)
    } catch (error) {
      return Result.failure(error instanceof Error ? error : new Error(String(error)))
    }
  },
  
  isSuccess: <T, E>(result: Result<T, E>): result is Success<T> => {
    return result.success === true
  },
  
  isFailure: <T, E>(result: Result<T, E>): result is Failure<E> => {
    return result.success === false
  }
}
 
// Utility types para working com Results
export type UnwrapResult<T> = T extends Result<infer U, any> ? U : never
export type UnwrapError<T> = T extends Result<any, infer E> ? E : never

Error Classes Estruturadas

TYPESCRIPT
// src/shared/errors/base-error.ts
export abstract class BaseError extends Error {
  abstract readonly code: string
  abstract readonly statusCode: number
  abstract readonly isOperational: boolean
  
  constructor(
    message: string,
    public readonly context?: Record<string, any>
  ) {
    super(message)
    this.name = this.constructor.name
    
    // Maintain proper stack trace
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor)
    }
  }
  
  toJSON() {
    return {
      name: this.name,
      code: this.code,
      message: this.message,
      statusCode: this.statusCode,
      context: this.context,
      stack: this.stack
    }
  }
}
 
// Domain-specific errors
export class ValidationError extends BaseError {
  readonly code = 'VALIDATION_ERROR'
  readonly statusCode = 400
  readonly isOperational = true
  
  constructor(message: string, public readonly field?: string) {
    super(message, { field })
  }
}
 
export class NotFoundError extends BaseError {
  readonly code = 'NOT_FOUND'
  readonly statusCode = 404
  readonly isOperational = true
  
  constructor(resource: string, identifier?: string) {
    super(`${resource} not found${identifier ? `: ${identifier}` : ''}`, {
      resource,
      identifier
    })
  }
}
 
export class UnauthorizedError extends BaseError {
  readonly code = 'UNAUTHORIZED'
  readonly statusCode = 401
  readonly isOperational = true
  
  constructor(message: string = 'Unauthorized') {
    super(message)
  }
}
 
export class ForbiddenError extends BaseError {
  readonly code = 'FORBIDDEN'
  readonly statusCode = 403
  readonly isOperational = true
  
  constructor(message: string = 'Forbidden') {
    super(message)
  }
}
 
export class ExternalServiceError extends BaseError {
  readonly code = 'EXTERNAL_SERVICE_ERROR'
  readonly statusCode = 502
  readonly isOperational = true
  
  constructor(service: string, originalError?: Error) {
    super(`External service error: ${service}`, {
      service,
      originalError: originalError?.message
    })
  }
}
 
export class DatabaseError extends BaseError {
  readonly code = 'DATABASE_ERROR'
  readonly statusCode = 500
  readonly isOperational = true
  
  constructor(operation: string, originalError?: Error) {
    super(`Database operation failed: ${operation}`, {
      operation,
      originalError: originalError?.message
    })
  }
}

Essas classes de erro se integram com validação em runtime usando Zod e segurança de dependências npm. Veja também as lacunas críticas de segurança e validação que devs JavaScript ignoram para complementar seu error handling com proteções que vão além do código.

Service Layer com Error Handling

TYPESCRIPT
// src/application/services/post.service.ts
import { Result } from '@/shared/types/result'
import { NotFoundError, ValidationError } from '@/shared/errors'
import { PostRepository } from '@/infrastructure/database/repositories'
// Implementação completa do service layer seguindo Result pattern
// com injeção do PostRepository e tratamento explícito de erros.

A ilusão do CRUD

Existe uma armadilha muito comum em quem está aprendendo backend: acreditar que backend é CRUD.

CRUD significa:

  • criar;
  • ler;
  • atualizar;
  • deletar.

E sim, boa parte dos sistemas tem CRUD. Usuários, produtos, posts, pedidos, tarefas, pagamentos, comentários, matrículas, documentos. Quase tudo pode ser representado por algum tipo de cadastro.

Mas backend não é só CRUD.

CRUD é uma parte do comportamento do sistema. Backend é a estrutura que garante que esse comportamento aconteça com segurança, consistência, previsibilidade e possibilidade de manutenção.

Imagine uma rota simples para criar usuário:

TypeScript
app.post("/users", async (req, res) => {
  const user = await db.user.create({
    data: {
      name: req.body.name,
      email: req.body.email,
      password: req.body.password
    }
  });
 
  res.status(201).json(user);
});

De novo: em um tutorial, isso parece funcionar.

Mas em produção, esse código abre uma coleção de problemas:

  • não valida se o e-mail é válido;
  • não verifica se o e-mail já existe;
  • não faz hash da senha;
  • retorna o usuário com dados sensíveis;
  • não trata erro do banco;
  • não registra log;
  • não separa regra de negócio;
  • não deixa claro quem é responsável por quê.

Uma versão um pouco mais madura já muda bastante o raciocínio:

TypeScript
app.post("/users", async (req, res, next) => {
  try {
    const input = createUserSchema.parse(req.body);
 
    const user = await usersService.create(input);
 
    return res.status(201).json({
      id: user.id,
      name: user.name,
      email: user.email
    });
  } catch (error) {
    next(error);
  }
});

Agora a rota não tenta fazer tudo.

Ela recebe a requisição, valida a entrada, chama um serviço e devolve uma resposta controlada. A regra de negócio não está espremida dentro da rota. A senha não aparece no retorno. O erro segue para um tratamento centralizado.

Isso ainda é simples, mas já aponta para uma diferença importante:

backend profissional não é escrever mais código; é colocar cada responsabilidade no lugar certo.

Esse é o tipo de virada que muitos tutoriais não ensinam.

Eles ensinam a fazer funcionar. Mas não ensinam a fazer durar.

O backend de tutorial funciona até encontrar o mundo real

Existe uma fase bonita em todo projeto backend.

Você cria as primeiras rotas. O banco conecta. O login funciona. O Postman devolve 200. O terminal mostra “server running”. Tudo parece sob controle.

Aí vem o mundo real.

O frontend manda um campo vazio. O usuário tenta cadastrar o mesmo e-mail duas vezes. O token expira no meio da operação. O banco demora para responder. O deploy sobe sem uma variável. O CORS bloqueia a aplicação. O cache devolve dado antigo. O log não mostra nada útil. O erro acontece em produção, mas não acontece localmente.

Nesse momento, o dev descobre que backend não é só sobre escrever endpoints. Backend é sobre projetar comportamento diante de falhas.

E aqui entra uma frase que deveria estar colada no monitor de quem está aprendendo:

uma API que só funciona no cenário feliz ainda não está pronta.

O cenário feliz é aquele em que tudo dá certo:

  • o usuário envia os dados corretos;
  • o banco está disponível;
  • o token está válido;
  • a conexão está estável;
  • a permissão está certa;
  • a integração externa responde rápido;
  • o servidor tem memória suficiente;
  • ninguém faz uma requisição absurda.

Só que sistema real vive justamente fora desse cenário.

Uma API atual precisa lidar com entrada errada, permissão negada, exceção inesperada, lentidão, timeout, duplicidade, indisponibilidade e inconsistência. Não o tempo todo, mas o suficiente para quebrar sua confiança se você não se preparar.

Por isso, o backend profissional começa quando você deixa de perguntar apenas:

“Como eu faço essa rota funcionar?”

E começa a perguntar:

“Como essa rota pode falhar, e o que o sistema deve fazer quando isso acontecer?”

Essa pergunta é adulta. E ela muda a arquitetura.

TypeScript não substitui validação

Aqui muita gente escorrega.

TypeScript ajuda no código que você escreve. Mas ele não garante que o usuário vai enviar o payload correto pela internet.

Quando uma requisição chega na sua API, ela vem de fora do seu sistema. Pode vir certa, errada, incompleta, maliciosa ou simplesmente inesperada.

Este tipo abaixo ajuda o dev:

TypeScript
type CreateUserInput = {
  name: string;
  email: string;
  password: string;
};

Mas ele não valida automaticamente este corpo:

JSON
{
  "name": "",
  "email": "isso-nao-e-email",
  "password": "123"
}

Por isso, backend atual precisa de validação em tempo de execução.

Com uma biblioteca como Zod, por exemplo, você cria um contrato executável:

TypeScript
import { z } from "zod";
 
const createUserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  password: z.string().min(8)
});
 
const input = createUserSchema.parse(req.body);

Agora o sistema não está apenas “esperando” receber dados corretos. Ele está verificando.

Essa diferença é enorme.

Sem validação, sua API confia demais. Com validação, sua API define fronteiras.

E todo backend sério precisa de fronteiras.

Para onde ir a partir daqui

Este artigo é o mapa: dá o critério de decisão em cada camada. A implementação completa de cada tarefa, com código executável e testes, está nos tutoriais dedicados. Escolha pela tarefa que você tem hoje:

TarefaTutorial
Montar a API com validação, erros e loggingAPI REST profissional com Fastify e Prisma e Fastify com Zod
Validar contratos em NestJSNestJS com Zod
Alterar o schema em produção sem derrubar nadaMigrations seguras com Prisma, reverter migrations e zero-downtime migrations
Esgotamento de conexões e poolingConnection pooling e PgBouncer
Filas, retry e processamento em backgroundBullMQ e Redis
Rate limiting e proteção da APIRate limiting com Redis e OWASP Top 10 em APIs Node.js
Busca no PostgreSQLFull-text com tsvector
Tempo realWebSockets vs Server-Sent Events
Empacotar e publicarDocker do Dockerfile ao docker-compose e DevOps para devs
Organizar o código para crescerClean Architecture com TypeScript e Arquitetura Hexagonal

FAQ: Perguntas frequentes sobre engenharia backend em 2026

O que é arquitetura de backend Node.js?

Arquitetura de backend Node.js é a organização de camadas, contratos e abstrações que define como um servidor web processa requisições, consulta bancos de dados e devolve respostas. Em sistemas profissionais, ela separa domain, application, infrastructure e interface, permitindo testabilidade, evolução independente e troca de adaptadores sem reescrever a lógica de negócio.

Por que usar TypeScript em vez de JavaScript puro no backend?

TypeScript adiciona checagem de tipos em compile time sem custo em runtime. Em projetos backend com mais de 10 mil linhas, isso reduz bugs de integração entre módulos, facilita refatoração segura e documenta contratos entre camadas. Em 2026, TypeScript é padrão da indústria para Node.js sênior, não mais opcional.

Qual a diferença entre connection pooling direto e PgBouncer?

Connection pooling nativo do driver gerencia conexões dentro do processo Node.js, limitado pelo pool_size da instância. PgBouncer é um pooler externo que compartilha um pool global entre múltiplas instâncias da aplicação, reduzindo a pressão sobre o PostgreSQL quando você roda várias réplicas. Para aplicações serverless e alta escala, PgBouncer é essencial.

Como escolher entre WebSockets e Server-Sent Events?

Use WebSockets quando precisar de comunicação bidirecional em tempo real (chat, collaborative editing, jogos). Use Server-Sent Events (SSE) quando só precisar do servidor empurrar dados para o cliente (notificações, dashboards, feeds ao vivo). SSE roda sobre HTTP padrão, é mais simples de fazer proxy e reconecta automaticamente.

Qual é o maior erro de observabilidade em backend Node.js?

Logar tudo sem contexto. Logs sem correlation ID, sem request ID e sem estrutura viram ruído impossível de filtrar em produção. O padrão correto é structured logging (Pino), propagação de contexto via AsyncLocalStorage e amostragem em endpoints de alta frequência. Observabilidade acionável significa conseguir responder "o que aconteceu com essa request específica" em menos de 30 segundos.

Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Guias de integração relacionados

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.