Vandaag hebben we ongeveer 1,1 miljoen regels aan vastgelegde TypeScript-code in onze monorepo. Het opschalen ervan ging gepaard met een reeks uitdagingen, zoals trage typecontrole, opgeblazen imports en een steeds slechtere traceerbaarheid van de code. De afgelopen jaren hebben we verschillende ontwerppatronen onderzocht en getest om onze codebase beter te kunnen schalen. Eén patroon dat we tegenkwamen, hielp veel van onze groeiende problemen met codeorganisatie te verlichten: registries. Het is eenvoudig en een van de meest schaalbare patronen die we hebben toegepast.

Onze oorspronkelijke interne code voor het afhandelen van gebeurtenissen is een goed voorbeeld van een situatie waarin dit patroon een enorm verschil heeft gemaakt. Het begon eenvoudig, maar leidde na verloop van tijd tot gigantische type-unions die trage typecontrole veroorzaakten, barrel-bestanden die te veel code importeerden en moeilijk traceerbare code als gevolg van overmatige stringconcatenatie. Ten slotte zorgde het voor wrijving tussen ontwikkelaars bij het onderhouden en toevoegen van nieuwe gebeurtenishandlers.

Origineel ontwerp

Op hoog niveau is onze evenementenservice gebouwd op basis van een berichtenwachtrij (MQ). Evenementen worden geregistreerd en vanuit elke locatie in onze backend (API-pods, worker-pods, enz.) naar de MQ verzonden en precies één keer door een worker verwerkt.

Het ontwerp zelf is heel eenvoudig; de enige belangrijke beperking is dat alle gebeurtenissen volledig serialiseerbaar moeten zijn via de kabel. De meeste complexiteit en onderhoudswerkzaamheden vloeien voort uit typeveiligheid en de ervaring van ontwikkelaars.


1. Definieer een set basishelpers:

./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. Definieer gebeurtenistypen in een gedeeld bestand

./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. Definieer gebeurtenishandlers in hun eigen bestand

// ./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. Definieer een rollup-bestand

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

5. Definieer gebeurtenishandlers

./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. Definieer een algemene functie om gebeurtenissen vast te leggen.

// ./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. Exporteer een functie die de gebeurtenishandler voor de wachtrij in een worker verwerkt.

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

Op het eerste gezicht ziet dit ontwerp er prima uit. Het is typeveilig, gemakkelijk te begrijpen, eenvoudig om mee te werken en duidelijk over hoe nieuwe gebeurtenissen moeten worden aangemaakt. Er waren echter verschillende problemen:

Complexiteit van typecontrole: Voor elk nieuw evenement, de AppEvent type wordt groter. Op zichzelf is een enkele union niet problematisch, maar naarmate het gebruik van dit patroon in de codebase toeneemt (meerdere type unions), verslechtert de prestatie van de typecontrole snel.

Eager module laden: Ons patroon van rollup-bestanden betekende dat bijna elke module bij het opstarten de volledige codebase importeerde, waardoor lazy loading onmogelijk was. Dit belette ons ook om de server gemakkelijk op te splitsen in afzonderlijke bundels voor verschillende implementaties. Hoewel het eager laden van de volledige codebase aanvaardbaar was in productie, had het een ernstige impact op de ontwikkeling en het testen, waarbij het meer dan 20-30 seconden duurde om alleen al de ontwikkelingsserver op te starten.

Slechte traceerbaarheid: Het was moeilijk om eenvoudige vragen te beantwoorden, zoals "waar wordt deze gebeurtenis geïmplementeerd?" of "waar wordt deze handler aangeroepen?". We moesten vertrouwen op full-text zoekopdrachten van string-literals. Als een engineer bijvoorbeeld besloot om iets als dit te doen:

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

Het zou erg moeilijk zijn om na te gaan dat de wire_sent en draad_aangemaakt gebeurtenissen worden vanaf hier geactiveerd. Als je in de codebase zoekt naar de tekenreeks "wire_sent", zul je dit gebruik niet vinden, omdat de naam dynamisch wordt samengesteld. Daardoor wordt deze informatie obscure "tribale" kennis die alleen in de hoofden van een select aantal ingenieurs blijft hangen.

Gebrek aan duidelijke domeingrenzen: Toen we meer homogene interfaces introduceerden (event handlers, DB-entiteiten, health checks), stimuleerde dit om vergelijkbare interfaces in dezelfde map te plaatsen in plaats van ze te groeperen op basis van domeinlogica. Dit versnipperde onze bedrijfslogica, waardoor domeinspecifieke contextwisselingen frequenter en complexer werden.

Dit ontwerp herhalen

In de bovenstaande configuratie hebben we alle gebeurtenishandlers in ./app-event-handlers/<event_type>.ts</event_type> bestanden. Hoewel het handig was om ze allemaal in één map te hebben, weerspiegelde dit niet hoe we daadwerkelijk werkten. In de praktijk bleek het veel nuttiger om event handlers bij de rest van de relevante applicatielogica te plaatsen dan ze bij andere handlers te groeperen.

Daar kwam het idee vandaan om subextensies aan bestanden toe te voegen (.event-handler.ts) kwam binnen. Ze lieten ons per domein samenwerken, terwijl we toch gemakkelijk konden zoeken door de extensie op te zoeken. Dankzij de bestandsextensie konden we handmatig onderhouden rollup-bestanden verwijderen, omdat we tijdens runtime konden scannen op alle bestanden die overeenkwamen met de extensie in de repository.

Hier volgt een verkorte versie van de basisregistratiecode en hoe deze werkt. modules laden zal alle bestanden scannen en alle geëxporteerde objecten registreren met een $discriminator eigenschap die overeenkomt met hetzelfde symbool dat is doorgegeven aan register aanmaken.

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

Hieronder ziet u hoe onze gebeurtenishandler eruitziet wanneer we Registries gebruiken:

1. Definieer een register in een <name>.registry.ts</name> bestand:

./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. Definieer de daadwerkelijke gebeurtenishandlers in .app-event-handler.ts bestanden

// ./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. Definieer een gemeenschappelijke functie om gebeurtenissen vast te leggen

./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. Exporteer een functie die de gebeurtenishandler voor de wachtrij in een worker verwerkt.

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

Enkele belangrijke verschillen om op te merken:

De traceerbaarheid van code is veel beter.: Telkens wanneer u een gebeurtenis vastlegt, doet u dat als volgt:

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

Dit betekent dat het eenvoudig is om alle plaatsen te traceren waar cardCreatedEventHandler wordt gebruikt met behulp van AST-tools zoals "Find all references" (in VS Code). Omgekeerd, wanneer u een recordEvent oproep, kunt u met één klik naar "Ga naar implementatie" gaan om de gebeurtenisdefinitie en de bijbehorende handler te vinden.

Geen type-unions meerWe gebruiken eerder basistypen, wat iets is dat TypeScript moedigt aan om prestatieproblemen bij typecontrole te vermijden die grote unions met zich meebrengen.

Gebeurtenishandlers worden samen met domeinspecifieke logica geplaatst.App-gebeurtenishandlers worden niet langer in één map opgeslagen. In plaats daarvan worden ze samen met de relevante bedrijfslogica opgeslagen. Een domeinspecifieke service kan er bijvoorbeeld als volgt uitzien:

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

Werken met registers vandaag de dag

Tegenwoordig werken we samen met tientallen registers om alle code bij hun applicatielogica te houden. Enkele opvallende voorbeelden zijn:

  • .db.ts voor het registreren van database-entiteiten
  • .workflows.ts en .activiteiten.ts voor het registreren Tijdelijk werkstromen
  • .checks.ts voor het registreren van gezondheidscontroles (blogbericht)
  • .main.ts voor het registreren van diensten die domeinspecifieke bedrijfslogica groeperen
  • .toestemming-rol.ts en .toestemmingssleutel.ts voor het definiëren van RBAC-machtigingen in ons product
  • .email-box.ts voor het registreren van handlers die e-mails in een Gmail-account parseren
  • .cron.ts voor het registreren van cron-taken
  • .grootboekbalans.ts voor het definiëren van onze interne financiële "grootboek"-primitief
  • .metrics.ts voor het definiëren van Datadog-metrics

en verschillende andere domeinspecifieke extensies.

Op dit moment hebben we dit patroon nog niet open source gemaakt, maar hopelijk geeft dit bericht een duidelijk beeld van hoe het in andere codebases kan worden geïmplementeerd. Als je dit nuttig vond, probeer het dan eens in je eigen projecten en laat ons weten hoe het gaat!

Read more from us