【kintone】カスタマイズをクリーンアーキテクチャで整理してみる

## はじめに
大学最後の春休みも残すところあと1ヶ月となった あなP（[@annap_ms](https://x.com/annap_ms)）です。

最近、kintone のカスタマイズを進める中で、コードの肥大化や管理の難しさを感じることが増えてきました。そこで、この課題を解決するために「クリーンアーキテクチャ」の概念を取り入れた構成を考えてみることにしました。

本記事では、簡単なサンプルコードを整理しながら、クリーンアーキテクチャの理解を深める過程を備忘録としてまとめています。

## クリーンアーキテクチャとは
クリーンアーキテクチャは、ソフトウェア設計手法の一つであり、システムの柔軟性や保守性を高めることを目的としています。その基本概念は、「**ビジネスロジックを外部依存（データベース、API、UI など）から切り離す**」 ことにあります。これにより、変更に強く、テストしやすいコードを実現できます。

詳しくは、以下の記事で提唱されている概念を参考にできます。
[クリーンアーキテクチャの概念（Robert C. Martin）](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)

## 既存のコードとその問題点（Before）
### **サンプルコード**

まず、今回使用するサンプルコードの紹介です。
以下のコードは、詳細画面のヘッダースペースに「完了」ボタンを追加し、クリック時にレコードの「完了者」と「完了日」を更新する処理です。

``` js: sample.js
(() => {
  'use strict';
  kintone.events.on('app.record.detail.show', (event) => {
    const menuButton = document.createElement('button');
    menuButton.id = 'complete_button';
    menuButton.innerText = '完了';

    menuButton.onclick = async function() {
      try {
        const recordId = event.recordId;
        
        const updateParams = {
          app: kintone.app.getId(),
          id: recordId,
          record: {
            ユーザー選択: [{
              code: kintone.getLoginUser().code
            }],
            日付: {
              value: new Date().toISOString().split('T')[0]
            }
          }
        };

        await kintone.api(kintone.api.url('/k/v1/record', true), 'PUT', updateParams);
        location.reload();
      } catch (error) {
        console.error(error);
        alert(error instanceof Error ? error.message : 'エラーが発生しました');
      }
    };

    const headerMenuSpace = kintone.app.record.getHeaderMenuSpaceElement();
    if (headerMenuSpace) {
      headerMenuSpace.appendChild(menuButton);
    }
    return event;
  });
})();
```

### 現在の問題点

このままでは、コードの可読性や保守性が低く、新機能の追加や変更が難しくなります。  
以下の課題を解決するために、設計を見直す必要があります。

❌ **UI処理とビジネスロジックが混在** （可読性が低く、メンテナンスが困難）

❌ **データ構造がハードコード**（変更が難しく、スキーマの一貫性を保てない）

❌ **API を直接呼び出し** （テストしにくく、コードの再利用性が低い）

❌ **単一ファイルに処理が集中** （関心ごとが分離されておらず、拡張性が低い）


## 設計の改善（After）
### 🎯 設計方針
既存のコードの問題点を解決するために、クリーンアーキテクチャの考え方を取り入れた構成に見直しました。  
ここでは、設計の方針と、それによって得られるメリットを整理します。

#### ✅ 関心の分離
- **UIロジックとビジネスロジックを分離**（`presentation` / `application` / `domain` / `infrastructure`）
- **データ構造の統一**（スキーマ管理を導入し、データの一貫性を確保）

#### ✅ 依存関係の制御
- **依存関係を明確化**（DIコンテナを導入し、コンポーネントの直接依存を防ぐ）
- **データアクセスを抽象化**（インターフェースを用いて、レイヤー間の結合度を低減）

#### ✅ 型安全性の向上
- **スキーマ定義を導入**（型の整合性を保証し、APIの変更の影響を最小限に）
- **エラーの事前検知が可能**（不正なデータの登録を防ぐ）

#### ✅ テストの容易性
- **API呼び出しをモック可能に**（データアクセスをインターフェース化）
- **ユースケース層を分離**（ビジネスロジックのテストが可能）

---

### 📁 ディレクトリ構成
アプリケーション全体を **関心ごとに分離** し、**クリーンアーキテクチャの原則** に沿った構成に整理しました。  
以下は、今回のリファクタリング後のディレクトリ構成です。


```
src/
├── main.ts                   # エントリーポイント
├── presentation/             # UI レイヤー
│   ├── handler/
│   │   ├── recordDetailShowHandler.ts
│   ├── component/
│   │   ├── completeButton.ts
├── application/              # ユースケース（ビジネスロジック）
│   ├── usecase/
│   │   ├── UpdateRecordUseCase.ts
├── domain/                   # ドメイン（データ構造とインターフェース）
│   ├── repositoryInterface/
│   │   ├── KintoneRecordRepositoryInterface.ts
│   ├── schema/
│   │   ├── BaseRecordSchema.ts
│   │   ├── AppRecordSchema.ts
├── infrastructure/           # インフラ（kintone API との接続）
│   ├── repository/
│   │   ├── KintoneRecordRepository.ts
├── common/                   # 共通レイヤー 
│   ├── di/
│   │   ├── DIContainer.ts

```
---
### 📝 コードの役割およびメリット
ここでは、主要なコードの役割とそのメリットを解説します。  
リファクタリングにより、**関心ごとの分離**、**テストの容易性**、**メンテナンス性の向上** などが実現されています。

``` ts: main.ts
import { RecordDetailShowEvent } from '../../types/kintone-event';
import { recordDetailShowHandler } from './presentation/handler/recordDetailShowHandler';
import { AppRecordType } from './domain/schema/AppRecordSchema';
import { DIContainer } from './common/di/DIContainer';

(() => {
  const diContainer = DIContainer.getInstance();
  const updateRecordUseCase = diContainer.getUpdateRecordUseCase();

  kintone.events.on('app.record.detail.show', 
    (event: RecordDetailShowEvent<AppRecordType>) => {
      recordDetailShowHandler(event, updateRecordUseCase);
      return event;
    });
})();
```

📌 **役割**  
- kintone の `app.record.detail.show` イベントをフックし、`recordDetailShowHandler` を実行。  
- `DIContainer` を利用し、`UpdateRecordUseCase` を取得してハンドラーに渡す。

💡 **メリット**  
- `DIContainer` を使うことで、`main.ts` では `UpdateRecordUseCase` の具体的な実装を意識せずに利用可能  
- 拡張や変更が容易になり、他のユースケースにも対応しやすい  
- `DIContainer` でモックを注入できるため、APIを呼ばずに検証可能    
- `main.ts` はイベントリスナーの登録だけを担当し、ビジネスロジックを持たない  

---

``` ts: presentation/handler/recordDetailShowHandler.ts
import { RecordDetailShowEvent } from '../../../../types/kintone-event';
import { AppRecordType } from '../../domain/schema/AppRecordSchema';
import { createCompleteButton } from '../component/completeButton';
import { UpdateRecordUseCase } from '../../application/usecase/UpdateRecordUseCase';

export const recordDetailShowHandler = (event: RecordDetailShowEvent<AppRecordType>, updateRecordUseCase: UpdateRecordUseCase) => {
  const headerMenuSpace = kintone.app.record.getHeaderMenuSpaceElement();
  if (!headerMenuSpace) {
    return;
  }

  const recordId = event.record.$id.value;
  const revision = event.record.$revision.value;

  const completeButton = createCompleteButton(recordId, revision, updateRecordUseCase);
  headerMenuSpace.appendChild(completeButton);
  return;
};
```

📌 **役割**  
- `app.record.detail.show` イベントで呼ばれ、UI（ヘッダーメニュー）に `完了` ボタンを追加。  
- `createCompleteButton` を呼び出し、`UpdateRecordUseCase` をボタンに渡す。

💡 **メリット**  
- `main.ts` からUIの処理を分離し、画面の変更がしやすい  
- `UpdateRecordUseCase` はボタンに渡すだけで、ハンドラー自体はデータ更新の処理を持たない  
- `createCompleteButton` を使い回せるため、別の画面にも適用しやすい 
 
---

``` ts: presentation/component/completeButton.ts
import { unknown } from 'zod';
import { UpdateRecordUseCase } from '../../application/usecase/UpdateRecordUseCase';

export const createCompleteButton = (recordId: string, revision: string, useCase: UpdateRecordUseCase): HTMLButtonElement => {
  const button = document.createElement('button');
  button.id = 'complete_button';
  button.innerText = '完了';

  button.onclick = async () => {
    try {
      await useCase.execute(recordId, revision);
      location.reload();
    } catch (error: unknown) {
      console.error(error);
      alert(error instanceof Error ? error.message : 'エラーが発生しました');
    }
  };  

  return button;
};
```

📌 **役割**  
- `完了` ボタンを作成し、クリック時に `UpdateRecordUseCase` を実行。  
- エラーハンドリングを行い、成功時はページをリロード。

💡 **メリット**  
- `recordDetailShowHandler.ts` からボタン生成処理を切り出し、再利用しやすくした  
- `UpdateRecordUseCase` を渡すだけで、データ更新処理の詳細を知らなくて済む   

---

``` ts: application/usecase/UpdateRecordUseCase.ts
import { KintoneRecordRepositoryInterface } from '../../domain/repositoryInterface/KintoneRecordRepositoryInterface';

export class UpdateRecordUseCase {
  private repository: KintoneRecordRepositoryInterface;

  constructor(repository: KintoneRecordRepositoryInterface) {
    this.repository = repository;
  }

  async execute(recordId: string, revision: string): Promise<void> {
    const updateParams = {
      id: recordId,
      revision,
      record: {
        ユーザー選択: { value: [{ code: kintone.getLoginUser().code, name: kintone.getLoginUser().name }]},
        日付: { value: new Date().toISOString().split('T')[0]}
      }
    };

    return this.repository.updateRecord(updateParams);
  }
}
```

📌 **役割**  
- レコードの更新処理を担当し、`KintoneRecordRepositoryInterface` を通じてデータを操作。  
- `完了` ボタンが押された際に、`ユーザー選択` フィールドと `日付` を更新。

💡 **メリット**  
- UIやAPIの実装に依存せず、データ更新のロジックを独立させることで、再利用しやすい  
- `KintoneRecordRepositoryInterface` を利用することで、データの保存先が変わっても影響を受けにくい  
- `KintoneRecordRepositoryInterface` をモック化することで、APIを実際に呼び出さずにテスト可能  

---

``` ts: domain/repositoryInterface/KintoneRecordRepositoryInterface.ts
import { AppRecordType } from '../../domain/schema/AppRecordSchema';

export interface KintoneRecordRepositoryInterface {
  updateRecord(params: {
    id: string;
    revision?: string;
    record: Partial<AppRecordType>;
  }): Promise<void>;
}
```

📌 **役割**  
- kintone のレコード更新処理のインターフェースを定義。  
- `UpdateRecordUseCase` が具体的なデータ保存方法に依存しないようにする。

💡 **メリット**  
- `UpdateRecordUseCase` がリポジトリの具体的な実装に依存せず、疎結合な設計になる  
- 実装を変更しても、`UpdateRecordUseCase` 側の修正を最小限に抑えられる  
- モックリポジトリを作成することで、実際のAPIを呼ばずにテスト可能  

---

``` ts: domain/schema/BaseRecordSchema.ts
import { z } from 'zod';

export const BaseRecordSchema = z.object({
  $id: z.object({ value: z.string() }),
  $revision: z.object({ value: z.string() }),
  レコード番号: z.object({ value: z.string() }),
  更新者: z.object({ value: z.string() }).optional(),
  作成者: z.object({ value: z.string() }).optional(),
  更新日時: z.object({ value: z.string() }).optional(),
  作成日時: z.object({ value: z.string() }).optional(),
});

export type BaseRecordType = z.infer<typeof BaseRecordSchema>;
```

📌 **役割**  
- kintone レコードの基本的なスキーマを定義。  
- すべてのレコードに共通するフィールド（`$id`、`$revision`、`レコード番号` など）を含む。

💡 **メリット**  
- `Zod` によるスキーマ定義で、データの整合性を保証  
- 共通のフィールドを統一管理し、`AppRecordSchema.ts` などで拡張可能  
- データ構造が明確になり、変更時の影響範囲を限定できる 

---

``` ts: domain/schema/AppRecordSchema.ts
import { z } from 'zod';
import { BaseRecordSchema } from './BaseRecordSchema';

export const AppRecordSchema = BaseRecordSchema.extend({
  ユーザー選択: z.object({ value: z.array(z.object({ code: z.string(), name: z.string() }))}),
  日付: z.object({ value: z.string().regex(/^\d{4}-\d{2}-\d{2}$/)})
});

export type AppRecordType = z.infer<typeof AppRecordSchema>;
```

📌 **役割**  
- `BaseRecordSchema` を拡張し、アプリ固有のフィールド（`ユーザー選択`、`日付`）を追加。  
- kintone のデータ構造を型安全に定義する。

💡 **メリット**  
- `Zod` によるバリデーションで、型の整合性を保証  
- `BaseRecordSchema.ts` を拡張することで、共通部分を一元管理しつつ、アプリ固有の定義を追加可能  
- スキーマを統一することで、変更時の影響を最小限に抑えられる  

---

``` ts: infrastructure/repository/KintoneRecordRepository.ts
import { KintoneRecordRepositoryInterface } from '../../domain/repositoryInterface/KintoneRecordRepositoryInterface';
import { AppRecordSchema } from '../../domain/schema/AppRecordSchema';

export class KintoneRecordRepository implements KintoneRecordRepositoryInterface {
  async updateRecord(params: {
    id: string;
    revision?: string;
    record: Partial<typeof AppRecordSchema._type>;
  }): Promise<void> {
    const parsedRecord = AppRecordSchema.partial().parse(params.record);

    const updateParams = {
      app: kintone.app.getId(),
      id: params.id,
      revision: params.revision,
      record: parsedRecord
    };

    await kintone.api(kintone.api.url('/k/v1/record', true), 'PUT', updateParams);
  }
}
```

📌 **役割**  
- `KintoneRecordRepositoryInterface` を実装し、kintone Rest API を使用してレコードを更新。  
- `AppRecordSchema` を利用してデータのバリデーションを行う。

💡 **メリット**  
- `UpdateRecordUseCase` は `Kintone API` の詳細を意識せずに利用可能  
- `Zod` を活用し、不正なデータが送信されるのを防ぐ  
- `KintoneRecordRepositoryInterface` を利用することで、APIの実装が変わっても `UpdateRecordUseCase` 側の修正を最小限に抑えられる  


---

``` ts: common/di/DIContainer.ts
import { KintoneRecordRepository } from '../repository/KintoneRecordRepository';
import { KintoneRecordRepositoryInterface } from '../../domain/repositoryInterface/KintoneRecordRepositoryInterface';
import { UpdateRecordUseCase } from '../../application/usecase/UpdateRecordUseCase';

export class DIContainer {
  private static instance: DIContainer;

  private recordRepository: KintoneRecordRepositoryInterface;
  private updateRecordUseCase: UpdateRecordUseCase;

  private constructor() {
    this.recordRepository = new KintoneRecordRepository();
    this.updateRecordUseCase = new UpdateRecordUseCase(this.recordRepository);
  }

  static getInstance(): DIContainer {
    if (!DIContainer.instance) {
      DIContainer.instance = new DIContainer();
    }
    return DIContainer.instance;
  }

  getUpdateRecordUseCase(): UpdateRecordUseCase {
    return this.updateRecordUseCase;
  }
}
```

📌 **役割**  
- `UpdateRecordUseCase` や `KintoneRecordRepository` のインスタンスを一元管理。  
- `getInstance` メソッドを使用し、`DIContainer` のシングルトンインスタンスを取得。

💡 **メリット**  
- `DIContainer` を通じて `UpdateRecordUseCase` を取得することで、各コンポーネントが具体的な実装に依存しない設計になる  
- `main.ts` や `recordDetailShowHandler.ts` では `DIContainer` からユースケースを取得するため、変更が容易  
- `DIContainer` を利用することで、テスト時にモックを注入しやすくなる  

## さいごに
サンプルコードを自分で整理してみることで、クリーンアーキテクチャの目指しているものやそのメリットが少しずつ理解できてきたように感じます。  
実際のカスタマイズでは、より複雑な構成やさまざまな課題に直面すると思いますが、実践の中で「あるべき設計」を模索しながら、理解を深めていきたいです。  

また、今回の整理を通じて、依存関係の管理やテストのしやすさを考える重要性を改めて実感しました。  今後は、クリーンアーキテクチャだけでなく、他の設計パターンやフレームワークにも目を向け、より柔軟で拡張性の高いシステム設計を学んでいきたいと思います。