irwin@notes: ~/post/essay-frontend-architecture

Um ensaio sobre arquitetura frontend

2026-02-2710 min de leitura
#frontend #architecture #react

Sempre me incomodei com a falta de padrões na criação de aplicações frontend com os frameworks modernos. Quando comecei a desenvolver, já eram 6 anos de vida do React e as pessoas ainda discutiam se deveriam utilizar Redux ou Context API para gerenciar estados. Foi só recentemente que as documentações ficaram melhores, as bibliotecas se estabilizaram e um caminho claro se abriu para ser seguido.

Os grandes frameworks parecem estar convergindo para um padrão "ótimo" no desenvolvimento de aplicações web. O Vue introduziu ref e a Composition API, Svelte criou Runes e o SolidJS sempre foi muito semelhante ergonomicamente ao React. Me parece que a evolução dos princípios core do React moveu todo o mercado para lugares parecidos.

Alguns padrões primitivos foram aperfeiçoados ao longo do tempo, e muitos frameworks passaram a expor conceitos como State, Computed, Actions e Effects. Esses padrões me deixaram inquieto, e comecei a estudar mais sobre o assunto. Minha proposição neste ensaio é que, talvez, usando esses axiomas, seja possível derivar um modelo de arquitetura universal, ou seja, uma arquitetura de frontend independente de bibliotecas.

Na literatura, o frontend é normalmente tratado como uma camada. Ele é apenas uma parte do sistema, um detalhe de implementação. De fato, muitas vezes, o frontend é somente uma maneira de conectar o usuário ao que gera valor, que normalmente fica no backend.

Por mais que possa ferir o coração de muitos frontenders, isso é verdade! Existem sistemas CRUD que não precisam de uma arquitetura robusta no frontend. Testes e manutenibilidade não são necessários. Substituir essa UI por outra não geraria grandes impactos no negócio.

É claro que essa é somente uma parte da história. Na verdade, frameworks como React e Angular só foram criados pois a UI precisava evoluir para gerar uma boa experiência aos usuários. A partir daí, o frontend começou a ter importância negocial, começou a gerar valor. Porém, o preço de uma experiência inovadora é complexidade no software. Com isso, o frontend deixa de ser um detalhe de implementação e passa a ser também um fim em si, e por isso faz sentido preocupar-se com sua arquitetura.

Existem muitas formas de organizar um software, e elas foram extensivamente documentadas por grandes autores. Como Uncle Bob deixa explícito em The Clean Architecture, ao falar sobre várias propostas arquiteturais:

Embora todas essas arquiteturas variem um pouco em seus detalhes, elas são muito semelhantes. Todas têm o mesmo objetivo, que é a separação de responsabilidades. Todas alcançam essa separação ao dividir o software em camadas. Cada uma tem pelo menos uma camada para regras de negócio e outra para interfaces. Cada uma dessas arquiteturas produz sistemas que são:

  1. Independentes de frameworks...
  2. Testáveis...
  3. Independentes da UI...
  4. Independentes de banco de dados...
  5. Independentes de qualquer agente externo...

Nesse sentido, o nome da arquitetura não é importante. A busca é por um design que respeite a separação de responsabilidades e que seja independente da UI.

Fazendo uma pesquisa inicial, o canal no YouTube Coffstack me levou a dois excelentes artigos que tentaram chegar, de forma genuína, a uma arquitetura de frontend.

Esses são bons exemplos de como modelar software de forma profissional e parecem apontar para um caminho promissor. Seguir essas arquiteturas provavelmente produzirá um software de qualidade.

Na prática, entretanto, ambas "ferem" os conceitos da separação de responsabilidades. As regras de negócio são criadas em hooks, estruturas intrinsecamente ligadas ao framework, fazendo com que a camada de Aplicação conheça a interface. Testar esses hooks, por exemplo, exige um runner que implemente os ciclos de vida do React. Por causa disso, decidi continuar procurando.

Client-Side Architecture Basics
Clean Architecture on Frontend

No fim de 2024, o time do Flutter definiu sua arquitetura recomendada na documentação Architecting Flutter apps. Essa foi a primeira vez que vi algo oficial que se aproximava do caminho que eu vinha considerando, a arquitetura MVVM.

Guide to app architecture
  • View: uma camada "burra" que renderiza a UI mas não possui nenhuma regra de negócio, apenas comportamento.
  • Model: encapsula regras de negócio e uso de recursos externos.
  • View Model: uma camada orquestradora que conecta assuntos de negócio como entidades e chamadas para APIs com as necessidades de estado de uma interface.

Mas eu ainda não estava satisfeito. O Flutter é um framework cross-platform utilizado principalmente para aplicações mobile. Ele foi fortemente inspirado no React em sua época pré-hooks, então o MVVM parece ser apenas uma maneira diferente de implementá-los. Perceba que seu nome já deixa explícita a quebra na separação de responsabilidades:

View Model, uma camada intermediária que "conhece" e integra a View e a Model.

A View Model me pareceu muito próxima da ideia de um gerenciador global de estados. Com os bindings corretos, é possível conectar uma store Redux, por exemplo, a qualquer framework. Isso sim deixa as regras de Aplicação separadas da UI (mas nos deixa presos às bibliotecas de estados).

Então, por mais que essa abordagem ferisse esse ideal de arquitetura, ela me interessou por cumprir com muitos dos requisitos de um bom design. O MVVM pode produzir uma camada de negócio testável, independente de framework frontend, e independente de qualquer agente externo.

Nesse ponto da história, eu já tinha começado a questionar se realmente existia uma arquitetura de frontend que verdadeiramente separasse a camada de Aplicação da interface.

Eu já não tinha tanto tempo, pois precisava definir a arquitetura dos projetos da empresa em que trabalho. Então decidi me inspirar no Flutter e criei um design similar ao MVVM, mais ou menos na seguinte forma:

export class TodoModel {
  constructor(
    public id: string,
    public name: string,
    public description: string,
    public checked: boolean,
  ) {}
  static create(name: string, description: string) {
    return new TodoModel(uuid(), name, description, false);
  }
  check() {
    this.checked = true;
  }
}
export interface ITodoViewModel {
  todos: Todo[];
  selectedTodoId: string;
  selectedTodo: Todo;
  createTodo(name: string, description: string): void;
  selectTodo(id: string): void;
  checkSelectedTodo(): void;
}
import { create } from 'zustand';
import { compute } from 'zustand-computed-state';
const useApp = create<ITodoViewModel>()((set, get) => ({
  todos: [],
  selectedTodoId: undefined,
  ...compute(get, s => ({
    selectedTodo: s.todos.find(t => t.id === s.selectedTodoId),
  })),
  createTodo(name, description) {
    const todo = TodoModel.create(name, description);
    set({ todos: [...get().todos, todo] });
  },
  selectTodo(id) {
    set({ selectedTodoId: id });
  },
  checkSelectedTodo() {
    const { todos, selectedTodoId } = get();
    const newTodos = todos.map(t => {
      if (t.id === selectedTodoId) t.check();
      return t;
    });
    set({ todos: newTodos });
  },
}));
function getApp() {
  return { get state() { return useApp.getState() as ITodoViewModel } };
}
test('create todo and select it', () => {
  const app = getApp();
  expect(app.state.todos).toHaveLength(0);
  expect(app.state.selectedTodo).toBeUndefined();
  app.state.createTodo('Buy milk', 'Go to the store');
  expect(app.state.todos).toHaveLength(1);
  const todo = app.state.todos[0];
  app.state.selectTodo(todo.id);
  expect(app.state.selectedTodo).toBe(todo);
});
test('check selected todo', () => {
  const app = getApp();
  app.state.createTodo('Buy milk', 'Go to the store');
  const todo = app.state.todos[0];
  expect(todo.checked).toBe(false);
  app.state.selectTodo(todo.id);
  app.state.checkSelectedTodo();
  expect(app.state.selectedTodo.checked).toBe(true);
});

Nesse desenho, a View Model assume o papel da camada de Aplicação e orquestra o fluxo de dados e o estado da UI; a Model funciona como uma entidade de domínio; e a View pode ser qualquer adaptador capaz de consumir esse contrato, inclusive um test runner.

Uma grande prioridade que tive foi manter os testes independentes da UI. Perceba que, se removermos o ponto de injeção da store (o getApp) da equação, o test runner não precisa conhecer o framework de gerenciamento de estado nem a tecnologia de UI usada por baixo dos panos.

Esse design ficou robusto e recebeu elogios de todos da equipe. Parecia que agora estava mais fácil adicionar novas features e testar o código. A busca aparentava ter sido finalizada. Mesmo assim, duas coisas ainda me incomodavam:

  1. Estamos presos à biblioteca de gerenciamento de estados (Zustand).
  2. A camada de Aplicação (View Model) ainda conhece detalhes da UI (todos, selectedTodoId, selectedTodo).

O primeiro ponto foi fácil de esclarecer com um exemplo. Quando criamos uma aplicação backend em que o Controller implementa diretamente um web framework como Express, estamos quebrando os conceitos de uma arquitetura limpa? 

Claro que sim! Entretanto, isso é relevante?

Quando essa decisão é deliberada, os desenvolvedores estão assumindo o risco de fazer o lock-in em uma ferramenta por saber que modificá-la será improvável. Desde que a camada de Aplicação esteja isolada, a arquitetura estará coerente.

É possível criar uma View Model independente de framework. Só é burocrático e sem ergonomia.

export class TodoViewModel implements ITodoViewModel {
  todos: Todo[] = [];
  selectedTodoId: string | undefined = undefined;
  constructor(private notifyListeners: (self: this) => void) {}
  get selectedTodo() {
    return this.todos.find(t => t.id === this.selectedTodoId);
  }
  createTodo(name: string, description: string) {
    const todo = TodoModel.create(name, description);
    this.todos = [...this.todos, todo];
    this.notifyListeners(this);
  }
  selectTodo(id: string) {
    this.selectedTodoId = id;
    this.notifyListeners(this);
  }
  checkSelectedTodo() {
    this.todos = this.todos.map(t => {
      if (t.id === this.selectedTodoId) t.check();
      return t;
    });
    this.notifyListeners(this);
  }
}
import { create } from 'zustand';
export const useApp = create(() => ({
  todos: [],
  selectedTodoId: undefined,
  selectedTodo: undefined,
}));
export const todosViewModel = new TodoViewModel((self) => {
  useApp.setState({
    todos: self.todos,
    selectedTodoId: self.selectedTodoId,
    selectedTodo: self.selectedTodo,
  });
});

O segundo ponto me incomodou por um bom tempo... a View Model conceitualmente só existe conhecendo a View. Em uma arquitetura purista, isso não poderia acontecer.

Explorando melhor as documentações, percebi que o Flutter recomenda uma camada de Use Cases quando a aplicação crescer para reutilizar código e orquestrar a interação com a Model. Esse já é um caminho que segue o ideal purista ao tentar criar uma camada de Aplicação externa à View.

Guide to app architecture

Essa abordagem funciona, porém deixa a implementação burocrática. Tentando aplicá-la em TDD, percebi que testar apenas a Use Case era ok, mas o verdadeiro valor do software estava em seu estado. Os testes que de fato eram efetivos passavam primeiro pela View Model.

Pesquisei um pouco mais e encontrei a origem do design. Em Presentation Model (mais tarde aprimorado e solidificado como MVVM), o Martin Fowler explica:

Presentation Model extrai o estado e o comportamento da View para uma classe de modelo que faz parte da apresentação. [...]

[...] Presentation Model não é uma fachada amigável à GUI para um objeto de domínio específico. Em vez disso, é mais fácil considerar o Presentation Model como uma abstração da View que não depende de um framework de GUI específico.

Lendo esse artigo, percebi algo sobre arquitetura que sempre esteve evidente, entendi que um software frontend possui atributos intrínsecos. Não estamos escrevendo um compilador, um sistema de arquivos ou uma API REST. Estamos escrevendo uma aplicação que:

  • Possui uma tela com elementos visuais bidimensionais.
  • Reage a interações do usuário.
  • Mantém um estado de apresentação.
  • Utiliza um modelo declarativo para renderização dos componentes.

Essas não são características opcionais. No momento em que decidimos criar uma arquitetura para um sistema frontend web, estamos nos vinculando fundamentalmente a um tipo específico de software, uma aplicação GUI declarativa 2D.

Depois dessa realização, percebi que talvez não exista uma arquitetura frontend totalmente pura: a camada de Aplicação quase sempre conhecerá a View de alguma forma. O que torna o código escalável é como essa interação acontece. A arquitetura certa, então, é a que abraça essa realidade, e tanto o MVVM quanto o Clean Architecture on Frontend e o Client-Side Architecture parecem cumprir bem esse papel.

Muitos autores chegaram a arquiteturas similares, como MVI ou Elm Architecture. Ainda não explorei todas, mas percebi um padrão interessante. A maioria inclui uma camada de estados utilizada para derivar a interface. Esse é exatamente o ponto focal deste ensaio e o que eu acredito ser a parte mais importante de um frontend.

A maioria das arquiteturas frontend possui esse conceito, que pode ser resumido em uma única equação: UI = f(state).

Conclusão

Ao longo da minha carreira no frontend, percebi que pouco importava como os componentes eram criados ou quais bibliotecas de forms, tables e gráficos eram utilizadas. Na verdade, o que tornava o código insustentável era uma camada de Aplicação com muitas responsabilidades, muito acoplada e sem organização.

Por essa razão, foquei o ensaio nesse ponto. Minha tese era de que se eu conseguisse criar uma abstração da View que não depende de um framework de GUI específico, a UI viraria um detalhe de implementação e escolheríamos frameworks com base em suas reais diferenças.

  • VueJS para adoção progressiva em sistemas legados.
  • React Native para mobile.
  • Svelte para JS compilado diminuindo bundle size.
  • SolidJS para aplicações que precisam de performance.
  • React Ink para softwares no terminal.

Eu talvez não tenha encontrado uma arquitetura universal. Mas acredito que aqueles axiomas primitivos (State, Computed, Actions e Effects) apontam, sim, para um modelo mental compartilhado entre todos os frameworks.

A arquitetura independente de bibliotecas que eu buscava não está em uma camada de Aplicação completamente isolada da UI. Na verdade, ela está em uma camada de negócio que reconhece a interface como parte fundamental do software. E isso se dá justamente pela equação UI = f(state) e pela disciplina de manter o estado de apresentação como a única fonte de verdade da interface.

Considerações finais

De forma alguma esse texto serve como um guia para MVVM ou Presentation Model, ele é apenas o início dessa conversa, um resumo da minha jornada aprendendo arquitetura de software. O manual de como EU implemento essa arquitetura fica para um próximo post.

Outra coisa importante de esclarecer é que estruturar componentes, integrar bibliotecas, escolher a estrutura de pastas e criar um projeto profissional em um framework também é um desafio. Saber como escrever a camada View é relevante. Também posso escrever sobre isso em outro post.

Na literatura e nos cursos de Clean Architecture, os exemplos quase sempre são de aplicações backend orientadas a objetos, e eu sentia falta de um modelo mental que aplicasse esses conceitos ao contexto em que trabalho diariamente. Este ensaio foi a síntese da minha busca por essa resposta.

Obrigado por ler!