Componentes com estado em Type­Script, estilo Clojure

Publicado em 4 jul 2020. Uns 5 minutos de leitura.

Uma das ideias da programação funcional é o uso de funções puras para definir a lógica da aplicação que você estiver fazendo. Isso traz várias vantagens, como a possibilidade de trabalhar com estruturas de dados imutáveis, o que diminui ou elimina uma classe de problemas de concorência.

Isso é muito bonito, e eu recomendo essa abordagem, mas em algum momento você (tomara) vai precisar causar efeitos colaterais no universo. Seja ler ou escrever num banco de dados, interagir com um servidor ou cliente HTTP, com o sistema de arquivos, e por aí vai. E, nessa hora, separar estes efeitos colaterais ajuda muito a não misturar, sem querer, com a lógica que deveria ser pura.

Uma biblioteca em Clojure pequena mas bem útil para isso é a Component, de Stuart Sierra. Ela define uma interface para criar componentes que têm estado, e especificar quais as dependências entre esses componentes. O Nubank, por exemplo, usa essa biblioteca no template de serviços em Clojure.

Eu particularmente curto essa abordagem por deixar clara as dependências entre os componentes e minimizar a quantidade de estado global passado de um lado pro outro: a ideia é não passar o Sistema (a coleção de componentes), mas sim apenas os componentes que cada parte da aplicação precisa.

Recentemente, escrevendo projetos em TypeScript, fiquei pensando em maneiras de organizar componentes com efeito colateral, e o costume de programar Clojure me deixou feliz quando encontrei a ts-system-components, criada por Leo Iacovini. É uma biblioteca bem pequena que define uma interface comum para os componentes iniciarem e pararem, e garante que os componentes são inicializados na ordem certa das dependências entre eles.

Resolvi experimentar e usar num projeto simples para separar partes que antes estavam mais misturadas no código da primeira versão do gerador deste blog que você está lendo. Ele é gerado a partir de arquivos estáticos, então fez sentido ter um componente de acesso ao sistema de arquivos. Dele depende um componente de gerar HTML a partir de templates que gera HTML a partir dos templates e posts no sistema de arquivos. Outros componentes como um servidor HTTP para desenvolvimento e um gerador de imagens para posts dependem destes componentes. Como são páginas estáticas, a aplicação não tem um banco de dados e nem roda por muito tempo, mas a declaração de dependências dessa maneira me permitiu ter esses componentes separados e criar pontos de entrada que usam um conjunto diferente de componentes de acordo com a tarefa (gerar o blog ou rodar o servidor de desenvolvimento, por exemplo).

Um componente é algo que implementa uma interface Component com dois métodos: start e stop. Ambos são assíncronos e a biblioteca vai garantir que os componentes são inicializados por completo na ordem necessária para cada dependência. Se o componente não precisa ser incializado/destruído, esses métodos podem ser vazios. O meu componente de sistema de arquivos, por exemplo, tem essa cara:

class FileSystem implements Component {
  private readonly roodDir: string

  construct(rootDir: string) {
    this.rootDir = rootDir
  }

  async start() {
    if(!await exists(this.rootDir)) {
      throw 'Cannot initialize FileSystem: root directory does not exist'
    }
  }

  async stop() {}

  async listDirectory(path: string): Promise<string[]> {
    // implementation
  }

  async readFile(path: string): Promise<string> {
    // implementation
  }

  // ...
}

Esse componente não tem nenhuma dependência em outro, e recebe na criação só um valor estático. O componente de geração a partir dos templates depende do primeiro, e tem uma cara assim:

class Generator implements Component {
  private readonly fileSystem: FileSystem
  private templates?: Templates

  construct(fileSystem: FileSystem) {
    this.fileSystem = fileSystem
  }

  async start() {
    this.templates = await this.buildTemplates()
  }

  async stop() {}

  async buildTemplates(): Promise<Templates> {
    // implementation
  }

  async generatePostPage(post: Post): Promise<string> {
    // implementation
  }

  // ...
}

Finalmente, a conexão desses componentes é feita num sistema, que é uma classe que extende a classe System. Ela tem esses decoradores que servem para declarar as dependências entre os componentes.

class BaseSystem extends System {
  @BaseSystem.Using([], () => new FileSystem(process.cwd()))
  fileSystem!: FileSystem

  @BaseSystem.Using(['fileSystem'], ({ fileSystem }) => new Generator(fileSystem))
  generator!: Generator
}

Ao inicializar uma instância de um sistema, temos garantido que os componentes estão prontos para serem usados. Por exemplo, uma página de um post pode ser gerada de uma maneira semelhante a essa:

const system = new BaseSystem()

await system.start()

const post = {
  title: 'A post',
  date: new Date(2020, 1, 2),
  // ...
}

console.log(await system.generator.generatePostPage(post))

Os outros componentes, o servidor de desenvolvimento e o gerador de imagens, podem ser adicionados ao sistema com dependência no componente de geração dos templates. O legal é que, para aplicações diferentes, podemos construir um sistema diferente apenas com o que é necessário. Por exemplo, enquanto estou com o servidor de desenvolvimento aberto, eu não vou ficar gerando das imagens dos posts, então eu posso ter um System sem este último componente e ele nem vai ser inicializado.

Num projeto pequeno, como este, essa resolução ordenada e injeção de dependências podem parecer um excesso. E talvez seja, mesmo. Mas me ajudou a pensar quais efeitos colaterais estavam misturados em pedaços de código e como eu poderia isolá-los. A distinção entre lógica e efeitos colaterais ficou mais bem definida, mas ainda dá para melhorar.

Além disso, essa implementação abriu as portas para a criação de diferentes versões dos mesmos componentes (como, por exemplo, um "sistema de arquivos" falso que guarda tudo em memória, usando estruturas de dados de JS), que podem ser usadas em testes de integração sem se preocupar em, na hora do teste, ter que fazer mocks de cada efeito colateral. É uma ideia que uso bastante nos projetos em Clojure que trabalho e que vou experimentar por aqui também!