Ir para o conteúdo principal

Devlog · Fase 0 · 5/7

A arquitetura Modules do repo web

Cinco módulos, um único ponto de saída para o cloud e revisões que se leem pelo caminho de arquivo: a arquitetura Laravel do repo web, gerado em mais de noventa por cento por agentes de IA.

Publicado em 10 agosto 20265 min de leitura
  • laravel
  • architecture
  • octane

Um site vitrine sem banco de dados é algumas páginas e um formulário. A tentação, num escopo tão pequeno, é empilhar tudo em dois ou três controllers e seguir em frente. Peguei o caminho inverso já na primeira semana do projeto. Quando coloquei a landing no ar em abril, arrumei cada responsabilidade em seu próprio módulo. Hoje o repo tem cinco domínios: Cloud, Locale, Seo, Waitlist, e agora Blog. Eis por que essa disciplina se sustenta, mesmo nessa escala.

Uma pasta por domínio, não por tipo técnico

A estrutura inicial do Laravel arruma o código por natureza: todos os controllers juntos, todos os services juntos, todas as classes utilitárias juntas. Em aubia.dev, mudei para uma arrumação por domínio de negócio. Cada pasta em app/Modules/ carrega uma intenção de produto completa, com suas subpastas Actions/, DataTransferObjects/, Http/ e Enums/ quando são úteis.

  • Cloud: o único ponto de saída HTTP em direção a api.aubia.cloud.
  • Locale: a resolução de idioma antes do roteamento.
  • Seo: a geração do sitemap e dos sinais de indexação.
  • Waitlist: o proxy de inscrição em double opt-in.
  • Blog: o motor flat-file que serve este artigo.

Quando uma intenção muda, sei exatamente onde olhar. Nenhum arquivo joga dos dois lados.

O mapa animado dos módulos do repositório web: Locale, Seo, Waitlist e Blog convergem no módulo Cloud, o único ponto de saída para api.aubia.cloud.

As Actions: uma intenção, uma classe

Cada gesto de negócio vive em sua própria classe de Action. Nenhum service faz-tudo que acumula quinze métodos ao longo dos meses: uma classe faz uma coisa, a expõe por um método de entrada, e se testa fora da stack HTTP. A convenção Aubia quer essas classes final readonly por padrão, com um ponto de entrada nomeado (execute) ou invocável.

A resolução de locale é um bom exemplo. Ela reproduz a mesma cascata que o middleware, sem tocar no segmento de URL, isolada para permanecer testável:

public function __invoke(Request $request): string
{
    $candidates = [
        $request->cookie(SetLocale::COOKIE_NAME),
        $this->extractAcceptLanguage($request),
        config('app.locale'),
    ];

    foreach ($candidates as $candidate) {
        if (is_string($candidate) && in_array($candidate, SetLocale::SUPPORTED_LOCALES, true)) {
            return $candidate;
        }
    }

    return SetLocale::DEFAULT_LOCALE;
}

O controller que chama essa Action permanece trivial: ele delega e redireciona. Toda a lógica testável está em outro lugar.

Os DTOs readonly: um contrato tipado entre as camadas

Entre uma camada e a seguinte, nunca faço transitar um array associativo vagamente tipado. Cada payload estruturado passa por um Data Transfer Object em PHP puro: uma readonly class com propriedades promovidas, com um fromArray() na entrada e um método de saída explícito. Nenhuma dependência externa para isso, nenhum mapeamento mágico: a tipagem estrita do PHP 8.5 basta, e o contrato se lê num piscar de olhos.

A inscrição na lista de espera ilustra por que esse contrato importa. O formulário captura um campo honeypot temporal, startedAt, usado apenas para armadilhar os bots do lado da validação. Esse campo nunca deve sair para o cloud. O DTO o carrega internamente mas o exclui do payload de saída:

public function toCloudPayload(): array
{
    return [
        'email' => $this->email,
        'utm_source' => $this->utmSource,
        'utm_medium' => $this->utmMedium,
        'utm_campaign' => $this->utmCampaign,
        'locale' => $this->locale,
    ];
}

A fronteira entre o que o web conhece e o que o cloud recebe está escrita em preto no branco, em um método cujo nome diz exatamente o seu papel.

Um único ponto de saída em direção ao cloud

Como a Fase 0 não armazena nada localmente, todo dado persistente sai para o repo cloud via um proxy HTTP. Centralizei essas chamadas em uma única classe, CloudApiClient, para que as Actions nunca tenham de conhecer os detalhes do cliente Http nativo do Laravel: timeouts, backoff, logging sem dado pessoal. O cliente aplica duas políticas de retry distintas conforme a idempotência do verbo. Um GET reproduz em erro de rede e em 5xx, sem efeito colateral. Um POST assinado só reproduz em uma falha de rede clara, nunca em uma resposta recebida, para não arriscar uma dupla inscrição.

Esse cliente é final mas, deliberadamente, não readonly no nível da classe: sob o Octane, ele deve permanecer sem estado de uma requisição a outra, e permanecer mockável nos testes. Um detalhe de disciplina stateless que evita vazamentos entre workers.

Por que essa disciplina em um repo tão pequeno

A pergunta honesta é: isso não é demais para cinco páginas?

Este repo é gerado noventa por cento e mais por agentes de IA, e a revisão humana continua sendo o verdadeiro gargalo. Uma fronteira de módulo nítida é uma salvaguarda nos dois sentidos: um agente solto sobre Waitlist não transborda para Seo por acidente, e minhas revisões continuam curtas de ler porque o escopo de uma mudança se adivinha pelo seu caminho de arquivo.

A Fase 1 trará um verdadeiro site de produto, e cada módulo de hoje é uma fundação, não um andaime descartável. E essa estrutura é coerente de um repo Aubia a outro: reencontro os mesmos reflexos no cockpit desktop e no cloud. Essa regularidade, num projeto tocado sozinho, bem vale algumas pastas a mais.

Descer para dentro da pele do site

Resta o mais visível: a paleta, o vidro e o movimento. Para receber o convite para a beta 0.1 assim que ela abrir, a lista de espera está lá.

Esta construção é contada aqui, artigo após artigo. Embarque: o seu feedback desenhará o que vem a seguir.

Entrar na lista de espera