| name | backend-api |
| description | Skill do Backend Developer para definição de APIs, banco de dados, e lógica de servidor. Use quando
precisar definir schemas de banco, endpoints REST/GraphQL, validação server-side, autenticação,
migrations, ou qualquer lógica de backend. Trigger em: "API", "endpoint", "banco de dados", "schema",
"migration", "backend", "servidor", "autenticação", "JWT", "middleware", "ORM", "Prisma", "PostgreSQL",
"Node.js", "Express", "NestJS", "validação server-side".
|
Backend Developer - API e Banco de Dados
O Backend define a fundação de dados e lógica de negócio que sustenta toda a aplicação.
Governanca Global
Esta skill segue GLOBAL.md, policies/execution.md, policies/handoffs.md, policies/quality-gates.md, policies/token-efficiency.md, policies/stack-flexibility.md, policies/tool-safety.md e policies/evals.md.
Para exemplos extensos de schema, auth e migracoes, consultar docs/skill-guides/backend-api.md apenas quando necessario.
Quando Usar
- definir schema, endpoint, auth ou regra de servidor
- ajustar contrato de API, validacao ou persistencia
Quando Nao Usar
- para detalhes puramente visuais
- para documentacao ou review final sem mudanca de backend
Entradas Esperadas
- spec e regras de negocio
- dependencias tecnicas e restricoes de seguranca
- contrato atual ou esperado da API
Saidas Esperadas
- contrato de API e estrategia de dados consistentes
- validacoes, auth e erros padronizados
- handoff claro para Frontend, QA e Security
Responsabilidades
- Definir schema do banco de dados
- Criar APIs RESTful (ou GraphQL quando justificado)
- Implementar autenticação e autorização
- Validação server-side de todos os inputs
- Tratamento de erros padronizado
- Performance: queries otimizadas, caching, indexação
Stack Padrão
Runtime: Node.js (LTS)
Framework: Express / NestJS (dependendo da complexidade)
ORM: Prisma
Banco: PostgreSQL
Validação: Zod
Auth: JWT (access em memoria + refresh em HttpOnly cookie)
Cache: Redis (quando necessário)
Documentação: OpenAPI/Swagger auto-gerado
Schema do Banco - Convenções
prisma/schema.prisma
model User {
id String @id @default(uuid())
email String @unique
name String
password String
role Role @default(USER)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
posts Post[]
sessions Session[]
@@map("users")
@@index([email])
@@index([deletedAt])
}
enum Role {
USER
ADMIN
MODERATOR
}
model Session {
id String @id @default(uuid())
userId String
refreshToken String @unique
userAgent String?
ip String?
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@map("sessions")
@@index([userId])
@@index([expiresAt])
}
Convenções:
- Nomes de tabela:
snake_case plural (via @@map)
- Nomes de campo:
camelCase no Prisma
- IDs: UUID v4 (nunca auto-increment exposto)
- Timestamps: sempre
createdAt + updatedAt
- Soft delete:
deletedAt nullable
- Índices: em todo campo usado em WHERE/JOIN/ORDER BY
API - Padrão de Resposta
Toda resposta da API segue este formato:
Sucesso:
{
"success": true,
"data": { ... },
"meta": {
"page": 1,
"perPage": 20,
"total": 100,
"totalPages": 5
}
}
Erro:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Email inválido",
"details": [
{
"field": "email",
"message": "Formato de email inválido"
}
]
}
}
API - Padrão de Endpoints
GET /api/v1/resources → Lista (com paginação, filtro, sort)
GET /api/v1/resources/:id → Detalhe
POST /api/v1/resources → Criar
PATCH /api/v1/resources/:id → Atualizar parcial
DELETE /api/v1/resources/:id → Soft delete
POST /api/v1/auth/register → Registro
POST /api/v1/auth/login → Login (retorna access token, seta refresh cookie)
POST /api/v1/auth/refresh → Refresh token
POST /api/v1/auth/logout → Logout (invalida session)
GET /api/v1/auth/me → Usuário logado
Query params para listagem:
?page=1&perPage=20 → Paginação
?sort=createdAt&order=desc → Ordenação
?search=termo → Busca fulltext
?filter[status]=active → Filtros
?include=author,comments → Relations
Autenticação - Fluxo Completo
Login:
1. POST /auth/login { email, password }
2. Valida credenciais
3. Gera access token (JWT, 15min, retornado no response body para uso em memoria)
4. Gera refresh token (UUID, 7d, HttpOnly cookie)
5. Salva session no banco
6. Retorna { accessToken, user }
Refresh:
1. POST /auth/refresh (cookie com refresh token)
2. Valida refresh token no banco
3. Verifica se session não expirou
4. Gera novo access token
5. Opcionalmente rotaciona refresh token
6. Retorna { accessToken, user }
Logout:
1. POST /auth/logout (com access token)
2. Remove session do banco
3. Limpa cookie do refresh token
Validação com Zod - Patterns
src/validators/user.validator.ts
import { z } from 'zod';
const emailSchema = z.string().email('Email inválido').toLowerCase().trim();
const passwordSchema = z.string()
.min(8, 'Mínimo 8 caracteres')
.regex(/[A-Z]/, 'Precisa de letra maiúscula')
.regex(/[0-9]/, 'Precisa de número')
.regex(/[^A-Za-z0-9]/, 'Precisa de caractere especial');
export const createUserSchema = z.object({
email: emailSchema,
password: passwordSchema,
name: z.string().min(2).max(100).trim(),
});
export const updateUserSchema = createUserSchema.partial().omit({ password: true });
export const loginSchema = z.object({
email: emailSchema,
password: z.string().min(1, 'Senha obrigatória'),
});
export const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
perPage: z.coerce.number().int().min(1).max(100).default(20),
sort: z.string().optional(),
order: z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type PaginationInput = z.infer<typeof paginationSchema>;
Middleware Pattern
src/middleware/validate.ts
import { ZodSchema } from 'zod';
export const validate = (schema: ZodSchema, source: 'body' | 'query' | 'params' = 'body') => {
return (req, res, next) => {
const result = schema.safeParse(req[source]);
if (!result.success) {
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Dados inválidos',
details: result.error.issues.map(i => ({
field: i.path.join('.'),
message: i.message,
})),
},
});
}
req.validated = result.data;
next();
};
};
src/middleware/auth.ts
export const authenticate = async (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED' } });
try {
const payload = verifyAccessToken(token);
req.user = payload;
next();
} catch {
return res.status(401).json({ success: false, error: { code: 'TOKEN_EXPIRED' } });
}
};
export const authorize = (...roles: Role[]) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ success: false, error: { code: 'FORBIDDEN' } });
}
next();
};
};
src/middleware/errorHandler.ts
export const errorHandler = (err, req, res, next) => {
console.error(err);
if (err.code === 'P2002') {
return res.status(409).json({
success: false,
error: { code: 'DUPLICATE', message: 'Registro já existe' },
});
}
res.status(err.status || 500).json({
success: false,
error: {
code: err.code || 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production' ? 'Erro interno' : err.message,
},
});
};
Resiliência - Chamadas a Serviços Externos
Toda chamada a um serviço externo (API terceira, outro microsserviço) pode falhar de forma lenta ou parcial, não só com erro imediato. Decidir a estratégia de resiliência é parte do contrato, não um detalhe de implementação a adicionar depois.
Rate Limiting - por endpoint, não global
Rate limit genérico pro app inteiro protege infra mas não previne abuso de endpoint caro (ex: geração de relatório, envio de email). Definir limite por rota, com response 429 e header Retry-After.
src/middleware/rateLimit.ts
import rateLimit from 'express-rate-limit';
export const rateLimitByRoute = (windowMs: number, max: number) =>
rateLimit({
windowMs,
max,
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
res.status(429).json({
success: false,
error: { code: 'RATE_LIMITED', message: 'Muitas requisições, tente novamente em breve' },
});
},
});
router.post('/reports/generate', rateLimitByRoute(60_000, 5), generateReport);
router.get('/users/:id', rateLimitByRoute(60_000, 100), getUser);
Retry com backoff exponencial + jitter
Retry sem backoff crescente amplifica falha de serviço já sobrecarregado (thundering herd). Sem jitter, múltiplas instâncias do seu app retentam no mesmo instante e recriam o pico. Retry só em erros transitórios (timeout, 502/503/504) — nunca em 4xx (erro do cliente não se resolve retentando).
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
const isRetryable = err.status === undefined || [502, 503, 504].includes(err.status);
if (!isRetryable || attempt === maxRetries) throw err;
const backoff = Math.min(1000 * 2 ** attempt, 10_000);
const jitter = Math.random() * backoff * 0.3;
await new Promise((resolve) => setTimeout(resolve, backoff + jitter));
}
}
throw new Error('unreachable');
}
Circuit Breaker - parar de tentar quando o serviço está fora
Sem circuit breaker, cada requisição concorrente continua tentando (e esperando timeout) contra um serviço já derrubado, consumindo conexões/threads do seu próprio app até ele também cair. Três estados: closed (normal) → open (falhas acima do limiar, rejeita na hora sem chamar o serviço) → half-open (após cooldown, deixa 1 requisição de teste passar).
type CircuitState = 'closed' | 'open' | 'half-open';
class CircuitBreaker {
private state: CircuitState = 'closed';
private failures = 0;
private nextAttempt = 0;
constructor(private threshold = 5, private cooldownMs = 30_000) {}
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() < this.nextAttempt) throw new Error('CIRCUIT_OPEN');
this.state = 'half-open';
}
try {
const result = await fn();
this.failures = 0;
this.state = 'closed';
return result;
} catch (err) {
this.failures++;
if (this.failures >= this.threshold) {
this.state = 'open';
this.nextAttempt = Date.now() + this.cooldownMs;
}
throw err;
}
}
}
Não implementar circuit breaker do zero em produção sem necessidade comprovada — se o projeto já usa uma lib HTTP com suporte nativo (ex: undici, got com plugin), preferir a implementação testada. O padrão acima é a referência mental, não o único código aceitável.
Cache Invalidation - nomear a estratégia, não confiar em TTL sozinho
TTL curto demais anula o cache; TTL longo demais serve dado obsoleto. Decidir explicitamente qual estratégia se aplica a cada recurso:
- Write-through: invalida/atualiza o cache no mesmo commit que escreve no banco — dado nunca fica obsoleto, mas toda escrita paga o custo de atualizar cache.
- TTL curto + stale-while-revalidate: serve o valor em cache mesmo vencido enquanto busca o novo em background — bom para dado que tolera alguns segundos de atraso (listagens, contadores).
- Invalidação por evento: ao mudar o recurso, publicar evento que derruba as chaves de cache relacionadas — necessário quando o mesmo dado aparece em múltiplas chaves derivadas (ex: cache de listagem E de detalhe).
Nunca cachear resposta de endpoint autenticado sem incluir o identificador do usuário na chave do cache — vaza dado de um usuário pra outro.
Service Pattern - DRY
src/services/base.service.ts
export const createBaseService = <T>(model: any) => ({
async findMany(params: PaginationInput & { where?: any }) {
const { page, perPage, sort, order, search, ...filters } = params;
const skip = (page - 1) * perPage;
const where = { deletedAt: null, ...filters.where };
const [data, total] = await Promise.all([
model.findMany({
where,
skip,
take: perPage,
orderBy: sort ? { [sort]: order } : { createdAt: 'desc' },
}),
model.count({ where }),
]);
return {
data,
meta: { page, perPage, total, totalPages: Math.ceil(total / perPage) },
};
},
async findById(id: string) {
return model.findFirst({ where: { id, deletedAt: null } });
},
async create(data: Partial<T>) {
return model.create({ data });
},
async update(id: string, data: Partial<T>) {
return model.update({ where: { id }, data });
},
async softDelete(id: string) {
return model.update({ where: { id }, data: { deletedAt: new Date() } });
},
});
Stack Alternativa — Plain JS + better-sqlite3
Quando Usar
- Repos pequenos, scripts, ferramentas CLI, sandboxes e protótipos
- Projetos onde TypeScript e ORM adicionam mais fricção do que valor
- Dados locais, single-user, ou leitura intensiva com writes eventuais
- Casos onde o binário precisa ser zero-dependência (Tauri, Electron, scripts CI)
DB Singleton Pattern
const Database = require('better-sqlite3');
const path = require('path');
let _db = null;
function getDb() {
if (_db) return _db;
_db = new Database(path.join(__dirname, '..', 'data.db'));
_db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON');
_db.pragma('synchronous = NORMAL');
return _db;
}
module.exports = { getDb };
Schema via db.exec() — sem Prisma
const { getDb } = require('./db');
function migrate() {
const db = getDb();
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at);
`);
}
module.exports = { migrate };
Queries — sempre parametrizadas
const { getDb } = require('../db');
const UserRepo = {
findById(id) {
return getDb().prepare('SELECT * FROM users WHERE id = ? AND deleted_at IS NULL').get(id);
},
findByEmail(email) {
return getDb().prepare('SELECT * FROM users WHERE email = ? AND deleted_at IS NULL').get(email);
},
findAll({ page = 1, perPage = 20 } = {}) {
const offset = (page - 1) * perPage;
const rows = getDb()
.prepare('SELECT * FROM users WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT ? OFFSET ?')
.all(perPage, offset);
const { total } = getDb()
.prepare('SELECT COUNT(*) as total FROM users WHERE deleted_at IS NULL')
.get();
return { data: rows, meta: { page, perPage, total, totalPages: Math.ceil(total / perPage) } };
},
create(data) {
const stmt = getDb().prepare(
'INSERT INTO users (email, name, password, role) VALUES (@email, @name, @password, @role)'
);
stmt.run(data);
return this.findByEmail(data.email);
},
softDelete(id) {
return getDb()
.prepare("UPDATE users SET deleted_at = datetime('now') WHERE id = ?")
.run(id);
},
};
module.exports = UserRepo;
Transactions — síncronas, rápidas
const { getDb } = require('./db');
function transferCredits(fromId, toId, amount) {
const db = getDb();
const transfer = db.transaction((from, to, amt) => {
db.prepare('UPDATE wallets SET credits = credits - ? WHERE id = ?').run(amt, from);
db.prepare('UPDATE wallets SET credits = credits + ? WHERE id = ?').run(amt, to);
});
transfer(fromId, toId, amount);
}
Quando NÃO Usar
- Alta concorrência de writes (SQLite é single-writer; use PostgreSQL)
- Multi-tenant grande ou dados que crescem para GB+
- Microservices distribuídos que precisam de conexão compartilhada
- Quando replicação, CDC ou streaming de dados for requisito
Estrategia de Migrations
- Toda migration DEVE ter UP e DOWN (reversivel)
- Zero-downtime migration checklist:
- Adicionar coluna nova (nullable ou com default)
- Deploy codigo que escreve na coluna nova E antiga
- Backfill dados antigos
- Deploy codigo que le apenas da coluna nova
- Remover coluna antiga em migration separada
- Testar migration em staging ANTES de prod
- Rollback: se migration falha, executar DOWN imediatamente
- NUNCA renomear coluna diretamente — criar nova, migrar dados, remover antiga
Evidencia de Conclusao
- contrato de API coerente com a spec
- auth, validacao e erros definidos
- impacto para frontend e QA explicitado
Handoff para Frontend
Entregar:
- Documentação OpenAPI/Swagger gerada
- Tipos TypeScript exportados (shared types)
- Contrato de resposta padronizado
- Endpoints de autenticação documentados
- Headers necessários (Authorization, CSRF token, etc.)
- Rate limits definidos por endpoint
- Websocket events se houver real-time
- Variáveis de ambiente necessárias no front
Código Limpo
Codigo deve priorizar clareza. Comentarios so fazem sentido quando explicam contexto nao obvio, restricoes externas ou workarounds temporarios.
Integração com Pipeline
- Orquestrador (skill 09): Coordena quando esta skill é invocada e define a próxima etapa
- Context Manager (skill 08): Rastreia progresso das tasks dentro desta skill
- Documentador (skill 10): Documenta entregas desta skill durante o desenvolvimento
Anti-Rationalization
Se você reconhece um desses pensamentos, PARE e siga o processo. Ver policies/anti-rationalization.md.
| Racionalização | Realidade |
|---|
| "Validação no frontend já cobre" | Frontend é bypassável. Backend é a última linha de defesa |
| "Trato erros depois" | Erros não tratados viram 500s em produção e logs inúteis |
| "É só um endpoint simples" | Endpoints simples sem rate limit, validação e auth são vetores de ataque |
| "ORM protege contra SQL injection" | ORM protege queries normais. Raw queries e query builders não |
| "Logs são overhead desnecessário" | Logs são a única forma de debugar produção. Sem logs = voo cego |
Fontes Externas
- Padrões de resiliência (rate limiting por rota, retry com backoff+jitter, circuit breaker, estratégias de cache invalidation) inspirados em conceitos consolidados em donnemartin/system-design-primer — curados aqui como decisão de contrato de API acionável, não como material de estudo teórico.