現在、当社のモノレポには約110万行のコミット済みTypeScriptコードが存在します。そのスケーリングに伴い、型チェックの遅延、肥大化したインポート、コード追跡性の悪化といった様々な課題が生じていました。 過去数年間、コードベースの拡張性を高めるため様々な設計パターンを模索し、改良を重ねてきました。その中で偶然見つけた「レジストリ」というパターンが、拡大するコード管理の課題を大幅に軽減してくれました。このパターンはシンプルでありながら、当社が採用した中で最も拡張性の高い手法の一つです。

このパターンが大きな効果を発揮した好例が、当社独自の内部イベント処理コードです。当初は単純でしたが、時間の経過とともに巨大な型ユニオンが生じ、型チェックの遅延を引き起こしました。また、過剰なコードをインポートするバレルファイルや、過度な文字列連結による追跡困難なコードが発生。さらに、新しいイベントハンドラの追加や保守において開発者の摩擦を生む結果となりました。

オリジナルデザイン

高レベルでは、当社のイベントサービスはメッセージキュー(MQ)の上に構築されています。イベントはバックエンド内のあらゆる場所(APIポッド、ワーカーポッドなど)から記録されMQに送信され、ワーカーによって厳密に1回だけ処理されます。

設計自体は非常にシンプルである。唯一の主な制約は、すべてのイベントがネットワーク経由で完全にシリアライズ可能でなければならない点だ。複雑さと保守性のほとんどは、型安全性と開発者体験に起因している。


1. ベースヘルパーのセットを定義する:

./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. 共有ファイルでイベントタイプを定義する

./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. イベントハンドラを専用のファイルで定義する

// ./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. ロールアップファイルを定義する

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

5. イベントハンドラを定義する

./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. イベントを記録するための共通関数を定義する

// ./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. ワーカー内のキューのイベントハンドラを処理する関数をエクスポートする

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

一見すると、この設計はかなり良さそうです。型安全で、理解しやすく、扱いも簡単で、新しいイベントの作成方法も明確です。しかし、いくつかの問題がありました:

型チェックの複雑さ: 新しいイベントごとに、 アプリイベント 型は大きくなる。個々のユニオン自体は問題ないが、コードベース全体でこのパターン(複数の型ユニオン)の使用が増えると、型チェックのパフォーマンスは急速に低下する。

熱心なモジュール読み込み: ロールアップファイルの構造上、ほぼ全てのモジュールが起動時にコードベース全体をインポートしていたため、遅延読み込みが不可能でした。これにより、異なるデプロイメント向けにサーバーを個別のバンドルに分割することも困難でした。本番環境ではコードベース全体の即時読み込みが許容範囲でしたが、開発とテスト環境では深刻な影響を及ぼし、開発サーバーの起動だけで20~30秒以上を要していました。

トレーサビリティの不足: 「このイベントの実装はどこにあるのか?」や「このハンドラはどこで呼び出されているのか?」といった単純な質問に答えるのは困難でした。文字列リテラルに対する全文検索に頼らざるを得なかったのです。例えば、エンジニアが次のようなことを行うと決めた場合:

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

それを追跡するのは非常に困難だろう 送信済み そして wire_created イベントはここからトリガーされる。コードベース内で「wire_sent」という文字列を検索してもこの使用法は判明しない。なぜならこの名前は動的に生成されるからだ。結果として、この情報は一部のエンジニアの頭の中にのみ存在する、不透明な「部族的な」知識となってしまう。

明確な領域境界の欠如: より均質なインターフェース(イベントハンドラー、DBエンティティ、ヘルスチェック)を導入するにつれ、ドメインロジックによるグループ化ではなく、類似したインターフェースを同一フォルダに配置する傾向が強まりました。これによりビジネスロジックが断片化し、ドメイン固有のコンテキスト切り替えがより頻繁かつ複雑になりました。

このデザインを反復する

上記の設定では、すべてのイベントハンドラを ./app-イベント-ハンドラーズ/<event_type>.ts</event_type> ファイル群。それらをすべて1つのフォルダにまとめておけば発見は容易だったが、実際の作業方法を反映していなかった。実際には、イベントハンドラを他のハンドラとグループ化するよりも、関連するアプリケーションロジックの残りの部分と同一配置する方がはるかに有用であることが判明した。

そこでファイルにサブ拡張子を追加するというアイデアが生まれた(.event-handler.tsドメイン単位でのコロケーションを許可しつつ、拡張子による簡単な検索を可能にしてくれました。さらにファイル拡張子により、手動で管理していたロールアップファイルを廃止できました。実行時にリポジトリ内の拡張子に一致する全ファイルをスキャンできるようになったためです。

以下は、基本レジストリコードとその動作の簡略版です。 モジュールを読み込む すべてのファイルをスキャンし、エクスポートされたオブジェクトをすべて登録します。 $discriminator 渡されたシンボルと一致するプロパティ レジストリを作成する.

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

では、レジストリを使用してイベントハンドラを構築する様子は以下の通りです:

1. レジストリを定義する <name>.registry.ts</name> ファイル:

./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. 実際のイベントハンドラを定義する .app-event-handler.ts ファイル

// ./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. イベントを記録するための共通関数を定義する

./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. ワーカー内でキューのイベントハンドラを処理する関数をエクスポートする

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

留意すべき重要な相違点:

コードのトレーサビリティははるかに優れているイベントを記録するときは、常に次のように記録します:

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

これは、すべての場所を追跡することが容易であることを意味します。 カード作成イベントハンドラー ASTツール(例:VS Codeの「すべての参照を検索」)を使用して行われます。逆に、 イベントを記録する 呼び出しでは、ワンクリックで「実装へ移動」し、イベント定義とそのハンドラを見つけることができます。

型ユニオンはもうないむしろ、我々は基本型を使用している。これは何かが TypeScript 大規模なユニオンが引き起こす型チェックのパフォーマンス問題を回避するよう推奨します。

イベントハンドラーはドメイン固有のロジックと同一場所に配置されるアプリイベントハンドラーは、もはや単一のフォルダに保存されません。代わりに、関連するビジネスロジックと同一の場所に配置されます。例えば、ドメイン固有のサービスは次のようなものになるでしょう:

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

今日のレジストリとの連携

現在、私たちは数十のレジストリと連携し、すべてのコードをアプリケーションロジックと同一場所に配置しています。主なものには以下が含まれます:

  • .db.ts データベースエンティティの登録用
  • .ワークフロー.ts そして .activities.ts 登録のために 時間的 ワークフロー
  • .checks.ts ヘルスチェックの登録のためにブログ記事)
  • .main.ts ドメイン固有のビジネスロジックをグループ化するサービスの登録
  • .permission-role.ts そして .permission-key.ts 当社製品におけるRBAC権限の定義のため
  • .email-box.ts Gmailアカウントでメールを解析するハンドラーを登録するため
  • .cron.ts cronジョブの登録のために
  • .元帳残高.ts 内部財務「元帳」プリミティブを定義するために
  • .metrics.ts Datadogメトリクスの定義のために

およびその他のいくつかのドメイン固有の拡張機能。

現時点では、このパターンをオープンソース化していませんが、この投稿が他のコードベースでの実装方法を明確に理解する一助となれば幸いです。もし参考になった場合は、ぜひご自身のプロジェクトで実装してみてください。その結果をぜひお知らせください!

Read more from us