11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-03-03

はじめに

大学最後の春休みも残すところあと1ヶ月となった あなP(@annap_ms)です。

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

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

クリーンアーキテクチャとは

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

詳しくは、以下の記事で提唱されている概念を参考にできます。
クリーンアーキテクチャの概念(Robert C. Martin)

既存のコードとその問題点(Before)

サンプルコード

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

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


📝 コードの役割およびメリット

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

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 はイベントリスナーの登録だけを担当し、ビジネスロジックを持たない

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 を使い回せるため、別の画面にも適用しやすい

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 を渡すだけで、データ更新処理の詳細を知らなくて済む

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を実際に呼び出さずにテスト可能

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を呼ばずにテスト可能

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 などで拡張可能
  • データ構造が明確になり、変更時の影響範囲を限定できる

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 を拡張することで、共通部分を一元管理しつつ、アプリ固有の定義を追加可能
  • スキーマを統一することで、変更時の影響を最小限に抑えられる

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 を利用してデータのバリデーションを行う。

💡 メリット

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

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;
  }
}

📌 役割

  • UpdateRecordUseCaseKintoneRecordRepository のインスタンスを一元管理。
  • getInstance メソッドを使用し、DIContainer のシングルトンインスタンスを取得。

💡 メリット

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

さいごに

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

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

11
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?