Engenharia 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.
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 --> TracesEstrutura de Pastas que Escala
Depois de ver dezenas de projetos Node.js crescerem de MVP para sistemas complexos, esta é a estrutura que realmente funciona:
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
// 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
// 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
// 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
// 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
// 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
// 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.
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.
// 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.
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.
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:
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
// 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
// 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
// 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.
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]
endCache Manager Inteligente
// 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
// 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ério | WebSockets | Server-Sent Events |
|---|---|---|
| Complexidade | Alta | Baixa |
| Bi-direcional | ✅ Sim | ❌ Não (apenas server→client) |
| Fallback HTTP | ❌ Não | ✅ Sim |
| Proxy/CDN | ⚠️ Complicado | ✅ Simples |
| Reconnection | Manual | Automática |
| Binary Data | ✅ Sim | ❌ Não |
| Browser Support | ✅ Universal | ✅ Universal |
| Overhead | Baixo | Médio |
| Load Balancing | Sticky sessions | Stateless |
Implementação WebSocket Robusta
// 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
// 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
// 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
// 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
// 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 : neverError Classes Estruturadas
// 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
// 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:
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:
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:
type CreateUserInput = {
name: string;
email: string;
password: string;
};Mas ele não valida automaticamente este corpo:
{
"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:
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:
| Tarefa | Tutorial |
|---|---|
| Montar a API com validação, erros e logging | API REST profissional com Fastify e Prisma e Fastify com Zod |
| Validar contratos em NestJS | NestJS com Zod |
| Alterar o schema em produção sem derrubar nada | Migrations seguras com Prisma, reverter migrations e zero-downtime migrations |
| Esgotamento de conexões e pooling | Connection pooling e PgBouncer |
| Filas, retry e processamento em background | BullMQ e Redis |
| Rate limiting e proteção da API | Rate limiting com Redis e OWASP Top 10 em APIs Node.js |
| Busca no PostgreSQL | Full-text com tsvector |
| Tempo real | WebSockets vs Server-Sent Events |
| Empacotar e publicar | Docker do Dockerfile ao docker-compose e DevOps para devs |
| Organizar o código para crescer | Clean 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.

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.


