Hoy en día, tenemos alrededor de 1,1 millones de líneas de código TypeScript comprometido en nuestro monorepo. Su escalado ha traído consigo una serie de retos, como la lentitud en la comprobación de tipos, las importaciones infladas y la trazabilidad del código cada vez más deficiente. Durante los últimos años, hemos explorado y probado varios patrones de diseño para mejorar la escalabilidad de nuestra base de código. Uno de los patrones con los que nos topamos nos ayudó a aliviar muchos de nuestros crecientes problemas de organización del código: los registros. Es sencillo y uno de los patrones más escalables que hemos adoptado.

Nuestro código interno original para el manejo de eventos es un buen ejemplo de cómo este patrón marcó una gran diferencia. Comenzó siendo sencillo, pero con el tiempo dio lugar a uniones de tipos gigantes que ralentizaban la comprobación de tipos, archivos barril que importaban demasiado código y código difícil de rastrear debido a la concatenación excesiva de cadenas. Por último, introdujo fricciones entre los desarrolladores a la hora de mantener y añadir nuevos manejadores de eventos.

Diseño original

A alto nivel, nuestro servicio de eventos se basa en una cola de mensajes (MQ). Los eventos se registran y envían a la MQ desde cualquier lugar de nuestro backend (pods API, pods de trabajo, etc.) y son procesados por un trabajador exactamente una vez.

El diseño en sí mismo es muy sencillo; la única restricción importante es que todos los eventos deben ser totalmente serializables a través de la red. La mayor parte de la complejidad y el mantenimiento provienen de la seguridad de tipos y la experiencia del desarrollador.


1. Defina un conjunto de ayudantes 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 los tipos de eventos en un archivo compartido.

./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 los controladores de eventos en su propio archivo.

// ./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. Definir un archivo de acumulación

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

5. Definir controladores 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. Definir una función común 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. Exportar una función que procese el controlador de eventos para la cola en un trabajador.

./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);
  }
}

A simple vista, este diseño parece bastante aceptable. Es seguro en cuanto al tipo, fácil de entender, sencillo de manejar y claro en cuanto a cómo crear nuevos eventos. Sin embargo, había varios problemas:

Complejidad de la comprobación de tipos: Para cada nuevo evento, el AppEvent El tipo se hace más grande. Individualmente, una sola unión no es problemática, pero a medida que aumenta el uso de este patrón en todo el código base (múltiples uniones de tipos), el rendimiento de la comprobación de tipos se degrada rápidamente.

Carga anticipada de módulos: Nuestro patrón de archivos de rollup significaba que casi todos los módulos importaban todo el código base al inicio, lo que hacía imposible la carga diferida. Esto también nos impedía dividir fácilmente el servidor en paquetes separados para diferentes implementaciones. Si bien la carga inmediata de todo el código base era aceptable en producción, afectaba gravemente al desarrollo y las pruebas, ya que solo para iniciar el servidor de desarrollo se tardaba entre 20 y 30 segundos.

Escasa trazabilidad: Responder a preguntas sencillas como «¿dónde se implementa este evento?» o «¿dónde se llama a este controlador?» resultaba difícil. Teníamos que recurrir a búsquedas de texto completo de literales de cadena. Por ejemplo, si un ingeniero decidía hacer algo como esto:

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

Sería muy difícil rastrear que el cable_enviado y creación_de_cable Los eventos se activan desde aquí. Buscar la cadena «wire_sent» en el código base no revelaría este uso, ya que el nombre se construye dinámicamente. Como resultado, esta información se convierte en un conocimiento «tribal» oscuro que acaba quedando en la mente de unos pocos ingenieros selectos.

Falta de límites claros entre los dominios: A medida que introdujimos interfaces más homogéneas (controladores de eventos, entidades de bases de datos, comprobaciones de estado), se fomentó la colocación de interfaces similares en la misma carpeta en lugar de agruparlas por lógica de dominio. Esto fragmentó nuestra lógica de negocio, haciendo que el cambio de contexto específico del dominio fuera más frecuente y complejo.

Iterando sobre este diseño

En la configuración anterior, colocamos todos los controladores de eventos en ./app-event-handlers/<event_type>.ts</event_type> archivos. Aunque tenerlos todos en una sola carpeta facilitaba su localización, no reflejaba cómo trabajábamos realmente. En la práctica, colocar los controladores de eventos junto con el resto de la lógica de la aplicación resultó mucho más útil que agruparlos con otros controladores.

Ahí es donde surgió la idea de añadir subextensiones a los archivos (.event-handler.ts) entró en escena. Nos permitieron colocar los archivos por dominio, al tiempo que facilitaban su localización mediante la búsqueda de la extensión. La extensión de archivo nos permitió además eliminar los archivos de acumulación mantenidos manualmente, ya que podíamos buscar todos los archivos que coincidían con la extensión en el repositorio en tiempo de ejecución.

Aquí hay una versión abreviada del código base del registro y cómo funciona. cargar módulos escaneará todos los archivos y registrará todos los objetos exportados con un $discriminador propiedad que coincide con el mismo símbolo pasado a crearRegistro.

./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 ...
}

Ahora, esto es lo que se ve al crear nuestro controlador de eventos utilizando Registries:

1. Defina un registro en un <name>.registro.ts</name> archivo:

./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 los controladores de eventos reales en .app-event-handler.ts archivos

// ./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. Definir una función común 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 una función que procese el controlador de eventos para la cola en un trabajador.

./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);
}

Algunas diferencias importantes a tener en cuenta:

La trazabilidad del código es mucho mejor.: Cada vez que registre un evento, hágalo de la siguiente manera:

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

Esto significa que es fácil rastrear todos los lugares donde cardCreatedEventHandler se utiliza mediante herramientas AST como «Buscar todas las referencias» (en VS Code). Por el contrario, cuando veas un registrar evento llamada, puede «Ir a la implementación» con un solo clic para encontrar la definición del evento y su controlador.

No más sindicatos de tipoMás bien, estamos utilizando tipos básicos, que es algo que TypeScript Recomienda evitar los problemas de rendimiento relacionados con la comprobación de tipos que provocan las uniones grandes.

Los controladores de eventos se encuentran en la misma ubicación que la lógica específica del dominio.Los controladores de eventos de aplicaciones ya no se almacenan en una sola carpeta. En su lugar, se ubican junto con la lógica empresarial relevante. Por ejemplo, un servicio específico de dominio podría tener un aspecto similar al siguiente:

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

Trabajar con registros hoy en día

Hoy en día, trabajamos con docenas de registros para mantener todo el código junto con su lógica de aplicación. Algunos de los más destacados son:

  • .db.ts para registrar entidades de la base de datos
  • .flujos de trabajo.ts y .actividades.ts por registrarse Temporal flujos de trabajo
  • .checks.ts para registrar controles médicos (entrada de blog)
  • .main.ts para registrar servicios que agrupan la lógica empresarial específica del dominio
  • .permiso-rol.ts y .clave-de-permiso.ts para definir los permisos RBAC en nuestro producto
  • .buzón-de-correo.ts para registrar controladores que analizan correos electrónicos en una cuenta de Gmail
  • .cron.ts para registrar tareas cron
  • .saldo-contable.ts para definir nuestra primitiva «libro mayor» financiero interno
  • .métricas.ts para definir métricas de Datadog

y varias otras extensiones específicas del dominio.

Por el momento, no hemos publicado este patrón como código abierto, pero esperamos que esta publicación proporcione una idea clara de cómo se puede implementar en otros códigos base. Si te ha resultado útil, ¡prueba a implementarlo en tus propios proyectos y cuéntanos qué tal te ha ido!

Read more from us