Hoje, temos cerca de 1,1 milhão de linhas de código TypeScript comprometido em nosso monorepo. O dimensionamento trouxe uma série de desafios, como verificação de tipos lenta, importações inchadas e rastreabilidade de código cada vez mais precária. Nos últimos anos, exploramos e iteramos vários padrões de design para escalar melhor nossa base de código. Um padrão que descobrimos ajudou a aliviar muitas das nossas crescentes dificuldades de organização de código: registros. É simples e um dos padrões mais escaláveis que adotamos.

Nosso código interno original de tratamento de eventos é um bom exemplo de onde esse padrão fez uma enorme diferença. Ele começou simples, mas com o tempo levou a uniões de tipos gigantescas, causando lentidão na verificação de tipos, arquivos barril que importavam código em excesso e código difícil de rastrear devido à concatenação excessiva de strings. Por fim, ele introduziu atrito entre os desenvolvedores na manutenção e adição de novos manipuladores de eventos.

Design original

Em um nível elevado, nosso serviço de eventos é construído sobre uma fila de mensagens (MQ). Os eventos são registrados e enviados para a MQ de qualquer lugar em nosso backend (pods de API, pods de trabalho, etc.) e processados por um trabalhador exatamente uma vez.

O design em si é muito simples; a única restrição principal é que todos os eventos devem ser totalmente serializáveis pela rede. A maior parte da complexidade e manutenção decorre da segurança de tipos e da experiência do desenvolvedor.


1. Defina um conjunto de auxiliares básicos:

./app-event-handler/base.ts
export interface AppEventBase {
  type: string;
}
 
export interface AppEventHandler<Ev extends AppEventBase> {
  type: Ev['type'];
  handler: (data: Ev) => Promise<void>
}
 
export function createAppEventHandler<Ev extends AppEventBase>(
  event: Ev['type'],
  handler: (data: Ev) => Promise<void>
): AppEventHandler<Ev> {
  return { type: event, handler }
}

2. Defina os tipos de eventos em um arquivo compartilhado

./app-event-handlers/types.ts
import type { AppEventBase } from '../base.ts';
 
export interface CardCreatedEvent extends AppEventBase {
  type: 'card_created';
  cardName: string;
}
 
export interface WireSentEvent extends AppEventBase {
  type: 'wire_sent';
  data: {
    amount: number;
  }
}
 
export type AppEvent = CardCreatedEvent | WireSentEvent;

3. Defina manipuladores de eventos em seu próprio arquivo

// ./app-event-handlers/card.ts    
import type { SpecificEventHandler } from './base.ts';
 
export const cardCreatedEventHandler: SpecificEventHandler<'card_created'> = async (ev) => {
  await sendNotificationToUsers();
}
 
// ./app-event-handlers/wire.ts
import type { SpecificEventHandler } from './base.ts';
 
export const wireSentEventHandler: SpecificEventHandler<'wire_sent'> = async (ev) => {
  await sendNotificationToUsers();
}

4. Defina um arquivo de rollup

./app-event-handlers/rollup.ts
export * from './card.ts';
export * from './wire.ts';

5. Defina manipuladores de eventos

./app-event-handlers/index.ts
import type { AppEventBase } from './base.ts';
import * as AppEventHandlers from './rollup.ts';
 
export type AppEvent = {
  [Key in keyof typeof EventHandlers]: 
    (typeof EventHandlers)[Key] extends AppEventHandler<infer Ev extends AppEventBase>
      ? Ev
      : never;
}[keyof typeof EventHandlers];
 
export { AppEventHandlers };

6. Defina uma função comum para registrar eventos

// ./record-app-event.ts
import { mqClient } from '@server/message-queue/client.ts';
import type { AppEvent } from './app-event-handlers'
 
export async function recordEvent<Ev extends AppEvent>(ev: Ev) {
  await mqClient.push('events', ev);
}
 
// ./some-random-service-file.ts
import { recordEvent } from './record-app-event.ts';
 
await recordEvent({ type: 'card_created', data: { cardName: 'John Doe' } });

7. Exporte uma função que processa o manipulador de eventos para a fila em um trabalhador.

./process-app-event.ts
import { AppEventHandlers, type AppEvent } from './app-event-handlers/index.ts';
 
const appEventHandlerMap = new Map(
  Object.values(AppEventHandlers).map(item => [item.type, item.handler])
);
 
export async function processAppEventHandler(ev: AppEvent) {
  switch (ev.type) {
    case 'card_created':
      await AppEventHandlers.cardCreatedEventHandler(ev);
      break;
    case 'wire_sent':
      await AppEventHandlers.wireSentEventHandler(ev);
      break;
    default:
      // Compile-time & runtime check to ensure this case is unreachable (aka never)
      assertUnreachable(ev.type);
  }
}

À primeira vista, esse design parece bastante adequado. É seguro em termos de tipos, fácil de entender, simples de trabalhar e claro sobre como criar novos eventos. No entanto, havia vários problemas:

Complexidade da verificação de tipos: Para cada novo evento, o AppEvent O tipo fica maior. Individualmente, uma única união não é problemática, mas à medida que o uso desse padrão aumenta em toda a base de código (várias uniões de tipos), o desempenho da verificação de tipos se degrada rapidamente.

Carregamento antecipado do módulo: Nosso padrão de arquivos rollup significava que quase todos os módulos importavam toda a base de código na inicialização, tornando impossível o carregamento lento. Isso também nos impedia de dividir facilmente o servidor em pacotes separados para diferentes implantações. Embora o carregamento antecipado da base de código completa fosse aceitável na produção, isso afetava gravemente o desenvolvimento e os testes, levando mais de 20 a 30 segundos apenas para iniciar o servidor de desenvolvimento.

Rastreabilidade deficiente: Responder a perguntas simples como “onde está a implementação deste evento?” ou “onde este manipulador é chamado?” era difícil. Tínhamos que confiar em pesquisas de texto completo de literais de string. Por exemplo, se um engenheiro decidisse fazer algo assim:

const eventName = `wire_${type}`; // Valid because type is a union between 'created' | 'sent', which are both defined events
 
await recordEvent({ type: eventName, data });

Seria muito difícil rastrear que o wire_sent e wire_criado os eventos são acionados a partir daqui. Pesquisar pela string “wire_sent” no código-fonte não revelaria esse uso, já que o nome é construído dinamicamente. Como resultado, essa informação se torna um conhecimento “tribal” obscuro que acaba ficando na cabeça de alguns poucos engenheiros.

Falta de limites claros entre os domínios: À medida que introduzimos interfaces mais homogêneas (manipuladores de eventos, entidades de banco de dados, verificações de integridade), isso incentivou a colocação de interfaces semelhantes na mesma pasta, em vez de agrupá-las por lógica de domínio. Isso fragmentou nossa lógica de negócios, tornando a troca de contexto específico do domínio mais frequente e complexa.

Iterando neste design

Na configuração acima, colocamos todos os manipuladores de eventos em ./app-event-handlers/<event_type>.ts</event_type> arquivos. Embora tê-los todos em uma única pasta facilitasse a localização, isso não refletia a forma como realmente trabalhávamos. Na prática, agrupar os manipuladores de eventos com o restante da lógica relevante da aplicação se mostrou muito mais útil do que agrupá-los com outros manipuladores.

Foi aí que surgiu a ideia de adicionar subextensões aos arquivos (.event-handler.ts) surgiu. Eles nos permitiram agrupar por domínio, ao mesmo tempo em que possibilitaram uma fácil localização através da pesquisa pela extensão. A extensão do arquivo nos permitiu ainda remover arquivos rollup mantidos manualmente, já que podíamos procurar todos os arquivos correspondentes à extensão no repositório em tempo de execução.

Aqui está uma versão resumida do código básico do registro e como ele funciona. carregarMódulos irá verificar todos os arquivos e registrar todos os objetos exportados com um $discriminador propriedade correspondente ao mesmo símbolo passado para criarRegistro.

./registry.ts
interface Registry<T> {
  loadModules(): Promise<void>;
  get<Throws extends boolean>(key: string, options?: { throws?: Throws }): boolean extends Throws ? T | undefined : T;
}
export function createRegistry<T extends { $discriminator: symbol }>(options: {
  discriminator: T['$discriminator'];
  registryExtension: `.${string}.ts`;
  getKey: (value: T) => string;
}): Registry<T> {
  // implementation ...
}

Agora, veja como fica a criação do nosso manipulador de eventos usando Registros:

1. Defina um registro em um <name>.registro.ts</name> arquivo:

./app-event-handler.registry.ts
import { createRegistry } from '@/registry';
 
const appEventHandlerDiscriminator = Symbol('app-event-handler');
export const appEventHandlerRegistry = createRegistry<AppEventHandler<AppEventBase>>({
  // When importing a module, a discriminator symbol to identify that an import is relevant to the registry
  discriminator: appEventHandlerDiscriminator,
  // The subextension to search for
  registryExtension: '.app-event-handler.ts',
  // Allows a lookup key to be derived from a module
  getKey: (mod) => mod.type,
});
 
export interface AppEventBase {
  type: string;
  data: unknown;
}
 
/**
  * Define an interface that exposes a `$discriminator` prop
  */
export interface AppEventHandler<Ev extends AppEventBase> {
  $discriminator: typeof appEventHandlerDiscriminator;
  type: Ev['type'];
  handler: (data: Ev['data']) => Promise<void>;
}
 
/**
  * Define the method we'll actually use to create each event handler
*/
export function createAppEventHandler<Ev extends AppEventBase>(
  event: Ev['type'],
  handler: (data: Ev['data']) => Promise<void>
): AppEventHandler<Ev> {
  return { $discriminator: appEventHandlerDiscriminator, type: event, handler }
}

2. Defina os manipuladores de eventos reais em .app-event-handler.ts arquivos

// ./card-service/card.app-event-handler.ts
import { createAppEventHandler } from '@/app-event-handler.registry';
 
interface CardCreatedEvent extends AppEventBase {
  type: 'card_created';
  data: {
    cardName: string;
  }
}
 
export const cardCreatedEventHandler = createAppEventHandler<CardCreatedEvent>(
  'card_created',
  async (ev) => {
    /**
    * ev looks like:
    * { 
    *   type: 'card_created',
    *   data: {
    *     cardName: string
    *   }
    * }
    **/
    await sendNotificationToUsers();
  }
);
 
// ./transfers/wire.app-event-handler.ts
import { createAppEventHandler } from '@/app-event-handler.registry';
 
interface WireSentEvent extends AppEventBase {
  type: 'wire_sent';
  data: {
    amount: number;
  }
}
 
export const wireSentEventHandler = createAppEventHandler<WireSentEvent>(
  'wire_sent',
  async (ev) => {
    // ...
  }
)

3. Defina uma função comum para registrar eventos

./record-app-event.ts
import { mqClient } from '@server/message-queue/client.ts';
import type { AppEvent } from './app-event-handlers'
 
export async function recordEvent<Ev extends AppEvent>(
  ev: AppEventHandler<Ev>, 
  data: Ev
) {
  await mqClient.push('events', { type: ev.type, data });
}

4. Exportar uma função que processa o manipulador de eventos para a fila em um trabalhador

./process-app-event.ts
import { type AppEventBase, appEventHandlerRegistry } from './app-event-handler.registry';
 
export async function processAppEventHandler(ev: AppEventBase) {
  // Here, the registry allows us to look up by the serialized type name, and then call the corresponding handler.
  const item = appEventHandlerRegistry.get(ev.type, { throws: true });
 
  await item.handler(ev);
}

Algumas diferenças importantes a serem observadas:

A rastreabilidade do código é muito melhorSempre que você registrar um evento, faça-o desta forma:

await recordEvent(cardCreatedEventHandler, { cardName: 'John Doe' })

Isso significa que é fácil rastrear todos os locais onde cardCreatedEventHandler é usado com ferramentas AST como “Encontrar todas as referências” (no VS Code). Por outro lado, quando você vê um registrarEvento chamada, você pode “Ir para implementação” com um clique para encontrar a definição do evento e seu manipulador.

Chega de sindicatos de tipos: Em vez disso, estamos usando tipos básicos, que é algo que TypeScript incentiva a evitar problemas de desempenho na verificação de tipos que grandes uniões podem causar.

Os manipuladores de eventos estão localizados junto com a lógica específica do domínio.Os manipuladores de eventos do aplicativo não são mais armazenados em uma única pasta. Em vez disso, eles são colocados junto com a lógica de negócios relevante. Por exemplo, um serviço específico de domínio pode ter a seguinte aparência:

/ services
  / card-service
    - card-service.main.ts
    - card-lifecycle.app-event-handler.ts
    - card.db.ts
    ...

Trabalhando com registros hoje

Hoje, trabalhamos com dezenas de registros para manter todo o código colocalizado com sua lógica de aplicação. Alguns dos mais notáveis incluem:

  • .db.ts para registrar entidades do banco de dados
  • .workflows.ts e .atividades.ts para se registrar Temporal fluxos de trabalho
  • .checks.ts para registrar exames de saúde (postagem no blog)
  • .main.ts para registrar serviços que agrupam lógicas de negócios específicas do domínio
  • .permissão-função.ts e .chave-de-permissão.ts para definir permissões RBAC em nosso produto
  • .email-box.ts para registrar manipuladores que analisam e-mails em uma conta do Gmail
  • .cron.ts para registrar tarefas cron
  • .saldo-do-livro-razão.ts para definir nossa primitiva financeira interna "livro-razão"
  • .metricas.ts para definir métricas do Datadog

e várias outras extensões específicas do domínio.

No momento, ainda não disponibilizamos esse padrão como código aberto, mas esperamos que este post tenha esclarecido como ele pode ser implementado em outras bases de código. Se você achou isso útil, tente implementá-lo em seus próprios projetos e conte-nos como foi!

Read more from us