自己紹介とこの記事を書こうと思った経緯
愛知県名古屋市の情報系の専門学校に通っている山田マイケルと申します。
高校卒業後、2年間正社員として働き、その後進学しました。
現在は、就職が決まり、チーム開発・AIモデルの精度向上やコンペティションへの参加・基礎学習・ローカルLLMを組み込んでの完全オフラインでの精度向上に励んでいます。
さて、現在、チーム開発にて多くの初学者やある程度の基礎文法を把握している方が入りました。私のタスクとして、バックエンドのアーキテクチャ整備をすることになりました。
この記事を書こうと思った背景としては、そのチームのメンバーに共有しようと思ったことがきっかけです。
また、チームのメンバーだけでなく、Publicに向けても発信するのもいい機会だと思ったので、Qiitaで書いてみることにしました。
この記事の目的はメンバーに対して書くものでありますから、アーキテクチャに加え、記事の最後の方には精神論や初歩的なことに言及していることがあるため、タイトルの内容のみ知りたいのであれば、取捨選択しながら読むようにお願いします。
本題
そもそもアーキテクチャとは?
ISO/IEC/IEEE 42010 に基づくと
"Fundamental concepts or properties of a system in its environment embodied in its elements, relationships, and in the principles of its design and evolution." The standard makes the following main points:
と記載されてました。
日本語訳だと、
システムを構成する要素、
それらの関係性、
そして設計や進化を導く原則
とのこと。
ほお、進化を導く・・ポケモンかデジモンのことか?
まあ、アーキテクチャ設計に触れているとなんとなくイメージはつきますが。
出典:
- ISO/IEC/IEEE 42010
https://www.oreilly.com/library/view/software-architects-handbook/9781788624060/43e6518b-3cd1-4ed0-ad5f-acbc38f52133.xhtml?utm_source=chatgpt.com
なぜアーキテクチャを意識しないといけないのか?
この記事が分かりやすいと思いました。
出典:
自分の現段階で言える解像度的に、責務が明確になり保守性が上がるのが一番のメリットだと感じています。
今のAI時代、全自動的に任せることもできますが、
修正のたびに変更範囲が大きいことは、
自分の負担が大きくなる、reviewerの負担も大きくなる
ため、アーキテクチャ段階から意識することで、全員を楽(らく)させることができます。
あとは、テストがし易くなることとかもあります。
他にもメリットが出典記事には書いてあるので、目を通してみてみてください。
アーキテクチャの種類において
これはかなり、深く、エンジニアの友達とも議論になる程です。
ありていに言ってしまうと、自分は言及できるほど、熟知しているわけではありませんが、種類やその特徴を知ることは、視野が広まるため、知っておいて損はありません。
- レイヤードアーキテクチャ
- 役割ごとに層を分けることで、責務を明確にします。理解もしやすく、学習コストが低いと思います。ただし、層を跨ぐ依存が増えやすいです
- ヘキサゴナルアーキテクチャ
- ドメインを中心におき、Port(インターフェース)とAdapter(実装)で外部のリソースと接続します。必要なときがあったら知るでいいです
- オニオンアーキテクチャ
- ドメインを中心に置きます。DDD(ドメイン駆動設計)との相性がいいです
- MVC
- みんな大好きなDjangoやRailsに使われています。知らんけど。
他にも
- みんな大好きなDjangoやRailsに使われています。知らんけど。
- MVVM
- モノリスアーキテクチャ
- モジュラーモノリス
- マイクロサービスアーキテクチャ
があるみたいです。
※ クリーンアーキテクチャ
※ 私はこれを言及するのに毎度言葉を選んでいます。私はクリーンアーキテクチャについてあくまで、設計思想として捉えていますが、アーキテクチャパターンとして扱う人もいます。
今回はレイヤードアーキテクチャについて解説します。
4層アーキテクチャとは?
4層ってことは3層とかもあるの?と思ったそこのあなた。
察しがいいですね。
そうです。3層もあります。
今回は4層のことに言及します。
種類としては、
- プレゼンテーション層
- アプリケーション層
- ドメイン層
- インフラストラクチャ層
があります。
※ 本記事では「Application層」と「Service層」を同義として扱います。
実装上は createStudentService のような Service(ファクトリ関数)が Application 層に配置されます。
今回は生徒データに関するデータの取得に着目して解説していきます。
層の関係性としては、
Presentation
↓
Application
↓
Domain
↑
Infrastructure
4層のそれぞれの特徴
プレゼンテーション層
ユーザーや外部システムとの窓口になる層のことです。
具体例で言うと、
- HTTPリクエストの受け取り
- パラメータの検証
- レスポンスの生成
- Application層の呼び出し
を行います。
export const createStudentController = (
studentService: IStudentService
) => {
return {
getStudentById: async (c: Context) => {
const studentId = Number(c.req.param("studentId"))
const student = await studentService.getStudentById(
studentId
)
return c.json(student)
}
}
}
アプリケーション(Service)層
アプリケーションのユースケースを表現する層です。
具体例で言うと、
- ユースケース実行
- 業務フローの制御
- トランザクション管理
- Domainの組み合わせ
- Repositoryの呼び出し
を行います。
export interface IStudentService {
getStudentById(
studentId: number
): Promise<StudentDto>
}
export const createStudentService = (
studentRepository: IStudentRepository
): IStudentService => {
return {
async getStudentById(
studentId: number
) {
const student = await studentRepository.findById(
studentId
)
if (!student) {
throw new Error(
"Student not found"
)
}
return {
id: student.id,
name: student.name
}
}
}
}
ドメイン層
システムの核となるビジネスルールを持つ層です。
DDDという概念がありますが、この層と最も密接に関わってきます。
具体例で言うと、
- ビジネスルール
- エンティティ
- 値オブジェクト
- ドメインサービス
- Repository Interfaceの定義(これは依存性逆転を意識する場合)
ここで初めて、依存性逆転というワードが出てきたため、言及しておきます。
なぜ依存性逆転を使うかというと
普通のレイヤードだと
Presentation
↓
Application
↓
Infrastructure
となり、
Applicationが、Infrastructureに対して直接依存してしまいます。
もし、使用するDBをMySQLからPostgreSQLに変更すると、Applicationまで修正が必要となります。
依存性逆転を適用すると
Presentation
↓
Application
↓
Domain
↑
Infrastructure
になり、
Application(サービス)層はDomain層で定義されたRepository Interfaceに依存します。
Infrastructure層はそのRepository Interfaceを実装します。
これにより、Application(サービス)層はRepository Interfaceを介してデータアクセスを行うため、
具体的なデータベース実装に依存する必要がありません。
そのため、MySQL用のRepository実装からPostgreSQL用のRepository実装に差し替えても、
Repository Interfaceが変わらない限り、Application(サービス)層やDomain層を変更する必要はありません。
今更となって申し訳ないのですが、
Repositoryとは、ドメインオブジェクトを永続化(保存・取得)するための窓口を表します。
用語の定義として混在してほしくないのは
本記事では混同を避けるため、
- Repository Interface = データアクセスの契約
- Repository実装 = Infrastructure層の具体実装
として扱います。
Interfaceとは何?って思ったら、
僕が以前書いた記事の冒頭にInterfaceについて言及しているので、見てみてください。
https://qiita.com/yamadacojp27/items/c20e11a86179883f7217
話は戻り、Domainは具体的な保存方法を知りません。
Domain層には Repository Interface という「保存するための契約」だけが定義されています。
Application層はRepository Interfaceを利用し、Infrastructure層がその実装を提供します。
Entity
export interface Student {
id: number
name: string
}
Entityとは、一意に識別できるIDを持つドメインオブジェクトのことです。
例えば Student であれば、
- id = 1, name = "田中"
- id = 1, name = "山田"
は名前が変わっていても同じ生徒として扱います。
このように「IDによって同一性を判断するもの」を Entity と呼びます。
現在は
- 生徒IDを持つ
- 生徒名を持つ
というデータを表現する役割のみを持っています。
ただし、将来的に - 名前は空文字にできない
- 特定の条件でのみ名前変更できる
などの日にビジネスルールが増えた場合は、Entityをclass化として実装し、振る舞いを持たせる選択肢もあります。
export class Student {
constructor(
public readonly id: number,
public readonly name: string
) {}
rename(newName: string) {
if (newName.length === 0) {
throw new Error("名前は必須です")
}
return new Student(this.id, newName)
}
}
つまり、Entityは必ずしもclassである必要はなく、プロジェクトの複雑さやドメインルールに応じてinterfaceとclassを使い分けることができます。
※実はAIなんかじゃないか?と疑わいたくなる人生2、3週した僕の友達いわく、TypeScriptは基本Classで書かなくていいと言ってました。大体interfaceで振る舞いを定義しているんですね。これはまた、後日自分の方でも調べてみることにします。
Repository Interface
export interface IStudentRepository {
findById(
studentId: number
): Promise<Student | null>
}
インフラストラクチャ層
技術的な実装を担当する層です。
具体例で言うと、
- DBアクセス
- 外部APIアクセス
- ファイル操作
etc...
export const createStudentRepository = (
db: D1Database
): IStudentRepository => {
return {
async findById(
studentId: number
) {
const result = await db
.prepare(`
SELECT
id,
name
FROM students
WHERE id = ?
`)
.bind(studentId)
.first()
if(!result) {
return null
}
return {
id: result.id as number,
name: result.name as string
}
}
}
}
4層についてのまとめ
実際の処理
Controller
↓
Application
↓
Repository Interface
↑
Repository実装
↓
DB
各層の役割
| 層 | 書くもの | 主な責務 |
|---|---|---|
| Presentation層 | Controller,Request,Response | 入出力 |
| Application層(Service) | Service・DTO・Service Interface(必要に応じて) | ユースケース実行 |
| domain層 | Entity, ValueObject, Repository Interface | ビジネスルール |
| Infrastructure層 | Repositoryの実装, DBアクセス、外部API | 技術実装 |
言及しなかった用語もありますが、
今回は概要を掴むためでありますので、用語や実装とのいい出会いがあったら、その都度調べてください。
当初私は、処理の流れとしては
当初、コールバック関数みたいな流れに似てない?と感じていましたが、依存性注入(DI)というパターンを渡しています。
DIとは?
自分で作らず、外から受け取る仕組みです。
渡してもらうことで、中身を知らなくていいですし、簡単に取り替えられます。
// ControllerはServiceを外から受け取る
function createStudentController(
studentService: IStudentService
) {
return {
getStudentById: async(c) => {
const id = Number(c.req.param("studentId"))
const student = await studentService.getStudentById(id)
return c.json(student)
}
}
}
// 呼び出す側が好きなServiceを渡せる
const service: IStudentService = createStudentService(studentRepository)
createStudentController(service) // 本物
// createFakeStudentService という偽物のServiceを返す関数があると仮定して
const fakeService: IStudentService = createFakeStudentService()
createStudentController(fakeService) // テスト用
そこでDIコンテナというワードが出てきます。
DIコンテナとは?
依存関係(Dependency)の生成と注入(Injection)を管理する仕組みのことです。
Spring Bootだとこれがデフォルトでついてますね。
C#関連のフレームワークにもついてるみたいです。知らんけど。
function createDIContainer(env) {
const db = getDb(env)
const studentRepository = createStudentRepository(db) // DBを渡す
const studentService = createStudentService(studentRepository) // Repositoryを渡す
const studentController = createStudentController(studentService) // Serviceを渡す
return { studentController }
}
このように各層に外から渡す作業を一括でやってくれます。
処理の読み方としては
- DBという材料が手に入る
- Repositoryを作る
createStudentRepositoryがDBを受け取って、findById・findAllという操作ができる道具箱を返す
studentRepository = { findById, findAll }
※この createStudentRepository は infrastructure/repositories/StudentRepository.ts
の実際のSQL実装を返す。
約束書き(interface)はIsStudentRepository.ts
TypeScriptのInterfaceは実行時には存在せず、コンパイル時の型チェックに利用されます。 - Serviceを作る
studentService = { getStudentById, getAllStudents } - Controllerを作る
studentController = { getStudentById, getAllStudent }
なぜRepository→Application→Controllerという順番なの?と思ったそこのあなた、察しがいいね。
答えとしては、依存関係の順番に作るしかないからです。
ControlerはServiceがないと作れない。
ServiceはRepositoryがないと作れない。
RepositoryはDBがないと作れない。
なので、この順番に処理を受け渡す必要があります。
では、DIコンテナがないとどうなるのか?気になりますよね。
ある場合
apiV1.get('students', c => {
const container = createDIContainer(c.env)
return container.studentController.getAllStudent(c)
})
apivV1.get('students/:studentId', c => {
const container = createDIcontainer(c.env)
return container.studentController.getStudentbyId(c)
})
ない場合
apiV1.get('/students', c => {
const db = getDb(c.env)
const studentRepository = createStuentRepository(db)
const studentService = createStudentService(studentRepository)
const studentController = createStudentConroller(studentService)
return studentController.getAllStudent(c)
})
apiV1.get('/students/:studentId', c => {
const db = getDb(c.env)
const studentRepository = createStuentRepository(db)
const studentService = createStudentService(studentRepository)
const studentController = createStudentConroller(studentService)
return studentController.getStudentById(c)
})
すっきりさが圧倒的ですね。
また、どこのファイルで定義されているの?と思いましたか?
プロジェクトによりますが、大体、di/container.ts などで管理されることが多いと思います。
呼ばれる箇所に関しては、大体エントリポイント(例:index.ts)ですかね。
const app = new Hono()
app.get(
"/students/:studentId",
async (c) => {
const container = createDIContainer(c.env)
return container
.studentController
.getStudentById(c)
}
)
つまり、流れとしては、
index.ts
↓
DI Container
↓
Repository生成
↓
Application生成
↓
Controller生成
↓
Controller実行
上記のようにルートが増えるたびに同じ組み立てをしないといけません。
レイヤードアーキテクチャの説明は以上です。
お疲れ様でした。
ソフトウェア開発のソの字も分かっていない頃(私が1、2年生の頃)、こういう技術記事を読むのって大変だし、活字に慣れていなかったので、全然読んでいませんした。
でも、日頃から読むことで、なんとなく耐性が付くし、興味も湧いてきます。
当時、初めてアーキテクチャに触れた際が、アーキテクチャが整備された後のmainブランチと自分の作業していたブランチをmergeした際に、大量にconflictを起こし、挫折した経験があります。当時はそれだけでもだいぶ精神的にもってかれました。あるていに言ってしまいますが、自分は悪くないと哀れんだ記憶があります。
私の好きなアニメの文豪ストレイドッグスに出てくる太宰治というキャラが「自分を憐れむな。自分を憐れめば、人生はあくなき悪夢だよ」と言っています。
つまり、根気強く足掻いてみろってことですね。
わからなければAIや知見のある人に聞くなりして、解決してみてください。
分からないことを質問して嫌がる人はそうそういないはずです。
最後に
ソフトウェア開発は生き物の面倒を見るものだと思っています。
特にチームで開発している以上、生き物の進化も早く、
プロのエンジニアとは違い、学生は怠惰が許されてしまう身分ではあります。
ただし、プロジェクトの進化・動きが早く、自分がプロジェクトに対してネグレクトでいると、自分が捉えていたものとは大きなギャップを生み、愛情を持てなくなる可能性があります。
いかにプロジェクトを日ごろから可愛がり、意識して愛情を注ぐと、自分のモチベーションの担保や愛着が湧いてくると思います。
可愛がるためには、内情を知っていないといけません。生き物で例えると、性格やその子が好きなものを構造として把握しておきなさいということです。(←は?w)
ソフトウェアも同じで、アーキテクチャを知っていないと、プロジェクトの変更をする際、自分自身がどう接っしたらういいか(機能の追加や変更など)が分からなくなってしまいます。
ですので、アーキテクチャの特性を理解する必要があるんですね。
話は変わりますが、ただタスクをこなすだけでは退屈になってしまいます。
ポケモンでレベル上げや進化というものがありますね。
育て屋に預けると勝手にレベルが上がりますが、自分が好みの技や与えたいパラメータの割り振りができずに、思い通りに育たず、主要メンバーとなる手持ちポケモンに入れる気持ちがなくなってしまいます。
これと同じで、自分が当事者となり、いかに自分好みのプロダクトに育てるかも意識できるといいと思います。
つまり、ただ、タスクをこなすだけではなく、当事者意識を持ち、プロダクトに対して不満やどうなの?と思ったことを議論に挙げて、自ら実装するムーブを噛ませるとあち〜すっね。
今後この記事では更新を行うかもしれないです。
参考になったものがあれば出典元記載のもと、記載しますね。
間違っている個所や指摘したい箇所があったら、遠慮なくコメントください。私のためになります。