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".
64
78%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./skills/03-backend-api/SKILL.mdO Backend define a fundação de dados e lógica de negócio que sustenta toda a aplicação.
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.
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-geradoprisma/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:
snake_case plural (via @@map)camelCase no PrismacreatedAt + updatedAtdeletedAt nullableAs convenções acima (UUID, timestamps, soft delete) valem para qualquer banco relacional via Prisma. Os dois recursos abaixo são exclusivos de PostgreSQL — não têm equivalente direto em MySQL/SQL Server, e usá-los é uma decisão de acoplar o schema ao motor. Confirmar que o projeto roda Postgres antes de aplicar.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY; + CREATE POLICY user_access ON orders FOR SELECT TO app_users USING (user_id = current_user_id()); — mesmo uma query da aplicação que esquece o WHERE user_id = ... não vaza linha de outro tenant, porque o Postgres filtra antes de devolver qualquer linha. Cobre a lacuna que nenhuma revisão de código pega 100% das vezes; não substitui autenticação.EXCLUDE USING gist — previne overlap de intervalo, o que UNIQUE não resolve (duas reservas de sala com horários que se cruzam não são "iguais", então UNIQUE(room_id, periodo) deixa passar). ALTER TABLE bookings ADD CONSTRAINT no_overlap EXCLUDE USING gist (room_id WITH =, booking_period WITH &&); rejeita a sobreposição no INSERT/UPDATE, sem lock manual nem race condition de check-then-act.Detalhe completo (SQL de RLS, extensão btree_gist, tipos de range aceitos) em references/idempotencia-e-postgres-avancado.md.
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"
}
]
}
}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 logadoQuery 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 → RelationsLogin:
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 tokensrc/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>;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,
},
});
};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 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' },
});
},
});
// uso: endpoint caro tem limite mais apertado que endpoint de leitura simples
router.post('/reports/generate', rateLimitByRoute(60_000, 5), generateReport);
router.get('/users/:id', rateLimitByRoute(60_000, 100), getUser);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');
}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.
Retry resiliente resolve "a chamada eventualmente funciona". Não resolve "a chamada não aconteceu duas vezes" — são problemas diferentes. Todo endpoint que muda estado (cobrar, criar pedido, enviar email) e é alvo de retry precisa honrar uma Idempotency-Key, ou ser documentado explicitamente como inseguro para retentar.
4 pontos que definem se a implementação é real ou só decorativa:
crypto.randomUUID() gerado a cada retry, ou ${orderId}:${Date.now()}, criam uma chave nova por tentativa — o oposto do que idempotência exige. A chave certa vem do cliente (header Idempotency-Key) ou de um identificador imutável do evento (charge:v1:${orderId}).SELECT seguido de INSERT (isso é race condition: duas tentativas concorrentes leem "não existe" e ambas executam o efeito). Deixar o banco decidir o vencedor com um INSERT protegido por índice único.422, nunca servir a resposta antiga silenciosamente.reject (409, mais simples), wait (bloqueia com timeout), pending (202 + status URL). A retenção da chave deve cobrir a cadeia de retry mais longa do sistema — incluindo DLQ reprocessada dias depois, ou janela de disputa do provedor de pagamento. TTL curto atrás de fila de retry longa é duplicata esperando para acontecer.Código completo (claim atômico, guard de payload, tabela de estratégias) em references/idempotencia-e-postgres-avancado.md.
TTL curto demais anula o cache; TTL longo demais serve dado obsoleto. Decidir explicitamente qual estratégia se aplica a cada recurso:
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.
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() } });
},
});// src/db.js
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 };// src/migrate.js
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 };// src/repositories/user.repository.js
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;// db.transaction() retorna função que roda tudo atomicamente
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); // lança se qualquer stmt falhar
}Entregar:
Codigo deve priorizar clareza. Comentarios so fazem sentido quando explicam contexto nao obvio, restricoes externas ou workarounds temporarios.
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 |
api-and-interface-design de addyosmani/agent-skills (MIT) — a lacuna entre "retry funciona" (resiliência de chamada, já coberta acima) e "retry não duplica o efeito" (correção sob retry).CREATE POLICY, EXCLUDE USING gist para prevenção de overlap) adaptados da skill postgresql-table-design (plugin database-design) de wshobson/agents (MIT) — curados como os dois recursos específicos de Postgres com aplicação direta em produto real, deixando de fora tipos geométricos/rede por serem nicho.f3492ef
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.