<?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: Alessandro Nunes</title>
    <description>The latest articles on DEV Community by Alessandro Nunes (@alessandronuunes).</description>
    <link>https://dev.to/alessandronuunes</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2690761%2F48dce546-e041-4e9e-ad92-355e0e023c36.jpg</url>
      <title>DEV Community: Alessandro Nunes</title>
      <link>https://dev.to/alessandronuunes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alessandronuunes"/>
    <language>en</language>
    <item>
      <title>Cache impecavel e o app continuava lento</title>
      <dc:creator>Alessandro Nunes</dc:creator>
      <pubDate>Sat, 25 Jul 2026 14:39:17 +0000</pubDate>
      <link>https://dev.to/alessandronuunes/cache-impecavel-e-o-app-continuava-lento-347i</link>
      <guid>https://dev.to/alessandronuunes/cache-impecavel-e-o-app-continuava-lento-347i</guid>
      <description>&lt;p&gt;Trocar de página no meu SaaS estava arrastando. Não era lentidão de deixar o usuário esperando dez segundos, era aquela demora curta e constante que faz o app parecer barato. Sentei pra resolver com a hipótese pronta na cabeça: falta cache em algum lugar.&lt;/p&gt;

&lt;p&gt;Estava errado, e a hipótese errada era exatamente o que me impedia de achar o problema. O gargalo não estava em nada que eu pudesse cachear. Estava numa camada que eu tinha parado de enxergar faz tempo, porque ela funciona bem demais pra chamar atenção.&lt;/p&gt;

&lt;h2&gt;
  
  
  O que eu já tinha, e por que não adiantava nada
&lt;/h2&gt;

&lt;p&gt;Vale começar por aqui, porque é a parte contraintuitiva. Cache no meu projeto não é improviso. Tem duas fachadas finas, &lt;code&gt;TenantCache&lt;/code&gt; e &lt;code&gt;GlobalCache&lt;/code&gt;, que embrulham o cache do Laravel e garantem três coisas: toda chave nasce com escopo de time (&lt;code&gt;t{teamId}:{domínio}:{sufixo}&lt;/code&gt;), toda entrada nasce com TTL vindo de um enum (não existe "cachear pra sempre") e toda entrada nasce com tag pra invalidação em bloco. Tem &lt;code&gt;flexible&lt;/code&gt; pro stale-while-revalidate nos lugares quentes, tem observer invalidando por domínio quando o dado muda, e tem teste de arquitetura proibindo &lt;code&gt;Cache::&lt;/code&gt; e &lt;code&gt;cache()&lt;/code&gt; direto no código de domínio, pra ninguém (nem a IA, nem eu às 2 da manhã) furar a fila.&lt;/p&gt;

&lt;p&gt;É a parte da infra que eu tenho mais orgulho. E ela não tinha nada a ver com o problema.&lt;/p&gt;

&lt;p&gt;A lição, que eu só entendi depois de perder um tempo bom procurando no lugar errado: &lt;strong&gt;cache resolve computação cara que se repete. Ele não resolve payload que é remontado do zero em toda navegação.&lt;/strong&gt; Se o custo é "vinte queries baratinhas que rodam sempre", cache não te salva, porque não tem uma resposta cara pra guardar. Só tem muita coisa pequena acontecendo demais.&lt;/p&gt;

&lt;h2&gt;
  
  
  Onde estava, de verdade
&lt;/h2&gt;

&lt;p&gt;No Inertia, cada navegação é um request completo pro servidor. E existe um lugar central, o &lt;code&gt;HandleInertiaRequests&lt;/code&gt;, que monta os props compartilhados que toda página recebe: usuário logado, times do usuário, time atual, permissões, features do plano, contadores da navegação. Isso remonta &lt;strong&gt;em toda navegação&lt;/strong&gt;. É o código mais executado do sistema inteiro, e o que menos aparece quando você vai investigar performance, porque ninguém abre o middleware de shared props procurando query lenta.&lt;/p&gt;

&lt;p&gt;Liguei o log de queries e contei. Um usuário com 10 times pagava 27 queries por navegação. Duas causas, e as duas são clássicas com fantasia nova.&lt;/p&gt;

&lt;p&gt;A primeira é um N+1 escondido atrás de um método de conveniência. O &lt;code&gt;toUserTeams()&lt;/code&gt; carrega os times numa query só, bonito, e depois mapeia cada time pra montar o payload. Só que dentro desse map ele chama &lt;code&gt;teamRole($team)&lt;/code&gt; pra saber o papel do usuário naquele time, e o &lt;code&gt;teamRole()&lt;/code&gt; era uma query. Uma por time. O eager loading estava lá, na relação errada: eu carregava os times e continuava buscando as memberships uma a uma. Cada time novo que o usuário entrasse adicionava uma query a cada clique dele no app pra sempre.&lt;/p&gt;

&lt;p&gt;A segunda foi a que mais me irritou, porque é uma pegadinha específica de Inertia. Os props são closures, e closures avaliam de forma independente:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Http/Middleware/HandleInertiaRequests.php&lt;/span&gt;
&lt;span class="s1"&gt;'currentTeam'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resolveCurrentUserTeam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="s1"&gt;'teamPermissions'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toTeamPermissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resolveActiveTeam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="s1"&gt;'teamFeatures'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;teamFeatures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resolveActiveTeam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="s1"&gt;'teamTrial'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;teamTrial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resolveActiveTeam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quatro props diferentes precisam do time ativo, e o &lt;code&gt;resolveActiveTeam&lt;/code&gt; resolvia tudo do zero nas quatro vezes: lê a sessão, busca o time, confere se o usuário pertence a ele. Quatro vezes o mesmo trabalho, todo request. Não tem nada de errado com o código de cada linha isolada, e é justamente por isso que passa despercebido em code review: o problema não está em nenhuma delas, está no fato de serem quatro.&lt;/p&gt;

&lt;p&gt;O conserto das duas cabe em pouca coisa. Carrega a relação uma vez, no ponto onde o payload é montado:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Http/Middleware/HandleInertiaRequests.php&lt;/span&gt;
&lt;span class="c1"&gt;// Uma leitura de membership pro payload inteiro: teams, currentTeam e&lt;/span&gt;
&lt;span class="c1"&gt;// teamPermissions todos resolvem papel, e toUserTeams() faz isso uma vez por time.&lt;/span&gt;
&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'teamMemberships'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;E o &lt;code&gt;teamRole()&lt;/code&gt; passa a preferir a relação quando ela já está em memória:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Concerns/HasTeams.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;teamRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Team&lt;/span&gt; &lt;span class="nv"&gt;$team&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?TeamRole&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;relationLoaded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'teamMemberships'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;teamMemberships&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;firstWhere&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'team_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;teamMemberships&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'team_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;role&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;p&gt;Mais a memoização do time ativo, e são 15 queries em vez de 27. O número menor é bom, mas não é o ponto. &lt;strong&gt;O ponto é que agora é constante.&lt;/strong&gt; Antes, o custo de cada navegação crescia junto com a quantidade de times do usuário, o que significa que o app ficava mais lento exatamente pros clientes mais engajados. Esse é o tipo de curva que você não quer descobrir em produção.&lt;/p&gt;

&lt;h2&gt;
  
  
  A correção óbvia quebrou 208 testes, e ela estava certa em quebrar
&lt;/h2&gt;

&lt;p&gt;Agora a parte que interessa mais que o ganho.&lt;/p&gt;

&lt;p&gt;Se &lt;code&gt;teamRole()&lt;/code&gt; fica mais rápido lendo da relação carregada, o &lt;code&gt;belongsToTeam()&lt;/code&gt; também deveria, certo? É a mesma tabela, a mesma pergunta, e ele é chamado várias vezes por request. Fiz a mudança óbvia. &lt;strong&gt;208 testes ficaram vermelhos.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;O primeiro impulso quando 208 testes quebram de uma vez é achar que os testes estão errados, ou que é setup de teste, alguma coisa de fixture. Não era. Era um bug de verdade, e o cenário é este:&lt;/p&gt;

&lt;p&gt;O usuário aceita um convite pra um time. No meio desse request, a membership é criada no banco. Logo depois, no mesmo request, o &lt;code&gt;switchTeam()&lt;/code&gt; chama &lt;code&gt;belongsToTeam()&lt;/code&gt; pra confirmar que ele pode entrar naquele time antes de trocar. Com a relação carregada em memória, essa leitura devolve o estado de &lt;strong&gt;antes&lt;/strong&gt; da escrita que acabou de acontecer. A membership existe no banco, e o objeto na memória não sabe. O &lt;code&gt;belongsToTeam()&lt;/code&gt; responde "não pertence", o &lt;code&gt;switchTeam()&lt;/code&gt; desiste, e o usuário aceita o convite e continua no time anterior.&lt;/p&gt;

&lt;p&gt;Sem erro. Sem exceção. Sem log. A tela recarrega e ele está no lugar errado.&lt;/p&gt;

&lt;p&gt;Esse é o mesmo inimigo dos dois posts anteriores com outra roupa. Lá foi um teste verde guardando a porta errada, e uma cláusula falsa escrita com confiança porque era plausível. Aqui é uma otimização legítima que troca correção por velocidade num caminho que ninguém percorre com frequência suficiente pra notar. &lt;strong&gt;Otimização não avisa quando quebra alguma coisa. Ela fica rápida e errada, que é bem pior que devagar e certa.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Então &lt;code&gt;belongsToTeam()&lt;/code&gt; continua sendo query de propósito, e isso virou comentário no código, porque uma decisão dessas sem o porquê escrito ao lado dura até a próxima pessoa (ou o próximo prompt) achar que está otimizando:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Concerns/HasTeams.php&lt;/span&gt;
&lt;span class="cd"&gt;/**
 * Deliberadamente sempre query, diferente do teamRole(). O switchTeam() gateia
 * nisso logo depois de um convite criar a membership, e uma relação pré-carregada
 * ainda estaria mostrando o estado de antes dessa escrita: a troca falharia calada
 * e deixaria o usuário no time anterior.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O critério geral que eu tirei disso, e que eu vou levar pros próximos: &lt;strong&gt;relação em cache é segura pra leitura de exibição, e perigosa pra leitura de decisão.&lt;/strong&gt; Montar o payload que a tela vai renderizar pode ler de memória à vontade, o pior caso é mostrar um papel desatualizado por um request. Autorizar, gatear, decidir se o usuário pode entrar, isso precisa da verdade do banco naquele instante. E é exatamente por isso que a relação é carregada no middleware, na hora de montar a resposta, e não dentro do model. Se eu carregasse dentro do model, ela ficaria grudada ali pelo resto do request e envenenaria toda decisão que viesse depois.&lt;/p&gt;

&lt;h2&gt;
  
  
  O teste que trava a regressão sem me atrapalhar depois
&lt;/h2&gt;

&lt;p&gt;Otimização sem teste volta atrás sozinha na terceira feature. Mas eu não queria um teste que trava número absoluto de queries, porque esse tipo de teste vira inimigo: qualquer feature nova que adicione uma query legítima deixa o build vermelho, alguém sobe o número, e em três meses ele não guarda mais nada.&lt;/p&gt;

&lt;p&gt;Então o teste principal é de escala, não de contagem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tests/Feature/SharedPropsQueryBudgetTest.php&lt;/span&gt;
&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'props compartilhados custam o mesmo, independente de quantos times o usuário tem'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$team&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;currentTeam&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nv"&gt;$comUmTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;queriesForVisit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$team&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;Team&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Team&lt;/span&gt; &lt;span class="nv"&gt;$extra&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$extra&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;members&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;TeamRole&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Member&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Se teamRole() virar query por chamada de novo, cada time extra soma&lt;/span&gt;
    &lt;span class="c1"&gt;// uma query a toda navegação do app. Aqui isso fica vermelho na hora.&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;queriesForVisit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$team&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$comUmTime&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;p&gt;Ele não diz "custe 15 queries", diz "custe o mesmo com 1 time e com 10". Ou seja, ele não me atrapalha quando eu adicionar uma query honesta, e só abre a boca quando alguém reintroduzir a curva.&lt;/p&gt;

&lt;p&gt;Duas armadilhas na hora de escrever isso, e as duas fariam o teste passar mentindo. A primeira: o usuário precisa ser rebuscado do banco a cada visita, senão a segunda visita começa com a relação já carregada da primeira e esconde justamente a regressão que o teste existe pra pegar. A segunda: precisa dar &lt;code&gt;Cache::flush()&lt;/code&gt; entre as medições, porque o store de array usado em teste sobrevive entre requests, e a segunda visita sairia "mais barata" por servir contador de navegação do cache, um motivo que não tem nada a ver com o que está sendo medido.&lt;/p&gt;

&lt;h2&gt;
  
  
  E a parte que faltava: eu não media nada
&lt;/h2&gt;

&lt;p&gt;O achado mais constrangedor da sessão não foi o N+1. Foi perceber que eu descobri o N+1 na mão, contando query com log ligado, porque &lt;strong&gt;não existia nenhuma medição de performance no projeto&lt;/strong&gt;. Um SaaS em produção, com fila, com cache tenant-aware, com multi-tenancy, e zero visibilidade de quanto custa um request.&lt;/p&gt;

&lt;p&gt;Entraram os dois oficiais do Laravel, com papéis bem separados. Telescope pra local, que é onde você quer ver a lista crua de queries de um request específico. Pulse pra produção, que é onde você quer tendência ao longo do tempo. Telescope é dependência de dev, excluído do package discovery e registrado à mão só quando &lt;code&gt;APP_ENV=local&lt;/code&gt;, porque Telescope ligado em produção é ele próprio um problema de performance.&lt;/p&gt;

&lt;p&gt;O Pulse tem um detalhe que quase ninguém conta e que vale o post inteiro: &lt;strong&gt;no default, ele grava direto no banco, em todo request&lt;/strong&gt;. Ou seja, se você instalar e sair usando, as tabelas &lt;code&gt;pulse_*&lt;/code&gt; viram o gargalo que o Pulse existe pra medir. As travas que eu coloquei:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trava&lt;/th&gt;
&lt;th&gt;Por quê&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PULSE_INGEST_DRIVER=redis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Request grava num stream Redis e um daemon (&lt;code&gt;pulse:work&lt;/code&gt;) drena em lote. É a trava principal.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis DB 2, separado&lt;/td&gt;
&lt;td&gt;Exigência do próprio Pulse: ele nunca deve dividir conexão com a fila que o Horizon drena.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PULSE_STORAGE_KEEP="7 days"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Retenção com poda automática. Sem isso as tabelas crescem pra sempre.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;sample_rate&lt;/code&gt; 0.1 nos recorders de volume&lt;/td&gt;
&lt;td&gt;Grava ~10% e escala o número no dashboard.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recorder de cache desligado&lt;/td&gt;
&lt;td&gt;Com &lt;code&gt;TenantCache&lt;/code&gt; rodando em todo request, registrar cada hit/miss é a via mais rápida de inchar tudo. Ligo só pra investigar taxa de acerto, e desligo depois.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;E aí, no meio disso, mais uma falha silenciosa, que já virou o tema da minha vida. O &lt;code&gt;PULSE_STORAGE_KEEP&lt;/code&gt; espera uma string de intervalo, &lt;code&gt;"7 days"&lt;/code&gt;. Se você escrever &lt;code&gt;7&lt;/code&gt;, o &lt;code&gt;CarbonInterval&lt;/code&gt; estoura com &lt;code&gt;Invalid part 7 in definition 7&lt;/code&gt;. Só que o Pulse engole exceções por design, pra nunca derrubar a aplicação por causa de telemetria. Resultado: a poda falha calada, você não vê nada de errado no app, e descobre daqui a uns meses quando o banco estiver gordo sem explicação. Está anotado no runbook de deploy junto com o resto.&lt;/p&gt;

&lt;h2&gt;
  
  
  As 3 regras que eu tiro disso
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Antes de cachear, conte.&lt;/strong&gt; Cache é a primeira hipótese de todo mundo e resolve um problema específico: computação cara que se repete. Se o custo é muita coisa pequena rodando sempre, você não precisa de cache, precisa de menos queries. Ligue o log e conte antes de decidir.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Olhe primeiro o código que roda em toda navegação.&lt;/strong&gt; No Inertia são os shared props, no seu stack vai ser outro nome. É o código mais executado do sistema e o menos revisado, porque ele não pertence a nenhuma feature. Uma query desnecessária ali custa mais que uma query lenta numa página que ninguém abre.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relação em cache é pra exibir, nunca pra decidir.&lt;/strong&gt; Ler de memória pra montar a tela, tudo bem. Ler de memória pra autorizar, gatear ou trocar de contexto, não: se alguma coisa escreveu no meio do request, você decide com dado velho e falha sem barulho.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Pra fechar, com honestidade
&lt;/h2&gt;

&lt;p&gt;O ganho real aqui não foi de 27 pra 15 queries, foi trocar uma curva por uma constante. E eu só achei isso porque a hipótese fácil (cache) não colou e eu fui obrigado a medir de verdade.&lt;/p&gt;

&lt;p&gt;Ficou dívida à vista, e eu deixei escrita no próprio teste em vez de fingir que não existe: uma página com escopo de time ainda faz quatro checagens de membership por request, duas delas em middlewares que rodam colados e poderiam dividir uma leitura só. O teste trava em quatro com uma regra clara, esse número pode diminuir, nunca aumentar. Se um dia virar cinco, é porque alguém enfiou mais uma checagem no pipeline sem perceber, e eu quero saber no mesmo dia.&lt;/p&gt;

&lt;p&gt;Se o seu app tem "aquela lentidão que ninguém explica", meu palpite é que ela não está na página que você está olhando. Vai no seu middleware de props compartilhados, liga o log de query e conta. Me conta o que você achou.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>inertia</category>
      <category>performace</category>
      <category>multitenacy</category>
    </item>
    <item>
      <title>Politica de privacidade e promessa que o codigo precisa cumprir</title>
      <dc:creator>Alessandro Nunes</dc:creator>
      <pubDate>Sat, 25 Jul 2026 14:37:18 +0000</pubDate>
      <link>https://dev.to/alessandronuunes/politica-de-privacidade-e-promessa-que-o-codigo-precisa-cumprir-5n5</link>
      <guid>https://dev.to/alessandronuunes/politica-de-privacidade-e-promessa-que-o-codigo-precisa-cumprir-5n5</guid>
      <description>&lt;p&gt;Eu estava configurando login com Google, GitHub e Apple no meu SaaS, e pra publicar o app no Google você precisa de uma home pública com link pra termos de uso e política de privacidade. Os meus eram cinco seções de template genérico, daqueles que servem pra qualquer produto e por isso não servem pra nenhum. Sentei pra reescrever querendo uma coisa só: passar autoridade pro revisor.&lt;/p&gt;

&lt;p&gt;O que aconteceu foi melhor, e bem mais desconfortável. Quando o documento passou a descrever o que o sistema &lt;strong&gt;realmente&lt;/strong&gt; faz, ele começou a acusar o que o sistema não faz. Política honesta é auditoria de código disfarçada de burocracia, e eu não tinha percebido isso em nenhum dos SaaS que já escrevi.&lt;/p&gt;

&lt;h2&gt;
  
  
  O que o documento acusou
&lt;/h2&gt;

&lt;p&gt;A política dizia que os dados ficam 30 dias depois do cancelamento e depois são excluídos em definitivo. Bonito, padrão de mercado, e eu tinha certeza de que era verdade porque o meu &lt;code&gt;Team&lt;/code&gt; usa &lt;code&gt;SoftDeletes&lt;/code&gt;. Fui conferir: não existe &lt;strong&gt;um único &lt;code&gt;forceDelete&lt;/code&gt; no código de produção inteiro&lt;/strong&gt;. Um Time excluído há dois anos continua no banco, inteirinho. O soft delete que eu achava que era metade da solução era na verdade o problema, porque ele me dava a sensação de ter implementado retenção quando eu só tinha implementado "não apagar".&lt;/p&gt;

&lt;p&gt;E aí veio a parte que me fez rir de nervoso. Fui olhar o outro lado, a exclusão de usuário:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Http/Controllers/Settings/ProfileController.php&lt;/span&gt;
&lt;span class="nc"&gt;Auth&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// User não usa SoftDeletes. Isso é hard delete, agora.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ou seja, a mesma frase da política estava errada nas duas pontas ao mesmo tempo, e em direções opostas: o Time ficava pra sempre, o usuário sumia na hora, e nenhum dos dois tinha a tal janela de 30 dias que eu prometia por escrito.&lt;/p&gt;

&lt;p&gt;Depois foi a vez dos logs. A política falava em guardar registro de acesso por 12 meses, citando o art. 15 do Marco Civil, que é a coisa certa a se dizer. O meu config estava assim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/activitylog.php&lt;/span&gt;
&lt;span class="s1"&gt;'delete_records_older_than_days'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// null = pra sempre&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;E esse log grava IP e user agent de toda ação. O art. 15 é prazo &lt;strong&gt;mínimo&lt;/strong&gt; de guarda, nunca foi licença pra guardar eternamente, então eu estava violando o meu próprio documento pra pior.&lt;/p&gt;

&lt;p&gt;O terceiro achado foi exportação de dados. A política dizia que o dono do time exporta os dados dele pelo painel, e não existe rota pra isso. O que existe é um endpoint na API que exporta um usuário final, que é outra coisa completamente: é ferramenta pro meu cliente atender os titulares dele, não pra mim atender ele. Eu tinha confundido as duas.&lt;/p&gt;

&lt;h2&gt;
  
  
  A cláusula falsa que ninguém mentiu pra escrever
&lt;/h2&gt;

&lt;p&gt;Agora a parte que interessa mais que os achados, no mesmo espírito do teste verde que me enganou por meses no post anterior.&lt;/p&gt;

&lt;p&gt;Aquela frase sobre exportação de dados no painel foi escrita &lt;strong&gt;na mesma sessão&lt;/strong&gt;, poucas horas antes de eu descobrir que era falsa. Ninguém mentiu. A IA leu o código, viu um &lt;code&gt;DataExportsController&lt;/code&gt;, viu que ele exporta dados, e deduziu que existia exportação. É uma dedução razoável, escrita com confiança, e completamente errada. Se eu não tivesse pedido pra conferir cláusula por cláusula contra o código, ela ia pra produção com cara de verdade.&lt;/p&gt;

&lt;p&gt;É exatamente o mesmo inimigo do post passado, com outra roupa. Lá, a IA copiava o padrão errado porque ele era o mais visível no código. Aqui, ela escreveu o que era plausível porque plausível é o que ela otimiza. &lt;strong&gt;Imitação convincente é o modo de falha, não a mentira.&lt;/strong&gt; E texto jurídico é o pior lugar possível pra isso acontecer, porque ninguém revisa política de privacidade com o mesmo rigor que revisa um pull request. O documento passa batido justamente por ser chato.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identidade em config, não no texto
&lt;/h2&gt;

&lt;p&gt;Duas coisas viraram código e valem pra qualquer projeto.&lt;/p&gt;

&lt;p&gt;A primeira é boba e resolve uma dor real: tirar a identidade da empresa e os prazos de dentro do texto. Virou um &lt;code&gt;config/legal.php&lt;/code&gt; com razão social, CNPJ, endereço, foro, emails e os prazos de retenção e de resposta. O texto legal só interpola &lt;code&gt;:taxId&lt;/code&gt;, &lt;code&gt;:retentionDays&lt;/code&gt;, &lt;code&gt;:privacyEmail&lt;/code&gt;. Mudar de endereço agora é uma linha de env em vez de uma caçada em dois idiomas, e o prazo publicado passa a ter um lugar único de verdade.&lt;/p&gt;

&lt;p&gt;Teve um detalhe pequeno aí que eu gostei mais do que devia. O default vazio renderiza "inscrita no CNPJ sob o nº ." e ninguém enxerga isso numa revisão, porque o olho pula frase quebrada em texto jurídico. Então o default virou &lt;code&gt;[CNPJ]&lt;/code&gt; literal, que é impossível de publicar sem querer.&lt;/p&gt;

&lt;h2&gt;
  
  
  O gate, porque regra sem gate é sugestão
&lt;/h2&gt;

&lt;p&gt;A segunda é a que fecha o ciclo. Se o texto interpola &lt;code&gt;:taxId&lt;/code&gt; e a chave não existir na config, o Laravel não dá erro: ele renderiza &lt;code&gt;:taxId&lt;/code&gt; literal, cru, no meio da política. O leitor vê, eu não. É falha silenciosa, que é o tipo que eu mais odeio.&lt;/p&gt;

&lt;p&gt;Então o texto legal ganhou um teste que varre todas as strings, extrai cada token e falha se ele não tiver par:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tests/Feature/LegalPagesTest.php&lt;/span&gt;
&lt;span class="c1"&gt;// Token sem par não dá erro, renderiza literal. Este teste transforma isso em falha.&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertContains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$known&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"landing.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; interpola :&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, que não é compartilhado em config/legal.php"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Junto com ele, um teste que percorre as 29 seções dos dois documentos nos dois idiomas e falha se qualquer uma resolver pra vazio. É a mesma tese do post anterior aplicada num lugar que eu nunca tinha pensado em testar: se importa, vira teste que quebra o build. Política de privacidade importa, então vira teste como qualquer outra regra que eu levo a sério.&lt;/p&gt;

&lt;h2&gt;
  
  
  O que fez o documento parar de parecer genérico
&lt;/h2&gt;

&lt;p&gt;Uma coisa que eu não esperava: o que dá autoridade não é o documento ser bem escrito, é ele bater com o que o app faz.&lt;/p&gt;

&lt;p&gt;O meu produto tem dois papéis ao mesmo tempo. Eu sou &lt;strong&gt;controlador&lt;/strong&gt; dos dados da conta do meu cliente (cadastro, login, cobrança), e &lt;strong&gt;operador&lt;/strong&gt; dos dados dos usuários finais dele, que chegam pelo widget que ele instala no produto dele. São regimes jurídicos diferentes, com responsabilidades diferentes, no mesmo banco de dados. Nenhum template tem essa distinção, porque template não sabe o que o seu produto faz.&lt;/p&gt;

&lt;p&gt;Colocar isso em seção própria foi o que mudou o texto de "genérico bonito" pra "esse cara sabe do que está falando". E é justamente a primeira coisa que o jurídico de um cliente B2B maior vai procurar.&lt;/p&gt;

&lt;h2&gt;
  
  
  As 3 regras que eu tiro disso
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Escreva a política contra o código, não contra um template.&lt;/strong&gt; Cada cláusula é uma afirmação verificável sobre o seu sistema. Se você não consegue apontar o arquivo que cumpre a frase, a frase é ficção.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prazo publicado é prazo exigível.&lt;/strong&gt; No minuto que você escreve "30 dias" ou "12 meses", isso deixou de ser aspiração e virou dívida. Ou o agendador cumpre, ou o número sai do texto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Texto jurídico também merece gate.&lt;/strong&gt; É o lugar onde falha silenciosa mais sobrevive, porque ninguém revisa com carinho e o failure mode não é erro, é o usuário lendo &lt;code&gt;:taxId&lt;/code&gt; no meio da sua política.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Pra fechar, com honestidade
&lt;/h2&gt;

&lt;p&gt;Eu não publiquei nada disso ainda. As três falhas viraram issue no meu repo, e a da retenção é bloqueante: enquanto Time excluído ficar pra sempre no banco, o documento promete o que o código não faz, e publicar assim é trocar um problema de template por um problema de LGPD. A poda de log é a mais barata das três, config mais uma linha no agendador, e ainda assim estava lá.&lt;/p&gt;

&lt;p&gt;As frases falsas eu corrigi na hora pra descreverem o processo real de hoje, que no caso da exportação é um pedido manual por email. É menos bonito que "exporte pelo painel" e tem a vantagem de ser verdade.&lt;/p&gt;

&lt;p&gt;Se você tem um SaaS rodando agora, faz um teste: abre sua política de privacidade e escolhe uma frase, qualquer uma, que prometa prazo. Vai no código procurar quem cumpre. Me conta o que você achou.&lt;/p&gt;

</description>
      <category>lgpd</category>
      <category>privacidade</category>
      <category>laravel</category>
      <category>ia</category>
    </item>
    <item>
      <title>Testes de arquitetura como guardrail pra IA</title>
      <dc:creator>Alessandro Nunes</dc:creator>
      <pubDate>Sat, 11 Jul 2026 13:52:24 +0000</pubDate>
      <link>https://dev.to/alessandronuunes/testes-de-arquitetura-como-guardrail-pra-ia-25ef</link>
      <guid>https://dev.to/alessandronuunes/testes-de-arquitetura-como-guardrail-pra-ia-25ef</guid>
      <description>&lt;p&gt;Eu construo SaaS sozinho e programo com IA o dia inteiro, ela é rápida, entrega feature, resolve bug às 2 da manhã, mas ela tem um defeito que ninguém te conta no começo: &lt;strong&gt;ela não respeita a sua arquitetura&lt;/strong&gt;. Ela lê o padrão, concorda com você, e cinco prompts depois copia o jeito errado que já existe no código, porque o objetivo dela é "fazer funcionar", não "manter o padrão".&lt;/p&gt;

&lt;p&gt;Eu descobri isso do jeito mais concreto possível: mandei fazer uma auditoria no meu próprio SaaS e o resultado foi que &lt;strong&gt;89 pontos do código chamavam uma classe do jeito que o meu próprio manual de arquitetura proíbe&lt;/strong&gt;, contra só 6 no jeito certo, o errado tinha virado a maioria, e cada vez que eu pedia uma feature nova, a IA olhava, via que "todo mundo faz assim", e fazia igual.&lt;/p&gt;

&lt;p&gt;A lição não é "pare de usar IA", é que o que importa de verdade você não protege com documentação, protege com um &lt;strong&gt;teste que quebra o build&lt;/strong&gt;, esse post é sobre a técnica que está segurando a minha barra, e sobre o furo dela que me ensinou mais que o acerto.&lt;/p&gt;

&lt;h2&gt;
  
  
  Documentação não segura a IA
&lt;/h2&gt;

&lt;p&gt;Eu tenho um &lt;code&gt;CLAUDE.md&lt;/code&gt; e ADRs (registros de decisão de arquitetura) caprichados. A IA lê, e ignora quando dá, não por má vontade: é que um arquivo de texto é um pedido, não uma barreira, e no modo "só entrega logo", pedido perde pra imitação toda vez.&lt;/p&gt;

&lt;p&gt;O que a IA &lt;strong&gt;não&lt;/strong&gt; ignora é um teste vermelho no CI, ela não consegue passar por cima de um build quebrado, então a regra que eu adotei é simples:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Se uma decisão de arquitetura importa de verdade, ela vira um teste automatizado. Se ela não vale um teste, ela não vale um parágrafo no &lt;code&gt;CLAUDE.md&lt;/code&gt;, é só preferência.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Regra que importa vira teste que quebra o build
&lt;/h2&gt;

&lt;p&gt;No Laravel isso é barato com o &lt;em&gt;arch testing&lt;/em&gt; do Pest. Você descreve a regra em linguagem quase natural:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tests/Arch/ArchitectureTest.php&lt;/span&gt;

&lt;span class="c1"&gt;// Controllers não tocam no banco direto: quem faz isso é a camada de dados&lt;/span&gt;
&lt;span class="nf"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'controllers não usam a facade DB'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App\Http\Controllers'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toUse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Illuminate\Support\Facades\DB'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Toda Action tem que morar no lugar certo e ter o sufixo certo&lt;/span&gt;
&lt;span class="nf"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'actions seguem a convenção'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App\Actions'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeClasses&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toHaveSuffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Action'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Isso já mata uma classe inteira de "criatividade" da IA: ela não consegue enfiar uma query no controller sem o build reclamar. Escreveu, ficou vermelho, ela mesma corrige.&lt;/p&gt;

&lt;h2&gt;
  
  
  O pulo do gato: a baseline que só encolhe
&lt;/h2&gt;

&lt;p&gt;O problema real não é começar limpo. É que você &lt;em&gt;já&lt;/em&gt; tem código bagunçado (seu ou gerado por IA) e não dá pra consertar tudo hoje. Se você liga a regra agora, o build fica vermelho em 200 lugares e você desliga a regra, aí não vale nada.&lt;/p&gt;

&lt;p&gt;A solução que eu uso (e que um revisor externo elogiou como rara) é uma &lt;strong&gt;baseline que só pode encolher&lt;/strong&gt;. Você congela a lista de violações que já existem, mas escreve o teste de um jeito que essa lista nunca cresce e ainda te obriga a limpar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Dívida herdada. A regra de ouro: essa lista SÓ diminui.&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;LEGACY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'App\Http\Controllers\Tickets\TicketsController'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ... o que já infringe hoje&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sem violação nova, e a dívida velha só encolhe'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$violacoes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encontrarViolacoes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// varre o código e devolve os infratores&lt;/span&gt;

    &lt;span class="c1"&gt;// 1) Código NOVO não pode infringir: nada fora da baseline&lt;/span&gt;
    &lt;span class="nv"&gt;$novas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$violacoes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;LEGACY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$novas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'Código novo quebrando o padrão: '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$novas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 2) O truque: se você consertou um arquivo, ele TEM que sair da baseline.&lt;/span&gt;
    &lt;span class="c1"&gt;//    Esqueceu de tirar? Teste vermelho. Isso força a lista a encolher.&lt;/span&gt;
    &lt;span class="nv"&gt;$jaCorrigidas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;LEGACY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$violacoes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jaCorrigidas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'Remova da baseline (já corrigido): '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$jaCorrigidas&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A segunda asserção é o coração de tudo. Sem ela, uma baseline vira um tapete pra empurrar sujeira pra baixo. Com ela, cada limpeza é obrigatória de registrar, e o número só cai. É a diferença entre "dívida técnica documentada" e "dívida técnica que se paga sozinha com o tempo".&lt;/p&gt;

&lt;h2&gt;
  
  
  O invariante mais importante ganha um guarda em produção
&lt;/h2&gt;

&lt;p&gt;Tem uma regra que eu não confio nem no teste: isolamento entre clientes (multi-tenancy). Um &lt;code&gt;where&lt;/code&gt; esquecido não dá erro: vaza dado de um cliente pro outro, calado. Pra esse caso, além do escopo automático, eu tenho um guard que estoura uma exceção em produção se o escopo de tenant não foi aplicado numa query que deveria ter. Cinto e suspensório. É o tipo de coisa onde "provavelmente está certo" não é aceitável.&lt;/p&gt;

&lt;h2&gt;
  
  
  O teste verde que me enganou
&lt;/h2&gt;

&lt;p&gt;Agora a parte que interessa mais que os acertos.&lt;/p&gt;

&lt;p&gt;Lembra dos 89 call-sites errados? Eu tinha um arch test pra Actions. Ele estava &lt;strong&gt;verde&lt;/strong&gt; o tempo todo, enquanto 89 lugares faziam o proibido.&lt;/p&gt;

&lt;p&gt;Como? Porque o teste checava a coisa errada. Ele garantia que certos métodos estavam na baseline, e não o estilo de invocação, que era justamente onde a IA driftava. O guardrail existia, passava no CI, me dava uma falsa sensação de segurança, e guardava a porta errada. E como o estilo errado era o mais visível no código, a IA copiava ele por imitação a cada feature nova. O teste verde estava, na prática, &lt;em&gt;abençoando&lt;/em&gt; o drift.&lt;/p&gt;

&lt;p&gt;A lição doeu e é a melhor que eu tenho pra te dar: &lt;strong&gt;verde não quer dizer certo, quer dizer que aquilo que eu de fato testei está ok&lt;/strong&gt;. Se você nunca viu o teste ficar vermelho, você não sabe o que ele guarda.&lt;/p&gt;

&lt;h2&gt;
  
  
  As 3 regras que eu tiro disso
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Se importa, vira teste que quebra o build.&lt;/strong&gt; &lt;code&gt;CLAUDE.md&lt;/code&gt; e ADR a IA lê e contorna sob pressão. Vermelho no CI ela não contorna.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Baseline que só encolhe.&lt;/strong&gt; Você não precisa consertar tudo hoje, mas o débito não pode crescer, e limpar tem que ser obrigatório de registrar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escreva o caso que falha primeiro.&lt;/strong&gt; Antes de confiar num guardrail, quebre o padrão de propósito e veja o teste ficar vermelho. Um guardrail que nunca viu vermelho provavelmente está guardando a porta errada. Foi exatamente o meu caso.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Pra fechar, com honestidade
&lt;/h2&gt;

&lt;p&gt;Meu SaaS está no meio dessa migração agora, enquanto escrevo isso. Não é código perfeito, é código real, com guardrails reais, um deles furado, sendo consertado à luz do dia. Nos próximos posts eu vou mostrando cada etapa da limpeza: como eu deduplico dois controllers que a IA clonou, como converto o padrão de Actions domínio a domínio, e como eu fecho o furo daquele teste pra ele parar de mentir pra mim.&lt;/p&gt;

&lt;p&gt;Se você também constrói com IA e sente que o código foge do controle, esse é o caminho que está funcionando pra mim. Me conta o seu.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
