Geolocalização Performática com Postgres e PostGIS: Uma abordagem prática no projeto Photofy
Source: Dev.to
Desenvolvendo o Photofy aqui em Paulista
Percebi que a busca tradicional por “cidade” era muito frustrante. Um fotógrafo que mora no Janga está colado em Olinda, mas se o cliente buscasse apenas por profissionais em Recife, o sistema simplesmente ignorava quem estava a poucos quarteirões de distância por causa de um limite invisível no banco de dados. A geolocalização não é apenas um recurso estético; ela determina se o contrato é viável, reduzindo o custo de deslocamento para o fotógrafo e trazendo resultados reais para o cliente.
O Problema da Fronteira Invisível
O erro comum é achar que a geografia respeita o texto. Buscar por city: "Recife" é frágil porque o banco não sabe que Olinda é vizinha de Recife.
Minha primeira ideia foi a mais óbvia (o famoso Naive Approach): puxar todos os usuários do banco para o Node.js e rodar a fórmula de Haversine na mão. Mas logo vi o problema: se o Photofy crescer para 10 ou 20 mil fotógrafos, eu sobrecarregaria a memória do servidor processando um array gigante a cada busca.
A saída mais inteligente foi usar o PostGIS, deixando essa matemática pesada para a engine do PostgreSQL, que já é otimizada para isso.
Por que PostGIS? (Teoria Rápida)
Para o projeto funcionar, precisei decidir entre dois tipos de dados espaciais:
| Tipo | Como trata a Terra |
|---|---|
| Geometry | Plano cartesiano |
| Geography | Esferoide (curvo) |
Escolhi Geography com o padrão SRID 4326 (WGS 84) — o mesmo usado pelo GPS dos smartphones. Isso me permitiu trabalhar com distâncias em metros de forma nativa.
Para a performance, implementei o índice GiST (Generalized Search Tree). Diferente do B‑Tree comum, o GiST trabalha com Bounding Boxes (caixas delimitadoras). Na prática, o banco descarta instantaneamente áreas enormes (como ignorar todo o estado da Paraíba) antes mesmo de começar a filtrar os fotógrafos disponíveis em Pernambuco.
Mão na Massa: Configurando o Banco
1. O Schema (prisma.schema)
model User {
// ... outros campos
// O tipo Unsupported força o Prisma a ignorar a validação JS,
// mas cria a coluna correta no banco
location Unsupported("geography(Point, 4326)")?
// Índice GiST é OBRIGATÓRIO para performance
@@index([location], name: "location_idx", type: Gist)
}
2. A Migration SQL
O Prisma gera o arquivo SQL, mas precisei adicionar manualmente o comando para habilitar a extensão no topo:
-- Habilitar a extensão ANTES de criar a tabela
CREATE EXTENSION IF NOT EXISTS postgis;
-- Alterar a tabela (gerado pelo Prisma)
ALTER TABLE "users" ADD COLUMN "location" geography(Point, 4326);
Integrando ao Backend (NestJS)
Com o banco pronto, conectei o backend via Raw Queries ($queryRaw) para garantir que o índice GiST fosse realmente utilizado. Para converter endereço em coordenada (geocoding), usei a API do Nominatim.
1. Serviço de Geocoding (simplificado)
// backend/src/utils/geocoding.service.ts
async getCoordinates(address: string) {
const params = new URLSearchParams({
q: address,
format: 'json',
limit: '1',
});
// O header User-Agent é obrigatório para a API do Nominatim
const response = await fetch(
`https://nominatim.openstreetmap.org/search?${params}`,
{ headers: { 'User-Agent': 'Photofy-Project' } },
);
const data = await response.json();
return data[0]
? { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) }
: null;
}
2. Busca Espacial (o pulo do gato)
Usamos o Raw Query para acessar as funções ST_DWithin (filtro de raio usando índice) e ST_Distance (cálculo exato de metros).
// backend/src/users/users.service.ts
// Interface essencial para o TypeScript entender o retorno do banco
interface UserWithDistance {
id: string;
name: string;
distance: number; // metros
}
async findNearbyPhotographers(
lat: number,
lng: number,
radiusKm: number,
): Promise {
// ST_SetSRID cria um ponto GPS válido (WGS 84)
const result = await this.prisma.$queryRaw`
SELECT
id,
name,
ST_Distance(
location,
ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography
) AS distance
FROM users
WHERE ST_DWithin(
location,
ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography,
${radiusKm * 1000}
)
ORDER BY distance ASC
`;
return result;
}
Conclusão: O Banco não é Apenas um Depósito de Dados
Essa jornada no Photofy me tirou da zona de conforto do “Code First” e me fez entender que o banco de dados não é apenas um depósito de dados, mas uma engine de computação poderosa. Delegar a lógica espacial para o PostGIS limpou meu código e protegeu o Event Loop do Node.js. Hoje, o sistema está pronto para escalar para milhares de usuários com custo computacional O(log N), conectando pessoas em Paulista, Olinda e Recife com base na distância real e não em simples comparações de texto.
Referências
- NestJS. Documentation: A progressive Node.js framework. 2024. Disponível em: . Acesso em: 11 fev. 2026.
- OpenStreetMap Foundation. Nominatim API v4.3.2 Documentation. 2023. Disponível em: . Acesso em: 11 fev. 2026.
- PostGIS Project. PostGIS 3.4.0 Manual. 2023. Disponível em: . Acesso em: 11 fev. 2026.
- **POSTGIS**. *PostGIS Documentation (Manual 3.4)*. 2024. Disponível em: . Acesso em: 11 fev. 2026.
- **POSTGRESQL GLOBAL DEVELOPMENT GROUP**. *PostgreSQL 16.0 Documentation*. 2023. Disponível em: . Acesso em: 11 fev. 2026.
- **PRISMA DATA, INC.** *Prisma Documentation: Working with MongoDB, PostgreSQL, and more*. 2024. Disponível em: . Acesso em: 11 fev. 2026.