TypeScript で実現するクリーンアーキテクチャ:層分離と依存性逆転の実践

現代のアプリケーション開発において、コードの品質と保守性は成功を左右する重要な要素です。特に TypeScript を使用した開発では、型安全性という強力な武器を手に入れた一方で、適切な設計パターンを適用しなければ、その恩恵を十分に活かしきれません。
本記事では、クリーンアーキテクチャを TypeScript で実践する方法を詳しく解説いたします。層分離と依存性逆転の原則を理解し、実際のコード例を通じて、保守性が高く、テストしやすいアプリケーションの構築方法をお伝えします。
背景
クリーンアーキテクチャとは
クリーンアーキテクチャは、ロバート・C・マーチン(Uncle Bob)によって提唱されたソフトウェア設計原則です。このアーキテクチャの核心は、ビジネスロジックを外部の技術的詳細から独立させることにあります。
クリーンアーキテクチャが重視する基本原則は以下のとおりです。
- 依存性の方向性: 内側の層は外側の層を知らない
- 関心の分離: 各層は明確な責務を持つ
- テスタビリティ: ビジネスロジックが独立してテスト可能
以下の図は、クリーンアーキテクチャの基本構造を示しています。
mermaidgraph LR
subgraph "外側の層"
UI[プレゼンテーション層<br/>UI/Controllers]
DB[インフラストラクチャ層<br/>Database/Web]
end
subgraph "内側の層"
APP[アプリケーション層<br/>Use Cases]
DOMAIN[ドメイン層<br/>Entities/Business Rules]
end
UI --> APP
DB --> APP
APP --> DOMAIN
style DOMAIN fill:#e1f5fe
style APP fill:#f3e5f5
style UI fill:#fff3e0
style DB fill:#f1f8e9
この図のとおり、依存関係は常に外側から内側に向かって流れ、内側の層は外側の詳細を知りません。
TypeScript での実装における課題
TypeScript でクリーンアーキテクチャを実装する際には、以下のような課題が存在します。
まず、型システムの活用方法が挙げられます。TypeScript の強力な型システムを活かして、各層間のインターフェースを明確に定義する必要があります。しかし、適切な抽象化を行わないと、具体的な実装に依存してしまい、クリーンアーキテクチャの恩恵を受けられません。
次に、依存性注入の実装です。Java や C#のような言語と比較して、TypeScript には標準的な DI コンテナが存在しないため、依存性の管理方法を慎重に設計する必要があります。
最後に、ファイル構成とモジュール分割の課題があります。各層を適切に分離し、循環参照を避けながら、開発者にとって理解しやすいディレクトリ構造を構築することが重要です。
課題
従来の MVC アーキテクチャの限界
従来の MVC アーキテクチャでは、以下のような問題が発生しがちです。
まず、ビジネスロジックの散在が挙げられます。モデル、ビュー、コントローラーそれぞれにビジネスロジックが分散してしまい、どこに何があるのかわからなくなってしまいます。
typescript// 問題のあるControllerの例
class UserController {
async createUser(req: Request, res: Response) {
// バリデーションロジック
if (!req.body.email || !req.body.email.includes('@')) {
return res
.status(400)
.json({ error: 'Invalid email' });
}
// ビジネスロジック
const hashedPassword = await bcrypt.hash(
req.body.password,
10
);
// データベース操作
const user = await User.create({
email: req.body.email,
password: hashedPassword,
createdAt: new Date(),
});
// 外部サービス呼び出し
await emailService.sendWelcomeEmail(user.email);
res.json(user);
}
}
このコードでは、コントローラーにバリデーション、ビジネスロジック、データベース操作、外部サービスの呼び出しがすべて含まれています。
層間の依存関係の問題
従来のアーキテクチャでは、以下のような依存関係の問題が発生します。
mermaidflowchart TD
Controller[Controller] --> Service[Service]
Service --> Model[Model]
Service --> Database[Database]
Service --> EmailAPI[Email API]
Model --> Database
style Controller fill:#ffcdd2
style Service fill:#ffcdd2
style Model fill:#ffcdd2
style Database fill:#ffcdd2
style EmailAPI fill:#ffcdd2
この構造では、上位層が下位層の具体的な実装に直接依存してしまい、変更の影響が連鎖的に波及してしまいます。
具体的な問題点:
問題 | 影響 | 例 |
---|---|---|
密結合 | 変更困難 | データベースを変更すると全体に影響 |
テスト困難 | 品質低下 | 外部サービスをモックできない |
再利用困難 | 開発効率低下 | ビジネスロジックを他で使えない |
テスタビリティの課題
従来のアーキテクチャでは、単体テストの作成が困難になります。
typescript// テストしにくいサービスの例
class UserService {
constructor(
private database: MySQL, // 具体的なデータベース
private emailApi: SendGridAPI // 具体的なメールサービス
) {}
async registerUser(userData: UserData) {
// 直接データベースに依存
const existingUser = await this.database.query(
'SELECT * FROM users WHERE email = ?',
[userData.email]
);
if (existingUser.length > 0) {
throw new Error('User already exists');
}
const user = await this.database.insert(
'users',
userData
);
// 直接外部APIに依存
await this.emailApi.send({
to: userData.email,
subject: 'Welcome!',
body: 'Thank you for registering',
});
return user;
}
}
このコードをテストするには、実際のデータベースとメールサービスが必要になってしまいます。
解決策
クリーンアーキテクチャの 4 層構造
クリーンアーキテクチャでは、アプリケーションを 4 つの層に分離します。
mermaidgraph TB
subgraph "プレゼンテーション層"
Controllers[Controllers<br/>REST API, GraphQL]
UI[UI Components<br/>React, Vue]
end
subgraph "アプリケーション層"
UseCases[Use Cases<br/>Application Services]
Ports[Ports<br/>Interfaces]
end
subgraph "ドメイン層"
Entities[Entities<br/>Business Objects]
ValueObjects[Value Objects<br/>Immutable Values]
DomainServices[Domain Services<br/>Business Logic]
end
subgraph "インフラストラクチャ層"
Repositories[Repositories<br/>Data Access]
ExternalServices[External Services<br/>APIs, Email]
end
Controllers --> UseCases
UI --> UseCases
UseCases --> Entities
UseCases --> Ports
Repositories --> Ports
ExternalServices --> Ports
style Entities fill:#e8f5e8
style UseCases fill:#fff2e8
style Controllers fill:#e8e8f5
style Repositories fill:#f5e8e8
各層の責務は明確に分離されており、依存関係は内向きに流れています。
各層の役割:
層 | 責務 | 技術例 |
---|---|---|
ドメイン層 | ビジネスルールとロジック | エンティティ、値オブジェクト |
アプリケーション層 | ユースケースの調整 | サービス、ポート |
インフラストラクチャ層 | 外部リソースとの連携 | データベース、API |
プレゼンテーション層 | ユーザーインターフェース | コントローラー、UI |
依存性逆転の原則(DIP)の適用
依存性逆転の原則により、高レベルのモジュールは低レベルのモジュールに依存せず、両方とも抽象に依存します。
mermaidflowchart TD
subgraph "Before DIP"
A1[High Level Module] --> B1[Low Level Module]
end
subgraph "After DIP"
A2[High Level Module] --> I[Interface/Abstraction]
B2[Low Level Module] --> I
end
style A1 fill:#ffcdd2
style B1 fill:#ffcdd2
style A2 fill:#c8e6c9
style B2 fill:#c8e6c9
style I fill:#fff9c4
TypeScript での実装例:
typescript// 抽象化(ポート)
interface UserRepository {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<User>;
}
interface EmailService {
sendWelcomeEmail(email: string): Promise<void>;
}
typescript// 高レベルモジュール(ユースケース)
class RegisterUserUseCase {
constructor(
private userRepository: UserRepository, // 抽象に依存
private emailService: EmailService // 抽象に依存
) {}
async execute(userData: UserData): Promise<User> {
const existingUser =
await this.userRepository.findByEmail(userData.email);
if (existingUser) {
throw new Error('User already exists');
}
const user = new User(userData);
const savedUser = await this.userRepository.save(user);
await this.emailService.sendWelcomeEmail(user.email);
return savedUser;
}
}
TypeScript での実装パターン
TypeScript でクリーンアーキテクチャを実装する際の主要なパターンをご紹介します。
1. インターフェースによる抽象化
typescript// ドメイン層でのインターフェース定義
export interface UserRepository {
findById(id: UserId): Promise<User | null>;
findByEmail(email: Email): Promise<User | null>;
save(user: User): Promise<User>;
delete(id: UserId): Promise<void>;
}
2. 依存性注入のパターン
typescript// DIコンテナの例
export class Container {
private services = new Map<string, any>();
register<T>(key: string, factory: () => T): void {
this.services.set(key, factory);
}
resolve<T>(key: string): T {
const factory = this.services.get(key);
if (!factory) {
throw new Error(`Service ${key} not found`);
}
return factory();
}
}
3. ファクトリーパターンの活用
typescript// ユースケースファクトリー
export class UseCaseFactory {
constructor(
private userRepository: UserRepository,
private emailService: EmailService
) {}
createRegisterUserUseCase(): RegisterUserUseCase {
return new RegisterUserUseCase(
this.userRepository,
this.emailService
);
}
}
具体例
実際のユーザー管理システムを例に、各層の実装方法を詳しく見ていきましょう。
ドメイン層の実装
ドメイン層は、ビジネスルールとエンティティを含む、アプリケーションの核となる部分です。
エンティティの実装
typescript// ユーザーID値オブジェクト
export class UserId {
constructor(private readonly value: string) {
if (!value || value.trim().length === 0) {
throw new Error('UserId cannot be empty');
}
}
equals(other: UserId): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}
typescript// メールアドレス値オブジェクト
export class Email {
private static readonly EMAIL_REGEX =
/^[^\s@]+@[^\s@]+\.[^\s@]+$/;
constructor(private readonly value: string) {
if (!Email.EMAIL_REGEX.test(value)) {
throw new Error('Invalid email format');
}
}
equals(other: Email): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}
typescript// ユーザーエンティティ
export class User {
constructor(
private readonly id: UserId,
private readonly email: Email,
private readonly name: string,
private readonly createdAt: Date
) {}
// ビジネスルール:ユーザーがアクティブかどうかを判定
isActive(): boolean {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
return this.createdAt > thirtyDaysAgo;
}
// ビジネスルール:ユーザー名の変更
changeName(newName: string): User {
if (!newName || newName.trim().length < 2) {
throw new Error('Name must be at least 2 characters');
}
return new User(
this.id,
this.email,
newName,
this.createdAt
);
}
// ゲッター
getId(): UserId {
return this.id;
}
getEmail(): Email {
return this.email;
}
getName(): string {
return this.name;
}
getCreatedAt(): Date {
return this.createdAt;
}
}
ドメインサービスの実装
typescript// ユーザー重複チェックのドメインサービス
export class UserDomainService {
constructor(private userRepository: UserRepository) {}
async isDuplicateEmail(email: Email): Promise<boolean> {
const existingUser =
await this.userRepository.findByEmail(email);
return existingUser !== null;
}
}
アプリケーション層(ユースケース)の実装
アプリケーション層では、ユースケースを実装し、ドメイン層とインフラストラクチャ層を調整します。
ユーザー登録ユースケース
typescript// ユーザー登録のリクエストDTO
export interface RegisterUserRequest {
email: string;
name: string;
}
typescript// ユーザー登録のレスポンスDTO
export interface RegisterUserResponse {
id: string;
email: string;
name: string;
createdAt: string;
}
typescript// ユーザー登録ユースケース
export class RegisterUserUseCase {
constructor(
private userRepository: UserRepository,
private emailService: EmailService,
private userDomainService: UserDomainService
) {}
async execute(
request: RegisterUserRequest
): Promise<RegisterUserResponse> {
// 入力値の検証
const email = new Email(request.email);
if (!request.name || request.name.trim().length < 2) {
throw new Error('Name must be at least 2 characters');
}
// ビジネスルールの適用
const isDuplicate =
await this.userDomainService.isDuplicateEmail(email);
if (isDuplicate) {
throw new Error(
'User with this email already exists'
);
}
// エンティティの作成
const userId = new UserId(this.generateId());
const user = new User(
userId,
email,
request.name,
new Date()
);
// 永続化
const savedUser = await this.userRepository.save(user);
// 外部サービスとの連携
await this.emailService.sendWelcomeEmail(
email.toString()
);
// レスポンスの構築
return {
id: savedUser.getId().toString(),
email: savedUser.getEmail().toString(),
name: savedUser.getName(),
createdAt: savedUser.getCreatedAt().toISOString(),
};
}
private generateId(): string {
return Math.random().toString(36).substr(2, 9);
}
}
ユーザー取得ユースケース
typescript// ユーザー取得ユースケース
export class GetUserUseCase {
constructor(private userRepository: UserRepository) {}
async execute(
userId: string
): Promise<RegisterUserResponse | null> {
const id = new UserId(userId);
const user = await this.userRepository.findById(id);
if (!user) {
return null;
}
return {
id: user.getId().toString(),
email: user.getEmail().toString(),
name: user.getName(),
createdAt: user.getCreatedAt().toISOString(),
};
}
}
インフラストラクチャ層の実装
インフラストラクチャ層では、外部リソース(データベース、API 等)との具体的な連携を実装します。
リポジトリの実装
typescript// データベーススキーマの型定義
interface UserSchema {
id: string;
email: string;
name: string;
created_at: Date;
}
typescript// MySQL実装
export class MySQLUserRepository implements UserRepository {
constructor(private connection: mysql.Connection) {}
async findById(id: UserId): Promise<User | null> {
const query = 'SELECT * FROM users WHERE id = ?';
const [rows] = await this.connection.execute(query, [
id.toString(),
]);
if (Array.isArray(rows) && rows.length === 0) {
return null;
}
const userData = rows[0] as UserSchema;
return this.mapToEntity(userData);
}
async findByEmail(email: Email): Promise<User | null> {
const query = 'SELECT * FROM users WHERE email = ?';
const [rows] = await this.connection.execute(query, [
email.toString(),
]);
if (Array.isArray(rows) && rows.length === 0) {
return null;
}
const userData = rows[0] as UserSchema;
return this.mapToEntity(userData);
}
async save(user: User): Promise<User> {
const query = `
INSERT INTO users (id, email, name, created_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name)
`;
await this.connection.execute(query, [
user.getId().toString(),
user.getEmail().toString(),
user.getName(),
user.getCreatedAt(),
]);
return user;
}
async delete(id: UserId): Promise<void> {
const query = 'DELETE FROM users WHERE id = ?';
await this.connection.execute(query, [id.toString()]);
}
private mapToEntity(schema: UserSchema): User {
return new User(
new UserId(schema.id),
new Email(schema.email),
schema.name,
schema.created_at
);
}
}
外部サービスの実装
typescript// メールサービスの実装
export class SendGridEmailService implements EmailService {
constructor(private apiKey: string) {}
async sendWelcomeEmail(email: string): Promise<void> {
const msg = {
to: email,
from: 'noreply@example.com',
subject: 'Welcome to our service!',
text: 'Thank you for registering with us.',
html: '<p>Thank you for registering with us.</p>',
};
try {
await sgMail.send(msg);
} catch (error) {
console.error('Failed to send welcome email:', error);
throw new Error('Failed to send welcome email');
}
}
}
テスト用の実装
typescript// インメモリ実装(テスト用)
export class InMemoryUserRepository
implements UserRepository
{
private users = new Map<string, User>();
async findById(id: UserId): Promise<User | null> {
return this.users.get(id.toString()) || null;
}
async findByEmail(email: Email): Promise<User | null> {
for (const user of this.users.values()) {
if (user.getEmail().equals(email)) {
return user;
}
}
return null;
}
async save(user: User): Promise<User> {
this.users.set(user.getId().toString(), user);
return user;
}
async delete(id: UserId): Promise<void> {
this.users.delete(id.toString());
}
}
プレゼンテーション層の実装
プレゼンテーション層では、HTTP リクエストを受け取り、ユースケースを実行して、レスポンスを返します。
Express.js コントローラーの実装
typescript// コントローラーの基底クラス
export abstract class BaseController {
protected handleSuccess<T>(
res: Response,
data: T,
statusCode: number = 200
): void {
res.status(statusCode).json({
success: true,
data,
});
}
protected handleError(res: Response, error: Error): void {
console.error('Controller error:', error);
if (error.message.includes('already exists')) {
res.status(409).json({
success: false,
error: error.message,
});
return;
}
if (
error.message.includes('Invalid') ||
error.message.includes('must be')
) {
res.status(400).json({
success: false,
error: error.message,
});
return;
}
res.status(500).json({
success: false,
error: 'Internal server error',
});
}
}
typescript// ユーザーコントローラー
export class UserController extends BaseController {
constructor(
private registerUserUseCase: RegisterUserUseCase,
private getUserUseCase: GetUserUseCase
) {
super();
}
async register(
req: Request,
res: Response
): Promise<void> {
try {
const { email, name } = req.body;
const result = await this.registerUserUseCase.execute(
{
email,
name,
}
);
this.handleSuccess(res, result, 201);
} catch (error) {
this.handleError(res, error as Error);
}
}
async getById(
req: Request,
res: Response
): Promise<void> {
try {
const { id } = req.params;
const user = await this.getUserUseCase.execute(id);
if (!user) {
res.status(404).json({
success: false,
error: 'User not found',
});
return;
}
this.handleSuccess(res, user);
} catch (error) {
this.handleError(res, error as Error);
}
}
}
ルートの設定
typescript// ルート設定
export class UserRoutes {
constructor(private userController: UserController) {}
configure(router: Router): void {
router.post(
'/users',
this.userController.register.bind(this.userController)
);
router.get(
'/users/:id',
this.userController.getById.bind(this.userController)
);
}
}
依存性注入の設定
typescript// 依存性注入の設定
export class DIContainer {
private static instance: DIContainer;
private services = new Map<string, any>();
static getInstance(): DIContainer {
if (!DIContainer.instance) {
DIContainer.instance = new DIContainer();
}
return DIContainer.instance;
}
configure(): void {
// インフラストラクチャ層
this.services.set(
'userRepository',
() => new MySQLUserRepository(connection)
);
this.services.set(
'emailService',
() =>
new SendGridEmailService(
process.env.SENDGRID_API_KEY!
)
);
// ドメイン層
this.services.set(
'userDomainService',
() =>
new UserDomainService(
this.resolve('userRepository')
)
);
// アプリケーション層
this.services.set(
'registerUserUseCase',
() =>
new RegisterUserUseCase(
this.resolve('userRepository'),
this.resolve('emailService'),
this.resolve('userDomainService')
)
);
this.services.set(
'getUserUseCase',
() =>
new GetUserUseCase(this.resolve('userRepository'))
);
// プレゼンテーション層
this.services.set(
'userController',
() =>
new UserController(
this.resolve('registerUserUseCase'),
this.resolve('getUserUseCase')
)
);
}
resolve<T>(key: string): T {
const factory = this.services.get(key);
if (!factory) {
throw new Error(`Service ${key} not found`);
}
return factory();
}
}
単体テストの例
typescript// ユースケースのテスト
describe('RegisterUserUseCase', () => {
let useCase: RegisterUserUseCase;
let userRepository: InMemoryUserRepository;
let emailService: jest.Mocked<EmailService>;
let userDomainService: UserDomainService;
beforeEach(() => {
userRepository = new InMemoryUserRepository();
emailService = {
sendWelcomeEmail: jest
.fn()
.mockResolvedValue(undefined),
};
userDomainService = new UserDomainService(
userRepository
);
useCase = new RegisterUserUseCase(
userRepository,
emailService,
userDomainService
);
});
it('should register a new user successfully', async () => {
// Arrange
const request: RegisterUserRequest = {
email: 'test@example.com',
name: 'Test User',
};
// Act
const result = await useCase.execute(request);
// Assert
expect(result).toEqual({
id: expect.any(String),
email: 'test@example.com',
name: 'Test User',
createdAt: expect.any(String),
});
expect(
emailService.sendWelcomeEmail
).toHaveBeenCalledWith('test@example.com');
});
it('should throw error when email is duplicated', async () => {
// Arrange
const email = new Email('test@example.com');
const existingUser = new User(
new UserId('existing-id'),
email,
'Existing User',
new Date()
);
await userRepository.save(existingUser);
const request: RegisterUserRequest = {
email: 'test@example.com',
name: 'New User',
};
// Act & Assert
await expect(useCase.execute(request)).rejects.toThrow(
'User with this email already exists'
);
});
});
まとめ
TypeScript でクリーンアーキテクチャを実装することで、以下のような大きなメリットを得られます。
まず、保守性の向上です。各層が明確に分離されているため、変更の影響範囲を限定でき、機能追加や修正が容易になります。特に、ビジネスロジックがドメイン層に集約されているため、要求の変更に対してより柔軟に対応できるでしょう。
次に、テスタビリティの改善が挙げられます。依存性注入により、各層を独立してテストできるため、品質の高いソフトウェアを構築できます。モックやスタブを容易に作成でき、テスト駆動開発(TDD)も実践しやすくなります。
また、TypeScript の型安全性を最大限活用できます。インターフェースによる抽象化と組み合わせることで、コンパイル時にエラーを検出でき、実行時エラーを大幅に削減できるでしょう。
最後に、チーム開発での効率性が向上します。層が明確に分離されているため、開発者は自分の担当する層に集中でき、並行開発がスムーズに進められます。新しいメンバーも構造を理解しやすく、学習コストを削減できます。
図で理解できる要点:
- 依存関係は常に内向きに流れ、外側の層は内側の詳細を知らない
- 各層は明確な責務を持ち、関心事が適切に分離されている
- インターフェースによる抽象化により、具体的な実装に依存しない柔軟な設計が実現される
クリーンアーキテクチャは初期の学習コストは高いものの、長期的なプロジェクトの成功において非常に価値のある投資となります。ぜひ実際のプロジェクトで実践してみてください。
関連リンク
- article
TypeScript で実現するクリーンアーキテクチャ:層分離と依存性逆転の実践
- article
TypeScript × GitHub Copilot:型情報を活かした高精度コーディング
- article
TypeScript による型安全なエラーハンドリング:Result 型と Neverthrow の活用
- article
TypeScript と RxJS を組み合わせたリアクティブプログラミング完全ガイド
- article
Remix × TypeScript:型安全なフルスタック開発
- article
Vitest × TypeScript:型安全なテストの始め方
- article
htmx のエラーハンドリングとデバッグのコツ
- article
Homebrew のキャッシュ管理と最適化術
- article
WordPress のインストール完全手順:レンタルサーバー・Docker・ローカルを徹底比較
- article
gpt-oss で始めるローカル環境 AI 開発入門
- article
GPT-5 で変わる自然言語処理:文章生成・要約・翻訳の精度検証
- article
WebSocket と HTTP/2・HTTP/3 の違いを徹底比較
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来