<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aristoteles Lins da silva</title>
    <description>The latest articles on DEV Community by Aristoteles Lins da silva (@aristoteles_linsdasilva).</description>
    <link>https://dev.to/aristoteles_linsdasilva</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3769542%2F29ff325e-df5c-4bd1-87d1-4bf63a61d25b.png</url>
      <title>DEV Community: Aristoteles Lins da silva</title>
      <link>https://dev.to/aristoteles_linsdasilva</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aristoteles_linsdasilva"/>
    <language>en</language>
    <item>
      <title>Geolocalização Performática com Postgres e PostGIS: Um estudo de caso no projeto pessoal Photofy</title>
      <dc:creator>Aristoteles Lins da silva</dc:creator>
      <pubDate>Thu, 12 Feb 2026 19:30:29 +0000</pubDate>
      <link>https://dev.to/aristoteles_linsdasilva/geolocalizacao-performatica-com-postgres-e-postgis-uma-abordagem-pratica-no-projeto-photofy-4360</link>
      <guid>https://dev.to/aristoteles_linsdasilva/geolocalizacao-performatica-com-postgres-e-postgis-uma-abordagem-pratica-no-projeto-photofy-4360</guid>
      <description>&lt;p&gt;Ao desenvolver o &lt;strong&gt;Photofy&lt;/strong&gt; como um projeto pessoal aqui em Paulista, percebi que a busca tradicional por "cidade" era muito frustrante. Um fotógrafo que mora no Janga, em Paulista, está colado ao bairro de Rio Doce, 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. Percebi que a geolocalização não é apenas um recurso estético; é o que faz o contrato ser viável ou não, reduzindo o custo de deslocamento para o fotógrafo e trazendo resultados reais para o cliente.&lt;/p&gt;

&lt;h2&gt;
  
  
  O Problema da Fronteira Invisível
&lt;/h2&gt;

&lt;p&gt;O erro comum é acreditar que a geografia respeita o texto. Buscar apenas por city: 'Recife' é ineficiente porque o banco de dados realiza uma comparação exata de caracteres, ignorando que Olinda é vizinha de Recife. Dessa forma, o sistema desconsidera a proximidade física entre quem busca e os fotógrafos da região, limitando os resultados a nomes em uma tabela em vez da distância real entre as pessoas.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb5gyy44h44um9grdwuya.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb5gyy44h44um9grdwuya.png" alt="Mapa da Região Metropolitana do Recife destacando a proximidade entre Janga (Paulista), Rio Doce (Olinda) e áreas de Recife" width="800" height="618"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Figura 1:&lt;/strong&gt; A continuidade urbana na Região Metropolitana do Recife. Note como as fronteiras municipais são ignoradas pela proximidade real entre o Janga (Paulista) e Rio Doce (Olinda). Fonte: © OpenStreetMap contributors.&lt;/p&gt;

&lt;p&gt;Minha primeira ideia foi a mais óbvia (o famoso &lt;em&gt;Naive Approach&lt;/em&gt;): 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: quando o Photofy crescer para 10 ou 20 mil fotógrafos, eu ia sobrecarregar a memória do servidor processando um array gigante a cada busca. A saída mais inteligente que encontrei foi usar o &lt;strong&gt;PostGIS&lt;/strong&gt;, deixando essa matemática pesada para a engine do PostgreSQL, que já é otimizada para isso.&lt;/p&gt;
&lt;h2&gt;
  
  
  Por que PostGIS? (Teoria Rápida)
&lt;/h2&gt;

&lt;p&gt;Para o projeto funcionar, precisei decidir entre dois tipos de dados espaciais:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Geometry:&lt;/strong&gt; Trata a Terra como um plano cartesiano.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Geography:&lt;/strong&gt; Trata a Terra como um esferoide (curvo).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Escolhi o tipo &lt;strong&gt;Geography&lt;/strong&gt; 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. &lt;/p&gt;

&lt;p&gt;Para a performance, implementei o índice &lt;strong&gt;GiST&lt;/strong&gt; (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 um estado inteiro) antes mesmo de começar a filtrar os fotógrafos disponíveis em Pernambuco.&lt;/p&gt;
&lt;h2&gt;
  
  
  Mão na Massa: Configurando o Banco
&lt;/h2&gt;

&lt;p&gt;Na hora de codar com &lt;strong&gt;NestJS&lt;/strong&gt; e &lt;strong&gt;Prisma&lt;/strong&gt;, a realidade foi um pouco mais desafiadora. Como o Prisma ainda é limitado com tipos espaciais nativos, o "pulo do gato" foi usar o tipo &lt;code&gt;Unsupported&lt;/code&gt; no schema e editar a migration manualmente.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. O Schema (prisma.schema)
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  2. A Migration SQL
&lt;/h3&gt;

&lt;p&gt;O Prisma gera o arquivo SQL, mas precisei adicionar manualmente o comando para habilitar a extensão no topo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Habilitar a extensão ANTES de criar a tabela&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;postgis&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Alterar a tabela (gerado pelo Prisma)&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"users"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="nv"&gt;"location"&lt;/span&gt; &lt;span class="n"&gt;geography&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Point&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4326&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Integrando ao Backend (NestJS)
&lt;/h2&gt;

&lt;p&gt;Com o banco pronto, conectei o backend via &lt;strong&gt;Raw Queries&lt;/strong&gt; (&lt;code&gt;$queryRaw&lt;/code&gt;) para garantir que o índice GiST fosse realmente utilizado. Para converter endereço em coordenada (Geocoding), usei a API do Nominatim.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. O Serviço de Geocoding (Simplificado)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getCoordinates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// O header User-Agent é obrigatório para a API do Nominatim&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://nominatim.openstreetmap.org/search?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User-Agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Photofy-Project&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. A Busca Espacial
&lt;/h3&gt;

&lt;p&gt;Aqui usamos o Raw Query para acessar as funções &lt;code&gt;ST_DWithin&lt;/code&gt; (filtro de raio usando índice) e &lt;code&gt;ST_Distance&lt;/code&gt; (calculo exato de metros).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Interface essencial para o TypeScript entender o retorno do banco&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;UserWithDistance&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; 
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;findNearbyPhotographers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;radiusKm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ST_SetSRID cria um ponto GPS válido (WGS 84)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$queryRaw&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;UserWithDistance&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;`
    SELECT 
      id, name,
      ST_Distance(
        location, 
        ST_SetSRID(ST_MakePoint(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;), 4326)::geography
      ) as distance
    FROM users
    WHERE 
      ST_DWithin(
        location,
        ST_SetSRID(ST_MakePoint(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;), 4326)::geography,
        &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;radiusKm&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
      )
    ORDER BY distance ASC
  `&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusão: O Banco não é apenas um Depósito de Dados
&lt;/h2&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Nota sobre o Código
&lt;/h2&gt;

&lt;p&gt;Este artigo é um recorte técnico de um estudo de caso em um projeto pessoal. Por razões de segurança e estágio atual de desenvolvimento, o repositório completo permanece privado. No entanto, os trechos de código compartilhados acima são representações fiéis da lógica utilizada no sistema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Referências
&lt;/h2&gt;

&lt;p&gt;NESTJS. Documentation: A progressive Node.js framework. 2024. Disponível em: &lt;a href="https://docs.nestjs.com/" rel="noopener noreferrer"&gt;https://docs.nestjs.com/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;OPENSTREETMAP FOUNDATION. Nominatim API v4.3.2 Documentation. 2023. Disponível em: &lt;a href="https://nominatim.org/release-docs/latest/api/Overview/" rel="noopener noreferrer"&gt;https://nominatim.org/release-docs/latest/api/Overview/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;POSTGIS PROJECT. PostGIS 3.4.0 Manual. 2023. Disponível em: &lt;a href="https://postgis.net/docs/manual-3.4/" rel="noopener noreferrer"&gt;https://postgis.net/docs/manual-3.4/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;POSTGRESQL GLOBAL DEVELOPMENT GROUP. PostgreSQL 16.0 Documentation. 2023. Disponível em: &lt;a href="https://www.postgresql.org/docs/16/index.html" rel="noopener noreferrer"&gt;https://www.postgresql.org/docs/16/index.html&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;PRISMA DATA, INC. Prisma Documentation: Working with MongoDB, PostgreSQL, and more. 2024. Disponível em: &lt;a href="https://www.prisma.io/docs" rel="noopener noreferrer"&gt;https://www.prisma.io/docs&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>postgis</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
