export default new Class() — TypeScriptで最もシンプルなシングルトンの功罪
はじめに
TypeScriptでシングルトンが欲しくなったとき、最も手軽な方法があります。
class MyService {
// ...
}
export default new MyService();
これだけです。GoFのシングルトンパターンのような getInstance() もなければ、DIコンテナも不要です。Node.jsのモジュールシステムがキャッシュするため、同じインスタンスがプロジェクト全体で共有されます。
私が構築した自動化システムでは、SSH接続、Kafka、DB、ロガーなどすべてのサービスクラスでこのパターンを採用しました。約1年運用した結果、メリットとデメリットが見えてきたので共有します。
実際のコード
プロジェクト内の全サービスクラスがこのパターンです。
class Remote {
private jumpTunnel?: Client;
private remoteTunnel?: Client;
// ... 400行以上のメソッド
}
export default new Remote();
class DateUtil {
// ... 純粋なユーティリティメソッド
}
export default new DateUtil();
class Logger {
// ... Winston ベースのログ出力
}
export default new Logger();
なぜこのパターンが「動く」のか
Node.jsのモジュールシステム(CommonJS / ESM)は、モジュールを最初の require / import 時にのみ評価し、結果をキャッシュします。
// app.ts
import Remote from "./remote"; // → new Remote() が実行される
// other-file.ts
import Remote from "./remote"; // → キャッシュされたインスタンスが返る
つまり export default new Remote() は、プロセス全体でただ1つのインスタンスを保証します。
メリット
1. 圧倒的にシンプル
// これだけでシングルトン
export default new MyService();
// 利用側
import MyService from "./my-service";
await MyService.doSomething();
DIコンテナやファクトリーパターンのボイラープレートが一切不要です。
2. 呼び出し側がインスタンス管理を意識しない
// SSHトンネルの存在を気にせずコマンドを実行できる
const result = await Remote.execRemote("kubectl get pods");
Remote がどのように初期化されているか、トンネルが確立されているかは呼び出し側の関心事ではありません。
3. ステートフルなサービスに自然にフィットする
SSH接続やDB接続のように「1つの接続を使い回す」サービスでは、シングルトンは自然な選択です。接続を複数持つ必要がないなら、複雑な管理は不要です。
デメリット
1. 遅延初期化が散在する
サービスの中には、使用時に初めて初期化が必要なものがあります。
class Kafka {
private podName?: string;
private async checkPodname() {
if (!this.podName) {
const podName = (await Pod.getPodUtility())[0] || "";
this.podName = podName;
}
}
public async execTransfer(config: ConfigTransfer): Promise<void> {
await this.checkPodname(); // ← 毎回チェック
// ...
}
}
export default new Kafka() の時点では Pod 名がわからないため、各メソッドの冒頭で if (!this.podName) のチェックが必要になります。これがサービス全体に散在します。
// Remote.ts でも同じパターン
public async execRemote(cmd: string): Promise<string> {
if (!this.remoteTunnel) await this.initRemoteTunnel(); // ← 遅延初期化
// ...
}
2. テストでモックに差し替えられない
// これができない
import Remote from "./remote";
// Remote はすでにインスタンス化されている
// テスト用のモックに差し替える方法がない
Jest の jest.mock() でモジュール全体をモックすることは可能ですが、インスタンスの内部状態をリセットしたり、テストごとに異なる設定を注入するのは困難です。
3. 初期化順序がインポート順序に依存する
// a.ts
import B from "./b"; // B が先に初期化される
class A { /* B を使う */ }
export default new A();
// b.ts
import A from "./a"; // 循環参照! A はまだ undefined
class B { /* A を使う */ }
export default new B();
循環参照が発生すると、一方のインスタンスが undefined になります。このバグは実行時にしか発見できません。
4. プロセスのライフサイクルに縛られる
シングルトンのインスタンスはプロセスが終了するまで生き続けます。GracefulShutdown時にリソースを解放するには、明示的に stop() メソッドを呼ぶ必要があります。
// プロセス終了時のクリーンアップ
process.on("SIGTERM", () => {
Remote.stop();
// Kafka.stop();
// DB.close();
// ... 全サービスを手動で停止
});
DIコンテナなら、登録されたサービスのライフサイクルを一括管理できますが、モジュールレベルシングルトンでは手動管理が必要です。
いつこのパターンを使うべきか
向いているケース:
- CLIツールやバッチ処理など、プロセスのライフサイクルが短い
- サービスの数が少ない(10個以下)
- テストの必要性が低い(自動化ツール、スクリプト系)
- 全サービスが同じ設定で動く(環境別の設定注入が不要)
向いていないケース:
- Webサーバーのように、リクエストごとに異なるコンテキストが必要
- 単体テストでモックに差し替えたい
- サービス間の依存が複雑で、循環参照のリスクがある
- 複数の設定プロファイル(本番/ステージング)を同一プロセスで切り替えたい
代替案
クラスのエクスポート + 利用側でインスタンス化
export class Remote { /* ... */ }
// 利用側
const remote = new Remote(config);
最もシンプルな代替案。テストでモックに差し替えやすい。ただし、インスタンスの共有は呼び出し側の責任になります。
ファクトリー関数
let instance: Remote | null = null;
export function getRemote(config?: RemoteConfig): Remote {
if (!instance) {
instance = new Remote(config);
}
return instance;
}
// テスト用リセット
export function resetRemote(): void {
instance = null;
}
遅延初期化と設定注入を1箇所に集約できます。テスト時は resetRemote() で状態をリセット可能。
まとめ
export default new Class()はTypeScriptで最もシンプルなシングルトンパターン- Node.jsのモジュールキャッシュにより、プロセス全体で1つのインスタンスが保証される
- メリット: ボイラープレートゼロ、呼び出し側のシンプルさ
- デメリット: 遅延初期化の散在、テストの困難さ、循環参照リスク
- CLIツールやバッチ処理には適しているが、テスタビリティが重要なアプリケーションでは代替案を検討する



