ハンバーガーメニュー

Menu

【バックエンド】必要になってから層を分割してみたらアーキテクチャの重要性を実感した

このブログは基本自作しているんですが、最近機能の物足りなさを感じています。
記事に対するコメント機能もないですし、管理画面で言えばサムネイルをブログ上からアップロードできる機能もありません。

しかし現状これらは実現できない事情があります。
その事情とは「バックエンド側をすべてmicroCMSに頼っている」ことです。

https://www.arfes.jp/article/astz6mijti

ブログを作った当初はバックエンドに関する知識が全くと言っていいほどなかったのでめちゃくちゃありがたいサービスとしてmicroCMSを使用させていただいていたんですが、最近になってお困りが発生する様になりました。

それが先ほど挙げた2点です。
より正確に言えば以下の通りです。

  • カスタムAPIが3つまでしか生やせない
  • 画像のアップロードAPIが使えない

これらはmicroCMSの機能としては存在しているのですが無料で使用できるHobbyプランでは使用することができません。

流石にただの個人ブログに有料プランとして登録するほどのモチベーションは無いため今回バックエンドのスクラッチで自作することにしました。

技術構成

  • Hono
  • Drizzle ORM
  • postgreSQL
  • Docker
  • OpenAPI

Honoが熱いらしいので使ってみました。TSで書けるのがいい感じで、薄いので描き心地も違和感なかったです。

本題:後から層を分割してみた

今回はバックエンド開発の練習という目的もあったので、最初は最小限で作ってみて、そこから必要に応じて層を足していこうかな〜という気持ちで開発を始めました。

ここからはその変遷の過程と共にどんなことを学んだのかを書いていこうと思います。

1段階目:何も分割しない期

最初は層の分割すらせずに、ひたすらcontroller層(層を分けてないので不適切な気もしますがこう呼ぶことにします)にベタ書きする構成でした。
image

src/api配下のファイルにそのままORMのコードを書いて直接DBにアクセスする方法だったのでとっても単純に書けますね。正直ごく小規模なものを作るんだったらこれで全然いいなって思いました。

app.openapi(fetchArticleListRoute, async (c) => {
	const allArticles = await db
		.select()
		.from(articlesTable)
		.where(sql`${articlesTable.deletedAt} IS NULL`);
	return c.json({ contents: allArticles });
});

2段階目:Repository層とDomain層の分割

本当に最小限のものを作るのであれば1段階目で十分だったんですが、これだとエラーハンドリングをする時に困りました。

例えばブログの記事を投稿する時に重複したタイトルがあると困るので、それを確認して重複してたらエラーを出したいとなることがあると思います。

それは「記事を投稿する」という本質的な処理とは異なりますが、ドメインに紐づいているものなのでdomain層に分割したくなります。

しかしここでdomain層にも記事の取得処理を書いてしまうとcontroller層と全く同じコードを書くことになりあまりに冗長なので、共通化したいなぁという気持ちになりました。
そこでrepository層を追加してdomain層とcontroller層で共通のDBアクセスを行うORMのコードを共通化することにしました。
これによって冗長性が解消してハッピーな気持ちになりました。

// controller(エラーハンドリングの部分は本質では無いので省略している)
app.openapi(postArticleRoute, async (c) => {
	const body = c.req.valid("json");
	await checkDuplicateTitle(body.title);
	const res = await createArticle(body);
	return c.json(res);
});

// domain
export const checkDuplicateTitle = async (title: string) => {
	const existingArticle = await findArticleByTitle(title);
	if (existingArticle.length > 0) {
		throw new HTTPException(400, {
			message: "An article with the same title already exists",
		});
	}
};

// repository
export const findArticleByTitle = async (title: string) => {
	return await db
		.select()
		.from(articlesTable)
		.where(eq(articlesTable.title, title));
};

一応の補足でrepositoryを実装する際には抽象クラスを定義してそれを継承した具象クラスを定義するのがセオリーだと思うのですが今回はそれを行いませんでした。

このような抽象クラスを定義するやり方を取るメリットは

  • DBアクセスのORMなどが変更する際に結合度を減らすことによって変更コストが小さい
  • 先に抽象クラスを定義しておくことで具象クラスが定義される前により高レイヤーの層の実装を並行で進められる

の様なものがあると理解しているんですが、今回の実装では「必要になったら実装する」がポリシーなので現状必要ないこれら抽象クラスは実装しませんでした。

3段階目:Service層の分割

次の段階として、テストを書きたくなりました。
repositoryをテストする分には現状のディレクトリ構成でも問題なかったのですが、よりAPI全体の動作をテストしたいとなった時に現状のcontrollerだと難しい点がありました。

というのも、テストは関数単位でしかできないにも関わらず、controllerの内容は関数として定義されているのではなく登録しているだけなのでテストのしようがありません。

// 関数として定義されていないので呼び出し不可能でテストもできない
app.openapi(postArticleRoute, async (c) => {
	const body = c.req.valid("json");
	await checkDuplicateTitle(body.title);
	const res = await createArticle(body);
	return c.json(res);
});

ということでService層にAPIの本質的な処理の部分を移してテスト可能な形に変更しました。

// controller
app.openapi(postArticleRoute, async (c) => {
	return handleErrors(async (ctx) => {
		const body = ctx.req.valid('json')
		const res = await svc.article.create(body)
		return ctx.json(res)
	}, c)
})

// service
async create(body: ArticleInputSchema) {
	await domain.article.isUniqueTitle(body.title)
	await domain.article.exists(body.category)
	return await repo.article.create(body)
}

// domain
async isUniqueTitle(title: string) {
	const existingArticle = await repo.article.findByTitle(title)
	if (existingArticle.length > 0) {
		throw new HTTPException(400, {
			message: 'An article with the same title already exists',
		})
	}
}

async exists(articleId: string) {
	const existingArticle = await repo.article.findById(articleId)
	if (existingArticle.length === 0) {
		throw new HTTPException(400, {
			message: 'The specified article does not exist',
		})
	}
}

// repository
async create(body: ArticleInputSchema) {
	const res = await db
		.insert(articlesTable)
		.values(body)
		.returning({ id: articlesTable.id })
	return res[0]
}

これでよくあるリポジトリパターンのディレクトリ構成になったんじゃ無いかと思います。

おわりに

初めてまともにバックエンドを構築してみたんですが、自分で実際に作ってみることで層の分割の重要性に気づくことができてとても良かったです。

ここから基本以上のコードを書ける様により学習を進めていけたらと思います。

やたのアイコン画像

YUKI YATA

githubのアイコン画像xのアイコン画像zennのアイコン画像

横浜国立大学経営学部3年
フロントエンドエンジニアを目指しています。
Vue / React / Go / Laravel