🐈

クリーンアーキテクチャ in フロントエンド

2024/12/20に公開

はじめに

この記事は matsuri technologies 株式会社 Advent Calendar 2024 の12/18の記事です。

概要

この記事では、フロントエンドにおけるクリーンアーキテクチャの実装例を紹介しています。デモアプリとして、メモ機能とダークモードを搭載した簡易的なウェブサイトを作成しました。ユーザーが編集したメモやテーマは、Local Storageに永続化されます。

コードとデモアプリのリンクは下記のとおりです。

GitHub
Webサイト

SvelteKitを使用していますが、中心的な部分は基本的にTypeScriptで書かれています。そのため、NextやNuxt等のフレームワークでも同様のやり方で実装できると思います。

動機

クリーンアーキテクチャを導入することで、責務を分割し、変更に強いコードを書くことができます。

バックエンド開発で導入されることの多いこの手法ですが、フロントエンドではあまり採用事例を見かけない気がします(私の感想です)。考えられる理由として、小規模なアプリケーションにはオーバーキルであったり、フレームワークと共存させる書き方がわからない、といったものがあるでしょう。

しかし、フロントエンドでもクリーンアーキテクチャを導入することには大きなメリットがあります。例えば、変更に強いアプリケーションにすることで、技術的負債を最小限に抑えつつ多くの機能追加を行うことができます。また、データ取得部分を抽象化することで、アプリケーションのロジックに変更を加えることなくパフォーマンスの最適化を行うといったこともできます。

本記事では、小規模〜大規模までどのようなアプリケーションでも(多分)導入できるクリーンアーキテクチャを紹介します。

アーキテクチャ全体像

全体像は下図のとおりです。coreやroutesなどはディレクトリを表しています。矢印は依存の方向を表現しています。

全体像

このうち、アプリケーションの中心となるのは黄色で囲まれている coreserviceui(と場合によってはstate)です。

全体像 with 黄色い枠

各ディレクトリについては後ほど詳しく説明しますが、ここで簡単に紹介します。

coreではそのアプリケーションでよく使う型やメソッド、repositoryなどを定義します。serviceはcoreで定義されたメソッド等を活用し、より複雑なロジックを実装します。uiではcoreやserviceで定義・実装された型やロジックを活用しつつUIを表現します。また、アプリケーション全体の状態を管理したい場合もあると思います。その場合はstateディレクトリに配置します。

黄枠の外側はクリーンアーキテクチャーの外側に当たります。implはimplementationの名のとおり、coreで定義されたinterfaceを実装します。diは依存性注入(dependency injection)の名のとおり、coreの抽象を実装した具象オブジェクトを配置します。routesは、diに配置された具象オブジェクトをuiに渡して依存性注入を行います。

それぞれの深掘り

上記で紹介したディレクトリについてもう少し詳しく解説します。必要に応じてコードを見ながら読み進めていただければと思います。

core

アプリケーションの心臓部であり、クリーンアーキテクチャの有名な例の図でいうところの一番中心にあたります。どの層にも依存せず、外側のあらゆる層から依存されます。

ところで、coreは一般的なクリーンアーキテクチャの本などではdomainと呼ばれることが多いです。これは完全に私の個人的な感想なのですが、domainという名前になんとなくビジネス向けアプリケーションっぽいイメージを抱いております。クリーンアーキテクチャはビジネス・非ビジネス関係なく使えるアーキテクチャなので、より汎用的であるcoreという名前にしました。

本題に戻りましょう。coreは下記のような構造になっています。

core/
|
--- memo/
    |
    --- memo.ts
--- theme/
    |
    --- theme.ts

本機能の中心的な機能はメモ機能とテーマ設定の2つです。coreではそれぞれの機能で使う型やメソッド等をmemoやthemeで定義しています。

データの永続化を行うrepositoryや外部サービスとのinterface定義も同じ場所で行っています。例としてmemo.tsを見てみましょう。

export type Memo = {
	content: string;
	lastUpdatedAt: Date;
};

export interface IMemoRepository {
	get(): Memo;
	save(memo: Memo): void;
}

export interface IMemoTranslator {
	translate(memo: Memo, language: string): Promise<Memo>;
}

coreが関知するのはあくまでinterfaceだけであり、実装がどのように行われるかは後述するimplに委ねられます。永続化としてLocal Storageを使うのかIndexedDBを使うのか、はたまたfetch()でサーバーから取得してくるのかなどは、すべて関心の対象外です。ただし、外部サービスに依存せず、かつ複雑でないロジックは直接coreに書いても問題ありません。

また、外部サービスとの接続部分もinterfaceとしてcoreで定義します。例えば、(今回はimplでの実装は省略していますが、)IMemoTranslatorはChatGPTやLLama等の外部APIを叩いてmemoを翻訳するメソッドを抽象化したものです。

一般的なクリーンアーキテクチャでは永続化を担うrepositoryと、外部サービスとの接続を担うinterfaceは分けて配置されることが多いと思います。しかし、本記事ではrepositoryも外部サービスの一つと捉え、型や他のinterfaceと一緒の場所に配置します。

こうすることで、coreの実装者は型やrepository等の配置場所に迷うことなく、その要素を構成する機能を考えることだけに集中できます。

core要素が他のcore要素に依存することについてですが、これはとくに制限を設けていません。参考までに、新しくcore要素を追加する際の私の考え方を紹介します。

新しいcore要素が、既存のcore要素と独立している場合
この場合は普通に core/ 配下に新しくディレクトリを作成し、独立したcore要素として作成します。

新しいcore要素が、既存のcore要素と密接に関係する場合
新しいディレクトリは作成せず、該当する既存のcoreのディレクトリに入れます。例えばDogという要素を追加しようとする時を考えます。もしすでにAnimalという型が animal/ ディレクトリに存在している場合、Dogは animal/ ディレクトリに入れます。

// core/animal/animal.ts

export interface Animal {
	/// ...
}

export interface Dog {
	/// ...
}

新しいcore要素が、複数の他のcore要素に依存される場合
core/ 配下に新しくディレクトリを作成し、独立したcore要素として作成します。他のcore要素は、このcore要素をimportして使用します。

例えば、複数repositoryにまたがるトランザクションを管理するTransactionManager(名前は適当)を定義したい場合は、core/transaction/transaction.ts でinterfaceを定義します。他のcore要素やserviceはこれをimportし、複数repositoryにまたがるトランザクションを実現します。

service

serviceでは、coreで定義されたメソッドやrepositoryを活用し、より複雑なロジックを実現します。本記事のデモアプリでは、アプリ全体の初期化を行うinitをserviceに作成しています。

import type { IThemeRepository } from '$lib/core/theme';
import { theme } from '$lib/state';

export class Init {
	#themeRepository: IThemeRepository;

	constructor(themeRepository: IThemeRepository) {
		this.#themeRepository = themeRepository;
	}

	// Initialize app. Currently, it will read theme data from local storage
	// and set it to the state.
	async init(): Promise<void> {
		theme.value = await this.#themeRepository.get();
	}
}

今回のデモアプリでは見せられなったのですが、もう少し複雑なユースケースとして、core要素である user と auth を使用し、ログイン・会員登録処理を行うservice要素を作成するといったものも考えられます。

serviceもアーキテクチャの内側に位置しているので、外部サービスについては関知しません。これにより、純粋に core (と場合によっては state) のみを使用してアプリケーションロジックを作成することに集中できます。

ui

uiはcoreとservice (と場合によっては state)に依存し、これらを使用して必要なデータを取得・保存し、UIを表現します。コードはこちら

ui/componentsは、サイドエフェクトを与えない純粋なコンポーネントを定義します。ui/partsはcomponentsやcore, serviceを利用してアプリケーションに必要なほぼ全てのUIを実装します。

ポイントというか注意点として、UI層で完結するロジックはuiに記述する必要があります。例として、こちらのテーマ変更を行うロジックをご覧ください。

import type { IThemeRepository } from '$lib/core/theme';
import { theme } from '$lib/state';

export class ThemeHandler {
	#themeRepository: IThemeRepository;

	constructor(themeRepository: IThemeRepository) {
		this.#themeRepository = themeRepository;
	}

	applyCurrentTheme = async () => {
		const body = document.querySelector('body')!;

		if (theme.value === 'light') {
			body.classList.remove('dark');
			body.classList.add('light');
		} else {
			body.classList.remove('light');
			body.classList.add('dark');
		}
	};

	toggle = async () => {
		if (theme.value === 'light') {
			theme.value = 'dark';
			await this.#themeRepository.save('dark');
			this.applyCurrentTheme();
		} else {
			theme.value = 'light';
			await this.#themeRepository.save('light');
			this.applyCurrentTheme();
		}
	};
}

テーマ情報の永続化はcoreのThemeRepositoryを使用していますが、bodyのクラスを変更してUIに反映する部分はui側で記述しています。coreは自身の外側であるuiを関知しないので、ここの部分はuiで行う必要があるということです。これにより、core側とui側で明確に責務を分離することができます。

state

グローバルな状態を配置します。プロジェクトによってはグローバルに状態を持たないという意思決定をしているところもあると思うので、その場合はここは無視してOKです。

特に解説することもないです。依存の向きだけ気をつけておけば十分です。

impl

アーキテクチャの外側の解説に入ります。implではcoreのinterfaceを実装し、具象オブジェクトを返却します。

impl配下にはthemeやmemoとともにinternalというディレクトリがあります。今回のデモアプリでは使っていませんが、ここには外部サービスのライブラリなどを置くことを想定しています。例えばs3とopenaiのAPIを使う場合、internal配下は下記のような構造になります。

internal/
|
--- s3/
    |
    --- s3.ts
--- openai/
    |
    --- openai.ts

impl配下のcoreの具象オブジェクトは、これらinternalで用意されたライブラリを使って実装を行います。

di

依存性注入はこのディレクトリで行います。特にserviceが5、6個と多くののcore要素に依存する場合などは、このようにDIだけを行うディレクトリを置くことでコードの保守性を高める効果があります。

routes

diで用意したcoreやserviceの具象オブジェクトを、uiに注入します。SvelteKitはroutes/で行っていますが、Next.jsの場合はapp/で行うことになると思います。その他のフレームワークの場合も、おおよそトップレベルのファイルやディレクトリにあたる部分で同様のことを行います。

おわりに

今回紹介したクリーンアーキテクチャでは、外側の層がすべての内側にアクセスできるようにしたり、アダプターを用意しなかったりと、一般的なものに比べて簡略化されています。しかし、このような構成でも、クリーンアーキテクチャのメリットである責務の分離や、変更への強い耐性といった性質を獲得することができます。

実を言うと、この構成できちんとしたアプリケーションを作ったことはまだありません。いわゆる「ぼくのかんがえた最強のアーキテクチャ」です。なので、本アーキテクチャを使ってそれなりの規模のアプリケーションを作成した際には、また感想記事を書きたいと思います。

Discussion