Geolocalização Performática com Postgres e PostGIS: Uma abordagem prática no projeto Photofy

Published: (February 12, 2026 at 02:30 PM EST)
5 min read
Source: Dev.to

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:

TipoComo trata a Terra
GeometryPlano cartesiano
GeographyEsferoide (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.
0 views
Back to Blog

Related posts

Read more »

Partial Indexes in PostgreSQL

Partial indexes are refined indexes that target specific access patterns. Instead of indexing every row in a table, they only index the rows that match a condit...