Era uma mudança de uma frase só: “a pessoa precisa conseguir confirmar o dia”. Nada de tela nova, nada de fluxo novo. Um botão, uma chamada pra API e a tela atualizada depois.

Agora imagina essa frase caindo num frontend organizado do jeito que a maioria de nós aprendeu. O botão mora em components/. A chamada mora em services/. O estado que guarda o dia confirmado mora em stores/. A validação da resposta mora em schemas/. Uma regra de negócio, quatro pastas globais, quatro lugares onde outra feature pode estar escondida esperando você quebrar ela sem querer. O PR fica espalhado, quem revisa precisa montar o quebra-cabeça na cabeça, e ninguém consegue responder com segurança a pergunta mais básica: “o que mais isso afeta?”.

Eu já vi esse filme em mais de um codebase. E o problema não é que alguém organizou mal. É que a pasta estava respondendo a pergunta errada. components/, services/ e stores/ contam que tipo de arquivo existe ali. Não contam nada sobre o que o produto faz.

No SaaS que estou construindo, decidi inverter isso desde o começo. Esse artigo é sobre como ficou: a estrutura, a regra de boundary, o caminho do dado, o teste que impede o atalho e as decisões estranhas que só fazem sentido com slices. E também sobre o que ainda não sei se vai aguentar.

Sumário

  • A pasta estava respondendo a pergunta errada
  • De onde vem a ideia
  • A anatomia de um slice
  • Como o dado anda dentro do slice
  • Uma regra que o CI consegue reprovar
  • Decisões que só fazem sentido com slices
  • E se o seu mundo é Vue e Nuxt
  • A opinião honesta
  • O que eu faria hoje
  • Referências

A pasta estava respondendo a pergunta errada

A ideia cabe numa frase: organizar o código pelo que o produto faz, e não pelo tipo técnico do arquivo. Cada capacidade de negócio vira um vertical slice que atravessa todas as camadas (dado, regra, estado, tela e rota) e só se expõe pro resto do sistema por uma public API.

     Pastas horizontais                        Vertical slices

            ┌──────────────────────────────┐    ┌──────────┬──────────┬──────────┐
components/ │ contratos · ciclos · dias    │    │contracts │ cycles   │ workday  │
services/   │ contratos · ciclos · dias    │    │  ui      │  ui      │  ui      │
stores/     │ contratos · ciclos · dias    │    │  model   │  model   │  model   │
schemas/    │ contratos · ciclos · dias    │    │ services │ services │ services │
            └──────────────────────────────┘    │  routes  │  routes  │  routes  │
                                                └──────────┴──────────┴──────────┘
     uma mudança de negócio toca               uma mudança de negócio fica
     quatro pastas globais                     dentro de uma coluna

Com slices, “confirmar o dia” vira uma mudança dentro de uma pasta. O diff fica perto do domínio, quem revisa lê de cima pra baixo, e a pergunta “o que mais isso afeta?” tem uma resposta que cabe numa linha: o que importa a public API desse slice.

De onde vem a ideia

Nada disso nasceu no frontend. Vertical Slice Architecture foi popularizada pelo Jimmy Bogard em 2018, no backend .NET, como reação às arquiteturas em camadas (n-tier, Clean, Onion). A queixa é a mesma da abertura, só que com controller, service, repository e DTO no lugar das nossas pastas. A proposta é minimizar o acoplamento entre slices e maximizar o acoplamento dentro de um slice.

Se você já leu um pouco de arquitetura, vai reconhecer a família inteira:

NomeDe onde vemO que eu emprestei
Screaming ArchitectureRobert C. Martin, 2011A estrutura de pastas deve gritar o domínio, não o framework
Bounded Context (DDD)Eric Evans, 2003Cada domínio tem o próprio modelo; o mesmo conceito pode ter dois cortes
ColocationKent C. DoddsCódigo que muda junto mora junto
Bulletproof ReactComunidade ReactFeatures isoladas e importação numa direção só
Feature-Sliced DesignMetodologia frontendLayers formais app → pages → widgets → features → entities → shared
Modular monolithArquitetura de sistemasMódulos com public API dentro de um único deploy

O Feature-Sliced Design é o primo mais completo, e eu olhei pra ele com carinho. Mas escolhi uma versão deliberadamente mais simples: três níveis (app, domains, shared) e uma regra de boundary. Sem widgets, sem entities, porque eu não tinha um problema que essas layers resolvessem. Se um dia a composição entre domínios pedir uma camada intermediária, vai ser uma decisão nova, com motivo concreto, e não uma previsão.

Estrutura que você adota antes de precisar é estrutura que você paga sem receber.

A anatomia de um slice

O src/ da aplicação tem três pastas e nenhuma outra:

src/
├── app/       # composição: bootstrap, router, shell logado, loading, erros, tema
├── domains/   # os slices de negócio, é aqui que o produto mora
└── shared/    # infraestrutura neutra com dois ou mais consumidores reais

E as dependências só andam numa direção:

app ──▶ domains/<x>/index.ts ──▶ shared
           │
           └──▶ domains/<y>/index.ts   (só pela public API)

O app/ conhece todos os domínios, mas só pelo index.ts de cada um. Um domínio pode usar outro, desde que entre pela porta da frente. E o shared/ não conhece domínio nenhum. Se o shared/ importa alguma coisa de domains/, ele deixou de ser neutro e virou um domínio disfarçado.

Por dentro, um slice tem cara de mini-aplicação:

domains/contracts/
├── index.ts     # a public API: metadados + o que os outros podem usar
├── model/       # schemas Zod, tipos e regras puras (sem React, sem I/O)
├── services/    # I/O: API real ou um fake com forma de API
├── state/       # store Zustand, só pra client state de fluxo
├── ui/          # telas e componentes do domínio
├── routes/      # rotas que o domínio exporta pro app compor
└── i18n/        # dicionários do slice

As subpastas só existem quando têm código. O slice de ciclos, por exemplo, não tem state/, porque tudo que ele mostra vem do loader da rota. Pasta vazia “pra manter o padrão” é só ruído.

Repare também no model/. É ali que mora a regra de negócio pura, sem React e sem I/O. Se você leu React é uma biblioteca de Views, não uma arquitetura, é exatamente o lugar que passa no CLI test. O slice não substitui aquela separação. Ele dá um endereço pra ela.

O index.ts é a única public API

O arquivo mais importante de um slice é o mais curto. Ele diz o que o domínio oferece pro resto do sistema, e tudo que não está nele é detalhe interno:

// domains/account/index.ts
export const accountDomain = { id: 'account', status: 'implemented' } as const;

export { accountRoutes } from './routes/accountRoutes';
export { createSettingsRoutes } from './routes/settingsRoutes';
export { getSessionUser } from './services/sessionUser';
export type { SessionUser, Plan } from './model/sessionUser';

Quem precisa do usuário logado importa de domains/account, nunca de domains/account/services/sessionUser. Parece burocracia até o dia em que você reorganiza o interior de um slice inteiro e nenhum arquivo fora dele muda. É o mesmo princípio do contrato estável que eu defendi no CAL: a public API é pequena e explícita, a implementação pode mudar à vontade.

O campo status

Aquele status no topo tem dois valores possíveis: 'implemented' ou 'placeholder'. Ele torna visível, no próprio código, quais boundaries já têm produto e quais só estão reservadas. Todos os slices entram num catálogo único em domains/index.ts.

Hoje são dezesseis slices, nove com produto e sete reservados. Por que criar pasta pra algo que não existe? Porque reservar o nome força cedo a conversa sobre quem é dono de cada conceito, e evita que o primeiro código daquele assunto nasça no lugar errado só porque era o lugar disponível. Um slice placeholder é quase vazio. O que ele carrega é a decisão.

Como o dado anda dentro do slice

Organizar pastas é a parte fácil. O que mantém um slice pequeno é uma regra clara pro caminho do dado. A minha separa server state de client state:

           leitura                                 escrita
┌────────┐ loader() ┌────────┐ props ┌────┐     ┌────┐ service() ┌────────┐
│services│ ───────▶ │ routes │ ────▶ │ ui │ ──▶ │ ui │ ────────▶ │services│
└───┬────┘          └────────┘       └────┘     └────┘           └───┬────┘
    │ valida com o schema de model/                                  │
    ▼                                                                 ▼
shared/api (Zod + envelope da resposta)        router.invalidate() → loader de novo

A leitura acontece no loader da rota, nunca num useEffect da tela. A escrita chama o serviço e depois router.invalidate(), que faz o router rodar o loader de novo. E o Zustand guarda só client state: sessão, o rascunho de um fluxo de vários passos, um cronômetro do aparelho. Dado que veio do servidor não é copiado pra uma store.

Na prática, a rota da tela de ciclo fica assim (o router aqui é o TanStack Router, com rotas code-based):

// domains/cycles/routes/cycleRoutes.tsx
import { createRoute, useRouter } from '@tanstack/react-router';
import type { ShellRoute } from '../../../app/shellRoute';
import { getSessionUser } from '../../account';
import { confirmDay, fetchCycleSummary } from '../services/cycleService';
import { CycleScreen } from '../ui/CycleScreen';

export function createCycleRoutes(parent: ShellRoute) {
  const cycleRoute = createRoute({
    getParentRoute: () => parent,
    path: '/ciclo',
    loader: async () => {
      const [summary, user] = await Promise.all([fetchCycleSummary(), getSessionUser()]);
      return { summary, user };
    },
    component: CyclePage,
  });

  function CyclePage() {
    const router = useRouter();
    const { summary, user } = cycleRoute.useLoaderData();

    return (
      <CycleScreen
        summary={summary}
        user={user}
        onConfirmDay={async (date) => {
          await confirmDay({ date });
          await router.invalidate();
        }}
      />
    );
  }

  return cycleRoute;
}

Três detalhes. O getSessionUser vem de '../../account', a public API do outro domínio, e não do arquivo interno. A CycleScreen não sabe de onde o dado veio: recebe props e avisa quando alguém confirmou o dia, que é o rosto do CAL em versão React. E o mais importante é o que não aparece: nada de loading na mão, de flag “já buscou?”, de store sincronizando resposta. Como a leitura passa pelo router, ele sabe quando a tela está carregando, e dá pra ligar a barra de progresso e o skeleton num lugar só, pra aplicação inteira.

Se você leu o CAL, talvez tenha notado uma tensão. Lá, o composable sincroniza a resposta da API dentro da store Pinia. Aqui, a regra é justamente não copiar server state pra store. Pra mim não é contradição, é uma pergunta sobre onde mora a fonte da verdade. Quando o loader da rota é a fonte, uma store com o mesmo dado vira uma segunda cópia que alguém vai esquecer de atualizar. A store volta a ser só memória do que é do cliente.

Quem manda na URL e quem decide onde ela pendura

As rotas são code-based de propósito. Cada domínio exporta a própria árvore, e o app/router.tsx compõe:

// app/router.tsx (trecho)
import { createContractRoutes } from '../domains/contracts';
import { createCycleRoutes } from '../domains/cycles';
import { accountRoutes, createSettingsRoutes } from '../domains/account';

O domínio manda na URL. O app decide onde ela pendura. Tudo que fica atrás do login pendura numa rota de layout sem path, o shell. Rota nova pendurada ali já nasce protegida, com sessão, layout, loading e tratamento de erro, sem ninguém precisar lembrar de nada.

Uma regra que o CI consegue reprovar

Até aqui, tudo é convenção. E eu já escrevi sobre o que acontece com convenção num time com pressa: ela vira um pedido educado, e pedido educado não segura uma sexta-feira às 18h. No checkout que a gente reorganizou, a virada foi ensinar o linter a barrar a violação. Aqui a ideia é a mesma, com outra ferramenta.

As boundaries são verificadas por um architecture.test.ts de umas 150 linhas no Vitest. Ele faz poucas coisas, todas chatas de propósito. Bloqueia pastas horizontais (components, services, stores, schemas e companhia) na raiz de src/. Bloqueia import de um domínio pra um arquivo interno de outro, porque só vale o index.ts. Garante que o detector de imports pega as três formas: import x from, import '...' e import('...'). Trava uma troca silenciosa da stack escolhida (React, TanStack Router, Zod, Zustand e o tema do design system). E confere que as regras de boundary continuam escritas no README e no registro da decisão, porque documentação que some sem ninguém perceber também é erosão.

O coração dele é mais simples do que parece. Uma versão resumida:

// src/architecture.test.ts (versão simplificada)
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';

const SRC = dirname(fileURLToPath(import.meta.url));
const DOMAINS = join(SRC, 'domains');
const HORIZONTAL = ['components', 'services', 'stores', 'schemas', 'hooks', 'ui'];
const IMPORT_RE = /(?:from\s+|import\s*\(\s*|import\s+)['"]([^'"]+)['"]/g;

function walk(dir: string): string[] {
  return readdirSync(dir).flatMap((name) => {
    const path = join(dir, name);
    if (statSync(path).isDirectory()) return walk(path);
    return /\.(ts|tsx)$/.test(name) ? [path] : [];
  });
}

function domainOf(path: string) {
  const rel = relative(DOMAINS, path);
  return rel.startsWith('..') ? null : rel.split(sep)[0];
}

describe('architecture', () => {
  it('não tem camadas horizontais na raiz de src/', () => {
    const roots = readdirSync(SRC);
    expect(roots.filter((name) => HORIZONTAL.includes(name))).toEqual([]);
  });

  it('só atravessa domínio pela public API', () => {
    const violations: string[] = [];

    for (const file of walk(SRC)) {
      const from = domainOf(file);
      for (const [, spec] of readFileSync(file, 'utf8').matchAll(IMPORT_RE)) {
        if (!spec.startsWith('.')) continue;
        const target = resolve(dirname(file), spec);
        const to = domainOf(target);
        if (!to || to === from) continue;

        const isPublicPort = target === join(DOMAINS, to) || target === join(DOMAINS, to, 'index');
        if (!isPublicPort) violations.push(`${relative(SRC, file)} → ${spec}`);
      }
    }

    expect(violations).toEqual([]);
  });
});

Quando alguém escreve import { confirmDay } from '../../cycles/services/cycleService' de dentro de outro slice, o pnpm test falha com o caminho exato da violação. Não tem revisor distraído, não tem “depois eu arrumo”.

Na literatura de arquitetura evolutiva isso tem nome: fitness function. É um teste automatizado que mede se o sistema continua tendo a propriedade arquitetural que você escolheu. Quem popularizou o termo foi o livro Building Evolutionary Architectures, do Neal Ford, da Rebecca Parsons e do Patrick Kua.

Existem ferramentas prontas pro mesmo papel: o eslint-plugin-boundaries, que declara elementos e regras de dependência no ESLint, e o dependency-cruiser, que valida o grafo de dependências contra regras. As duas são boas. Escolhi o teste por motivos pragmáticos: nenhuma dependência nova, roda no mesmo pnpm test do CI e do pre-push, e qualquer pessoa do time lê e altera um arquivo de teste sem aprender a configuração de outra ferramenta. O trade-off é que eu mantenho o detector de imports, coisa que as ferramentas prontas já resolvem melhor. Por isso existe um teste só pra garantir que o detector pega as três formas de import. Um fiscal que não enxerga é pior que nenhum fiscal, porque dá a sensação de segurança.

Convenção sem ferramental continua sendo só um pedido. A diferença é que agora o pedido tem um teste vermelho atrás dele.

Decisões que só fazem sentido com slices

O melhor jeito de entender um modelo de arquitetura é olhar pras decisões que ele te obriga a tomar. Algumas dessas, num projeto organizado por tipo de arquivo, pareceriam erro. Com slices, são o caminho certo.

Duplicar um recorte em vez de importar o vizinho

O slice de atividades precisa de dados de contrato. O reflexo natural é importar o modelo de contracts e seguir a vida. Em vez disso, activities declara o próprio recorte do contrato, só com os campos que usa:

// domains/activities/model/contractRef.ts
import { z } from 'zod';

// O recorte de contrato que ESTE slice precisa. Não é o modelo de contracts.
export const contractRefSchema = z.object({
  id: z.string(),
  title: z.string(),
  status: z.enum(['active', 'paused', 'ended']),
});

export type ContractRef = z.infer<typeof contractRefSchema>;

Sim, isso é duplicação. E é de propósito. É o bounded context do DDD aplicado no frontend: o mesmo conceito tem modelos diferentes em contextos diferentes, e um não quebra quando o outro muda. Se contracts ganhar quinze campos novos amanhã, activities nem fica sabendo.

Um domínio abriga o dado de outro até o outro existir

Os dados do cliente (nome, e-mail de quem aprova, CNPJ) vivem hoje dentro de contracts, porque customers ainda não tem tela própria. A boundary de customers existe, mas como placeholder. Quando ela ganhar produto, o dado migra, com um motivo concreto. Criar um slice cheio de código pra um domínio que ainda não tem uso seria exatamente a estrutura que a gente paga sem receber. Esse é o tipo de decisão que às vezes exige conversa: decidir quem é o dono de um conceito nem sempre é óbvio, e o placeholder deixa essa conversa registrada.

Quebrar um ciclo de import injetando a rota pai

Esse aqui é meu favorito, porque é um problema que só aparece quando as boundaries são de verdade. O shell, que mora no app/, importa account pra montar o menu. Só que as rotas de Configurações também pertencem a account, e elas precisam pendurar no shell. Se account importasse o shell de volta, nasceria um ciclo.

A saída foi inverter a dependência. account não importa o shell. Ele exporta uma função que recebe a rota pai por parâmetro e importa dela só o tipo:

// domains/account/routes/settingsRoutes.tsx
import { createRoute } from '@tanstack/react-router';
import type { ShellRoute } from '../../../app/shellRoute'; // só o tipo
import { SettingsScreen } from '../ui/SettingsScreen';

export function createSettingsRoutes(parent: ShellRoute) {
  return createRoute({
    getParentRoute: () => parent,
    path: '/configuracoes',
    component: SettingsScreen,
  });
}
// app/router.tsx (trecho)
const routeTree = rootRoute.addChildren([
  accountRoutes,
  shellRoute.addChildren([
    createCycleRoutes(shellRoute),
    createSettingsRoutes(shellRoute),
  ]),
]);

Um import type some na compilação, então não existe ciclo em runtime. Dependency injection é um nome pomposo pra algo que aqui é só “passar por parâmetro”. E resolve.

O shared/ exige prova, não previsão

Um utilitário só sobe pra shared/ quando cumpre duas condições: é neutro em relação ao negócio e tem dois consumidores reais. Não “vai ter”, tem. Até lá, duplicar algo pequeno dentro dos slices é aceitável.

Isso vai contra o instinto de muito dev, inclusive o meu de alguns anos atrás. A Sandi Metz resume no clássico The Wrong Abstraction: duplicação é muito mais barata que a abstração errada. Uma função compartilhada cedo demais vira o lugar onde cada consumidor novo adiciona um parâmetro, um if, uma exceção, até ela não servir bem a ninguém. Desfazer isso dói mais do que juntar duas cópias parecidas depois.

O shared/ é a gaveta da cozinha que todo mundo usa. Se você deixa qualquer coisa entrar, em seis meses ninguém acha a tesoura.

Um fake com forma de API

Enquanto um endpoint não existe, o serviço do slice é um fake. Mas não qualquer fake: ele valida entrada e saída pelos mesmos schemas Zod de model/ que o serviço real vai usar.

// domains/invoices/services/invoiceService.ts
import { invoiceListSchema, type InvoiceList } from '../model/invoice';
import { invoiceFixtures } from './invoiceFixtures';

export async function listInvoices(): Promise<InvoiceList> {
  // Fake até a API ter a rota. Mesmo contrato, mesma validação.
  return invoiceListSchema.parse(invoiceFixtures);
}

Quando o endpoint chega, a troca mexe só em services/. A ui/, as routes/ e os testes continuam iguais. Dois dos slices com produto hoje rodam assim, contra um fake do contrato, até a API ganhar as rotas. Isso merece um artigo próprio, e vai ganhar.

E se o seu mundo é Vue e Nuxt

Eu passo boa parte dos meus dias em Vue e Nuxt, então vale traduzir. Quase nada aqui depende de React.

O slice tem um parente direto no Nuxt: as Nuxt Layers, em que cada layer pode ter páginas, componentes, composables e configuração própria. Foi esse o caminho que a gente seguiu no checkout. Dentro de cada slice, o CAL continua valendo: a página conversa com o composable, o composable conversa com a store e com a API.

O ponto de atenção é a public API. O auto-import do Nuxt é confortável justamente porque deixa composables e componentes disponíveis em qualquer lugar sem import explícito, e isso apaga a boundary que o index.ts desenha aqui. Slice de verdade num projeto Nuxt precisa de ferramenta (lint ou um teste como o de cima) ainda mais do que em React, porque o framework não te ajuda a enxergar a violação. E tem um detalhe: um teste que lê import não enxerga o que nunca foi importado. Pra ele funcionar, o código dos slices precisa de import explícito, desligando o scan do auto-import do seu próprio código (imports.scan: false e components.dirs: [] no nuxt.config).

E o loader? No Nuxt, o papel mais próximo é o do useAsyncData ou useFetch na página, com refresh() (ou refreshNuxtData()) depois de uma escrita. Não é a mesma API, mas é a mesma ideia: a leitura tem um dono, a escrita pede pra ler de novo, e a store Pinia fica com o client state.

Uma última nota: o Nuxt 4 também tem uma pasta shared/, mas ela serve pra código compartilhado entre o app e o servidor. É outro significado. Não confunda as duas na hora de desenhar a estrutura.

A opinião honesta

Eu desconfio de artigo de arquitetura que só conta a parte boa. Então aqui vai a conta inteira:

O que eu ganheiO que eu pago
Uma entrega de épico mexe numa pasta, não em quatroDisciplina de importar só pelo index.ts (o teste ajuda)
Um slice inteiro pode ser removido ou ter lazy loadingPequenas duplicações até existirem dois consumidores reais
PR mais fácil de revisar, com o diff perto do domínioFluxos que cruzam domínios pedem orquestração explícita no app/
Testes de schema, estado e tela no limite da capacidadeComposição manual das rotas no app/router.tsx
O nome das pastas conta o que o produto fazDecidir o dono de um conceito às vezes exige conversa

Nada nessa tabela é de graça. Mas repare no padrão: quase todo custo da coluna da direita é um custo de clareza. Você paga pra deixar explícito o que antes ficava implícito. Eu acho uma troca boa. Não acho uma troca óbvia.

E tem o teste que ainda não aconteceu. Toda arquitetura parece ótima enquanto os slices são independentes. O fluxo central do produto (fechamento, entrega, aprovação, nota fiscal) atravessa quatro domínios, e a orquestração entre eles vai mostrar se a composição no app/ continua simples ou pede uma camada nova. Talvez eu acabe precisando de algo parecido com as layers do FSD que deixei de fora. Se acontecer, vai ser uma decisão registrada, com o motivo na mesa. E vai virar artigo.

Alguns pontos ficaram abertos de propósito. Se o loader com invalidate() deixar de bastar e cache e deduplicação virarem dor de verdade, uma biblioteca de server state entra na conversa. Se o volume de rotas passar do ponto em que a composição manual é clara, file-based routing também. SSR ou BFF, só com requisito real. Não são promessas. São portas que eu sei onde estão.

Por fim, o aviso na direção contrária, porque o exagero é um modo de falha tão real quanto a bagunça. Se o seu app tem três telas e uma pessoa mexendo nele, você não precisa de dezesseis slices, de um catálogo de domínios e de um teste de arquitetura. Precisa de pastas com nomes bons. A estrutura deveria crescer junto com o problema, não chegar antes dele.

O que eu faria hoje

Se eu tivesse que plantar uma ideia só, seria esta: a primeira pasta que alguém abre no seu projeto deveria contar o que o produto faz.

Pra um próximo passo prático, pega a última feature que você entregou e lista todos os arquivos que ela tocou. Se eles estão espalhados por components/, services/, stores/ e companhia, tenta desenhar como seria a mesma entrega dentro de um slice só. Não precisa migrar nada. Esse exercício, sozinho, já mostra onde estão as boundaries do seu domínio e qual slice nasceria primeiro. E se decidir criar o primeiro, cria junto o teste que protege a public API dele. Uma regra só, um teste vermelho.

No próximo texto eu abro o teste de arquitetura inteiro: como escrever fitness functions com Vitest, o que vale a pena verificar, e por que o teste que testa o próprio detector de imports é o mais importante deles.

Se isso fez sentido, eu escrevo sobre arquitetura de frontend, liderança técnica e carreira. Me acha no LinkedIn e no GitHub.

Referências

Vertical slices e organização por domínio

No ecossistema React

Abstração e duplicação

Garantir a arquitetura

Do próprio blog