Por que ainda escrevo SQL à mão
O banco é o motor mais capaz da sua stack. Um query builder esconde essa capacidade. As queries que importam, eu mesmo escrevo.
Uso um ORM em todo produto que construo. Também escrevo SQL à mão em todo produto que construo, e o segundo hábito surpreende as pessoas mais do que deveria. Os dois não estão em conflito. Cobrem partes diferentes do mesmo trabalho, e a parte que escrevo à mão é onde a performance e a correção do produto de fato moram.
O banco é o motor mais capaz da stack. Ele ordena, agrega, aplica window functions, trava, deduplica e pagina milhões de linhas em milissegundos, com décadas de engenharia por trás de cada operação. Um query builder expõe uma fração disso. Para as queries que importam, quero tudo.
As queries que sempre escrevo eu mesmo
Relatórios e agregações. Qualquer coisa com group by, window function, common table expression ou lateral join. Builders conseguem expressar algumas dessas, mas o resultado lê como um quebra-cabeça e o SQL gerado costuma ser pior do que o planner gostaria. Escrevo a query, leio o plano, ajusto, e guardo como uma função nomeada com resultado tipado.
Paginação por keyset. Paginação por offset é o que todo builder te dá e degrada linearmente com o número da página, porque o banco ainda percorre cada linha pulada. Paginação por keyset, em que a query diz 'linhas depois deste id e deste timestamp', é tempo constante em qualquer profundidade e precisa de um where escrito à mão com comparação composta. Toda lista em que o usuário pode rolar fundo recebe isso.
Upserts e escritas condicionais. Insert on conflict update, com um where que só aplica o update se a versão que chega é mais nova. Isso é idempotência e controle de concorrência em um único statement, atômico, e a maioria dos builders ou não consegue expressar a condição ou expressa mal.
Operações de fila. Select for update skip locked é como você constrói uma fila de jobs confiável em um banco relacional sem broker, e já substituí vários message brokers por essa única linha. Nenhum builder que usei a gera de forma limpa.
Operações em massa. Atualizar cem mil linhas por um ORM são cem mil objetos e cem mil statements. Um único update com join a partir de uma lista de values é um statement e uma fração do tempo.
Mantendo seguro e tipado
SQL escrito à mão tem fama de injection, e essa fama pertence à concatenação de strings, não ao SQL. Toda query que escrevo usa parâmetros. O estilo de tagged template, em que os valores são interpolados como placeholders pela biblioteca e não como texto, torna o jeito seguro o jeito fácil e o jeito inseguro visivelmente diferente.
O resultado é tipado. Ou a ferramenta gera um tipo TypeScript a partir da query perguntando ao banco quais colunas ela retorna, ou eu declaro o tipo da linha e valido a primeira linha em testes contra um banco real. Os dois servem. O que não serve é uma query retornando any que é forçada a uma forma no ponto de chamada.
Queries vivem em módulos nomeados pelo agregado que servem, ao lado do código do ORM daquele agregado, e nada mais na base de código toca uma tabela diretamente. A camada de domínio chama funções. Ela não sabe quais são SQL e quais são o ORM, e esse é o ponto.
O que escrever SQL te ensina
O motivo real de manter o hábito é que ele me mantém honesto sobre o que o banco está fazendo. Quando você escreve o join, pensa no índice. Quando escreve a agregação, pensa na contagem de linhas. Quando escreve o lock, pensa na fronteira da transação. O ORM permite não pensar nessas coisas, o que é uma feature até o momento em que vira o incidente.
Em toda auditoria em que encontrei um banco lento, a correção começou com alguém lendo o SQL pela primeira vez. Prefiro que essa pessoa seja quem escreveu, no dia em que escreveu, com o plano aberto na aba ao lado.
Use o ORM para os noventa por cento. Escreva os dez por cento à mão, mantenha tipado e parametrizado, e leia o plano. Esses dez por cento são de onde vem a velocidade do produto, e merecem um autor humano.