17 de maio de 20269 min de leitura

Pare de tratar ordenação como detalhe em APIs REST

Ordenação parece apenas ``sort=price&order=asc``, mas pode causar registros duplicados, itens sumindo, paginação inconsistente, consultas lentas e comportamento imprevisível em produção.

Beleza, mas o que é ordenação?

Ordenação é a forma de definir em que ordem os registros serão retornados.

Exemplo:

HTTP
GET /products?sort=price&order=asc

Significa:

Buscar produtos ordenados pelo preço em ordem crescente.

1. Paginação sem ordenação confiável é uma fonte clássica de bug'

Porque sem ordenação, a API pode retornar os dados de forma imprevisível.

Exemplo:

HTTP
GET /products?page=1&limit=10

Se não tiver ORDER BY, o banco não garante que os mesmos registros vão aparecer sempre na mesma ordem.

Isso pode gerar bugs como:

  • item aparecendo em páginas diferentes
  • item duplicado entre páginas
  • item sumindo da listagem
  • paginação inconsistente
  • experiência ruim no frontend

Por isso, uma regra importante:

Toda listagem paginada deveria ter uma ordenação explícita.

Imagine esta lista:

TypeScript
1. Produto A
2. Produto B 
3. Produto C
4. Produto D

Se você pagina sem ordenação:

HTTP
GET /products?page=1&limit=2

O banco pode retornar:

TypeScript
Produto A, Produto B

Depois:

HTTP
GET /products?page=2&limit=2

Você espera:

TypeScript
Produto C, Produto D

Mas se a ordem interna mudar, você pode receber algo inconsistente.

Por isso:

Paginação sem ordenação confiável é uma fonte clássica de bug.

2. O bug invisível de ordenar só por createdAt

Esse ponto é muito importante.

Imagine que vários produtos têm o mesmo createdAt.

TypeScript
Produto A - 2026-01-01
Produto B - 2026-01-01
Produto C - 2026-01-01

Se você ordenar só por createdAt, o banco fica livre para ordenar como ele quiser, exemplo:

TypeScript
LIMIT 2 OFFSET 0

Página 1:

TypeScript
Produto A - 2026-01-01
Produto B - 2026-01-01

Agora próxima request:

TypeScript
LIMIT 2 OFFSET 2

Agora página 2 vira:

TypeScript
Produto A - 2026-01-01

Resultado:

  • item duplicado
  • item sumindo
  • paginação inconsistente

Como resolver

Você adiciona um critério de desempate:

SQL
ORDER BY created_at DESC, id DESC

Agora:

  1. Ordena por created_at
  2. Se empatar → ordena por id

Esse Id normalmente é o ID do registro no banco, ele é único.

Agora a ordenação fica determinística.

Ou seja, mesma query = mesma ordem sempre.


Regra prática MUITO boa

Sempre que ordenar por campo que pode repetir:

TypeScript
createdAt
updatedAt
price
name
status

adicione o id como desempate.


Como fica na API:

HTTP
GET /products?sort=createdAt&order=desc

Mas internamente o backend aplica:

TypeScript
orderBy: [  
	{ createdAt: 'desc' },  
	{ id: 'desc' },
]

No SQL:

SQL
ORDER BY created_at DESC, id DESC

3. Ordenação com cursor pagination

Esse é um ponto avançado e importante.

OFFSET pagination (tradicional)

Você conhece isso:

SQL
LIMIT 10 OFFSET 20

Significa: pule 20 registros e pegue os próximos 10.


O problema disso

Imagine um feed de pedidos:

TypeScript
ord_10 - 10:00
ord_9  - 09:59
ord_8  - 09:58

Usuário abre a primeira página:

SQL
LIMIT 2 OFFSET 0

e recebe:

  • ord_10
  • ord_9

Agora entra um novo registro no topo do feed:

TypeScript
ord_11 - 10:01
ord_10 - 10:00
ord_9  - 09:59
ord_8  - 09:58

Quando o usuário pede a segunda página:

SQL
LIMIT 2 OFFSET 2

o banco pula os dois primeiros registros da lista atual (ord_11 e ord_10) e entrega:

  • ord_9
  • ord_8

O problema é que o ord_9 já tinha aparecido na página anterior.

Isso acontece porque OFFSET depende da posição física/lógica dos registros naquele momento. Como a tabela mudou entre uma request e outra, a “página 2” deixou de apontar para o mesmo pedaço da lista.


O cursor pagination resolve isso

Em vez de dizer: “me dê página 2”, você diz: “continue depois deste item”

Exemplo do cursor

TypeScript
{ "nextCursor": "2026-01-09T10:00:00Z|ord_9" }

Isso significa:

  • o último item da página atual foi o ord_9
  • o createdAt dele é 2026-01-09T10:00:00Z
  • o id dele é ord_9

Na próxima query você não usa OFFSET, você usa:

SQL
WHERE created_at < '2026-01-09T10:00:00Z'

Ou seja: “me dê itens mais antigos que o último item que eu já entreguei”.

Mas aí vem o problema do empate

Imagine esta ordenação:

  • ord_10 - 10:00
  • ord_9 - 10:00
  • ord_8 - 10:00

Todos têm a mesma data. Se você fizer só:

SQL
created_at < X

você perde itens empatados. O filtro busca apenas registros com data menor que 10:00, então o ord_8 fica de fora mesmo devendo aparecer na próxima página.


Então entra o id como desempate

SQL
WHERE created_at < '2026-01-09T10:00:00Z'
  OR (created_at = '2026-01-09T10:00:00Z'
      AND id < 'ord_9')

Ou seja:

  • registros mais antigos
  • ou, se a data empatar, ids menores que ord_9

Isso cria uma posição exata

Cursor:

SQL
createdAt + id

vira literalmente um marcador único da posição no feed.

Visualmente

Ordenação:

TypeScript
10:00 | ord_10
10:00 | ord_9   ← cursor atual
10:00 | ord_8
09:59 | ord_7

Próxima página: pega tudo depois do ord_9.

Resultado:

TypeScript
10:00 | ord_8
09:59 | ord_7
Assim, não perdemos o ord_8.

Isso é robusto porque não depende de posição da linha, depende do valor ordenado. Sendo assim, se novos registros entrarem, não quebram a paginação.

Outra vantagem gigante: performance

SQL
OFFSET 100000

O banco precisa: ler e descartar 100 mil linhas

Já com o cursor:

SQL
WHERE created_at < X, id X

muito mais eficiente com índice.

SQL
CREATE INDEX idx_orders_created_idON orders(created_at DESC, id DESC);

Quando cursor pagination brilha

  • feeds
  • timeline
  • chat
  • infinite scroll
  • redes sociais
  • logs
  • grandes volumes

Quando OFFSET ainda é ok

  • admin panel pequeno
  • poucos dados
  • tabelas pequenas
  • relatórios simples

O ponto principal é:

Cursor pagination não pensa em “página 2, página 3”. Ela pensa em: “continue a partir deste item”.


4. Nunca aceite qualquer campo de ordenação

Esse é um erro muito comum, pois abre espaço para injeção ou comportamento perigoso.

Não faça isso sem validação:

TypeScript
const sort = req.query.sort;

e depois jogar direto no banco. Você deve permitir apenas campos conhecidos.

Exemplo:

TypeScript
const allowedSortFields = ['name', 'price', 'createdAt'] as const;

Se o usuário mandar:

HTTP
GET /products?sort=password

ou:

HTTP
GET /products?sort=someInternalField

o backend deve rejeitar com:

TypeScript
400 Bad Request
JSON
{  "error": "Invalid sort field",  
	"allowedFields": ["name", "price", "createdAt"]
}

Ou se ele mandar:

HTTP
GET /products?order=up

Resposta:

JSON
{  "error": "Invalid sort order",  
	"allowedOrders": ["asc", "desc"]
}

5. Validações

Manual

TypeScript
const allowedSortFields = ['name', 'price', 'createdAt'] as const;
const allowedOrders = ['asc', 'desc'] as const;
 
app.get('/products', async (req, res) => {
  const sort = String(req.query.sort ?? 'createdAt');
  const order = String(req.query.order ?? 'desc');
 
  if (!allowedSortFields.includes(sort as any)) {
    return res.status(400).json({
      error: `Invalid sort field: ${sort}`,
    });
  }
 
  if (!allowedOrders.includes(order as any)) {
    return res.status(400).json({
      error: `Invalid order: ${order}`,
    });
  }
 
  const products = await db.product.findMany({
    orderBy: [
      { [sort]: order },
      { id: order },
    ],
  });
 
  res.json({ data: products });
});

Aqui melhoramos bastante:

  • validamos o campo
  • validamos a direção
  • adicionamos id como desempate

Exemplo com Zod

TypeScript
import { z } from 'zod';
 
const productQuerySchema = z.object({
  sort: z.enum(['name', 'price', 'createdAt']).default('createdAt'),
  order: z.enum(['asc', 'desc']).default('desc'),
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(10),
});
 
app.get('/products', async (req, res) => {
  const parsed = productQuerySchema.safeParse(req.query);
 
  if (!parsed.success) {
    return res.status(400).json({
      error: 'Invalid query params',
      details: parsed.error.flatten(),
    });
  }
 
  const { sort, order, page, limit } = parsed.data;
 
  const skip = (page - 1) * limit;
 
  const [products, total] = await Promise.all([
    db.product.findMany({
      skip,
      take: limit,
      orderBy: [
        { [sort]: order },
        { id: order },
      ],
    }),
 
    db.product.count(),
  ]);
 
  res.json({
    data: products,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
    sorting: {
      sort,
      order,
    },
  });
});
 

6. Ordenação e performance

Ordenar pode ser caro.

Exemplo:

SQL
SELECT *FROM productsORDER BY price ASC;

Se a tabela for pequena, ok. Se tiver milhões de registros, talvez precise de índice:

SQL
CREATE INDEX idx_products_price ON products(price);

Índices para ordenação

Se você ordena muito produtos por:

SQL
ORDER BY created_at DESC

pode fazer sentido ter índice:

SQL
CREATE INDEX idx_products_created_at ON products(created_at);

Se você filtra e ordena:

SQL
WHERE tenant_id = ?
AND status = ?
ORDER BY created_at DESC

talvez faça sentido índice composto:

SQL
CREATE INDEX idx_orders_tenant_status_created_at
ON orders(tenant_id, status, created_at);

Esse ponto é importante em sistemas multi-tenant porque o trenant quase sempre vai no select, a ordenação precisa ser pensada junto com o filtro obrigatório de tenant.


7. Ordenação com campos calculados

Às vezes o cliente quer ordenar por algo que não é uma coluna simples.

Exemplo:

SQL
GET /products?sort=discountPercentage&order=desc

Mas discountPercentage talvez seja calculado assim:

TypeScript
(originalPrice - currentPrice) / originalPrice

Isso pode ser mais caro.

Você pode resolver de algumas formas:

Calcular no banco

SQL
ORDER BY ((original_price - current_price) / original_price) DESC

Salvar campo calculado

TypeScript
discount_percentage

Atualizado quando preço muda.

Não permitir essa ordenação

Às vezes a melhor decisão é dizer: Essa API não suporta ordenação por esse campo.

8 Ordenação por múltiplos campos

Às vezes você quer ordenar por mais de um campo.

Exemplo:

HTTP
GET /products?sort=category,name&order=asc,asc

Significa:

ordenar por categoria e, dentro da categoria, por nome.

Mas esse padrão pode ficar meio confuso.

Outro padrão comum:

HTTP
GET /products?sort=category:asc,name:asc

Ou:

HTTP
GET /products?sort=category,-createdAt

Nesse último padrão:

TypeScript
category   = asc
-createdAt = desc

9. Quando ordenar no frontend é aceitável?

Pode ser aceitável quando:

  • você já tem todos os dados carregados
  • lista é pequena
  • é apenas ordenação visual
  • não há paginação real no backend

Exemplo: lista de 20 itens em memória

Ok ordenar no frontend. Mas para tabelas grandes, dados paginados ou dados vindos do banco, ordene no backend.


Sempre que você implementar ordenação, pergunte:

TXT
1. Qual é a ordenação padrão?
2. Quais campos o cliente pode ordenar?
3. O order aceita só asc/desc?
4. Tem critério de desempate?
5. Isso combina com paginação?
6. Existe índice para essa ordenação?
7. O campo é seguro para expor?
8. A resposta documenta a ordenação aplicada?

Resumo central

Ordenação não é só colocar ORDER BY. Ordenação bem feita envolve:

  • query params claros
  • valor padrão
  • validação
  • whitelist de campos
  • integração com paginação
  • critério de desempate
  • performance com índices
  • segurança
  • documentação