はじめに
記事を閲覧いただきありがとうございます。
突然ですが、WEBアプリ開発に取り組んでいる方の中で、Udemyなどの学習教材でReactの基礎を学んだものの、実際業務で目にするソースコードの構成がまったく異なり、戸惑いを感じた経験がある方も多いのではないでしょうか。
筆者は業務でReact(TypeScript)とSpringBoot(Kotlin)を用いてWEBアプリの開発を行っています。その中で、これらの基礎の技術を学ぶ過程としてTODOアプリを題材にアーキテクチャの基本設計を学びました。本記事ではその学びを備忘録としてまとめ、共有させていただきます。
なお、本記事で取り上げる設計はあくまで一例((一設計思想)であり、唯一無二の正解ではありません。読者の方の中で、さらに良い設計アイデアや改善案がある場合は、ぜひコメントいただけると幸いです。
本記事の対象
React(TypeScript)とSpringBoot(Kotlin)の基礎を学習済みの方を対象としています。
なお、本記事では細かいコードの説明行わず、基本的なアーキテクチャ設計の概要を示すことに重点を置いています。その点をあらかじめご了承ください。
本記事で紹介するアーキテクチ
本記事では上記のアーキテクチャを基に設計を進めていきます。できるだけ飲み込みができるようレストランを例に図を作成しています。説明を読んでいく中で不明点が出てきた場合は、この図を参考にしてください。
それでは、それぞれの層について説明していきます。
フロントエンドの層
1. View(ウェイター)
役割:画面の表示とユーザーからの操作を受け付ける部分です。
例えるなら…
「Viewはレストランのウェイターやウェイトレス」です。お客様(ユーザー)から注文を聞き取り、キッチン(バックエンド)に伝えます。また、料理が出来上がったら、それをお客様のテーブルまで運びます。
2. Repository(オーダー端末)
役割:Viewが受け取った情報を整理し、外部通信を担当するHttpに伝える役割を担います
例えるなら…
「Repositoryはウェイターが使うオーダー入力端末」 です。ウェイター(View)が料理の注文を入力すると、この端末から注文を具体的な形でキッチン(バックエンド)に送信し、完成した料理(データ)を受け取ってウェイターに返します。
3. Http(通信装置)
役割:外部のシステム(バックエンド)とやり取りを行う部分です。
例えるなら…
「Httpはオーダー端末とキッチンを繋ぐ通信ケーブルや無線機」です。Repositoryから受け取った注文を正確にキッチン(バックエンドのController)に送信し、調理が終わった料理(レスポンス)をRepositoryに戻します。
バックエンドの層
1. Controller(注文受付係)
役割:外部からのリクエストを受け取り、どの処理を実行するかを判断する部分です。
例えるなら…
「Controllerはキッチンのオーダー受付係」です。ウェイターから届いた注文を受け取り、シェフ(Service)に指示を出します。そして、完成した料理をウェイターに渡す役割も担います。
2. Service(シェフ)
役割:ビジネスロジックを実行する中心的な部分です。
例えるなら…
「Serviceはキッチンのシェフ」です。オーダー管理係(Controller)からの指示に基づき、材料(データ)を使って調理を行い、料理(結果)を作ります。そして、それをオーダー管理係に渡します。
3. Repository(倉庫管理係)
役割:データベースとのやり取りを担当する部分です。
例えるなら…
「Repositoryはシェフのために材料を調達する倉庫管理係」です。シェフが「この材料を取り出してほしい」または「作った料理を保管してほしい」と頼むと、倉庫管理者はその指示に従って倉庫(Database)から材料を取り出したり、料理を保存したりします。
4. Database
役割:データを保管する場所です。
例えるなら…
「Databaseは倉庫」です。Repositoryがデータを取り出したり保存したりするために利用します。この倉庫には、料理の材料やレシピといった重要な情報が整理されて保管されています。
以上が各層の説明です。
開発環境の準備
今回、開発をスムーズに始められるようテンプレート用意しました。
ターミナルより、任意のディレクトリで上記のリポジトリをcloneしてください。
コマンドは以下です。
https://github.com/ReactLearningDepot/TemplateApp.git
cloneが完了したら、webディレクトリに移動し、プロジェクトで必要となるパッケージをインストールします。
cd ToDoApp/web
npm install
以上で開発準備は完了です。
フロントエンド構築
それではToDoアプリを作成していきましょう。今回はフロントエンドから実装を始めます。これはアウトサイド・イン・アプローチ(Outside-In Approach)の考えに基づいています。
以下、ChatGPTの説明を引用
アプリ開発における「アウトサイド・イン・アプローチ(Outside-In Approach)」とは、ユーザーの視点やニーズを最優先にして開発を進める手法です。このアプローチでは、まず最初にアプリを利用するユーザーがどのようにアプリを使うか、ユーザー体験(UX)やユーザーインターフェース(UI)を設計します。その後、内部の技術的な部分や実装を考慮していきます。
アーキテクチャを適用しない書き方
まず、アーキテクチャを使用しないフロントエンドコードの例を示します。
GETリクエスト
以下のコードをApp.tsxに記述してください。
// App.tsx
import './App.css'
import {useEffect, useState} from "react";
function App() {
const [todos, setTodos] = useState<Todo[]>([])
useEffect(() => {
fetch('/api/todos')
.then(res => res.json())
.then(data => setTodos(data))
}, []);
return (
<>
<ul>{
todos.map(todo => (
<li key={window.crypto.randomUUID()}>{todo.text}</li>
))
}</ul>
</>
)
}
export default App
type Todo = {
text: string
}
このコードでは、Appコンポーネントが初期レンダリングされる際にuseEffect
が実行され、以降バックエンドで作成するエンドポイント/api/todos
にfetch
を使ってGET
リクエストを送信しています。返ってきたレスポンスはuseState
で定義したsetTodosを使ってtodos
に保持され、<li>
タグにより画面上に表示されます。
POSTリクエスト
次に、ToDoを登録する処理を先ほどのコードに追加します。
// App.tsx
import './App.css'
import {useEffect, useState} from "react";
function App() {
const [todos, setTodos] = useState<Todo[]>([])
const [draftTodo, setDraftTodo] = useState<string>('') //追加
useEffect(() => {
fetch('/api/todos')
.then(res => res.json())
.then(data => setTodos(data))
}, []);
//追加
const onClickSaveButton = () => {
fetch('/api/todos', {
method: "POST",
body: draftTodo,
headers: {"Content-Type": "application/json"}
})
.then(res => res.json())
.then(todo => setTodos([...todos, todo]))
}
return (
<>
<input type="text" onChange={e => setDraftTodo(e.target.value)}/> {/* 追加 */}
<button onClick={onClickSaveButton}>登録</button> {/* 追加 */}
<ul>{
todos.map(todo => (
<li key={window.crypto.randomUUID()}>{todo.text}</li>
))
}</ul>
</>
)
}
export default App
type Todo = {
text: string
}
このコードでは、input
要素に入力した文字列をuseState
で定義したsetDraftTodo
を使ってdraftTodo
に保持し、button
をクリックした際にfetch
のbodyに入力内容を含めてPOST
リクエストを送信します。さらに、バックエンドから返ってきた新しいToDoをtodosに追加し、リストに表示される仕組みです。
以上のコードで、ToDoリストの取得(GET)と登録(POST)が可能になります。ここまでの内容は、Reactの基礎を学んだ方であれば実装経験があるのではないでしょうか。次に、これをどのようにアーキテクチャに組み込んでいくかを見ていきます。
アーキテクチャを適用した書き方
先ほどのコードをアーキテクチャを適用した形に変更し、View、Repository、Httpの層に分割した実装を示します。
View層
まず、View層の役割について説明します。ここではApp.tsxがView層に該当します。この層は、UIの表示やユーザー操作にのみ責任を持たせ、データの取得や保存といった処理はRepository層に委ねます。
import './App.css'
import {useEffect, useState} from "react";
import {TodoRepository} from "./TodoRepository.ts";
interface Props {
todoRepository?: TodoRepository
}
function App(
{todoRepository = new TodoRepository()}: Props
) {
const [todos, setTodos] = useState<Todo[]>([])
const [draftTodo, setDraftTodo] = useState<string>('')
useEffect(() => {
// Repositoryに委ねてデータを取得
todoRepository.getTodos()
.then(data => setTodos(data))
}, []);
const onClickSaveButton = () => {
// Repositoryに委ねてデータを登録
todoRepository.postTodo(draftTodo)
.then(todo => setTodos([...todos, todo]))
}
return (
<>
<input type="text" onChange={e => setDraftTodo(e.target.value)}/>
<button onClick={onClickSaveButton}>登録</button>
<ul>{
todos.map(todo => (
<li key={window.crypto.randomUUID()}>{todo.text}</li>
))
}</ul>
</>
)
}
export default App
export type Todo = {
text: string
}
この変更によって、以下のような構造が実現されます:
- GETリクエスト(
useEffect
内部)やPOSTリクエスト(onClickSaveButton
内部)の処理が、Repository層に委ねられています - Repositoryがどのようにデータの送受信を行うかについて知らなくても、Repositoryクラスのメソッドを呼び出すだけで良い
このように層を分離することで、疎結合を達成しています
疎結合とは、各層がそれぞれ独立して役割を果たし、他の層の実装詳細に依存しない状態を指します。これにより、変更やテストが容易になります。
Repository層
次に、Repositoryの実装例を示します。この層はデータの取得・操作を行う責任を持ちます。Http通信はHttp層に委ねます。
以下のコードを新規作成したTodoRepository.ts
に記述してください。
// TodoRepository(新規作成)
import {Todo} from "./App.tsx";
import {Http} from "./NetworkHttp.ts";
export class TodoRepository {
http: Http
constructor(http: Http = new Http()) {
this.http = http
}
getTodos(): Promise<Todo[]> {
return this.http.get('/api/todos') as Promise<Todo[]>
}
postTodo(todo: string): Promise<Todo> {
return this.http.post('/api/todos', JSON.stringify({ text: todo }))
}
}
この構造では、以下の流れが確立されます:
- getTodos: /api/todosにGETリクエストを送り、ToDoリストを取得します
- postTodo: /api/todosにPOSTリクエストを送り、新しいToDoを登録します
これにより、View層はデータ取得や登録の実装詳細に依存せず、TodoRepositoryのメソッドを使うだけで処理を実行できます。
Http層
最後に、Http層を実装します。この層は、Repository層から依頼を受けて、実際にバックエンドとHttp通信を行います。
以下のコードを新規作成したNetworkHttp.ts
に記述してください。
// NetworkHttp(新規作成)
export class Http {
get<T>(url: string): Promise<T> {
return fetch(url)
.then(res => res.json())
}
post<T>(url: string, body: string): Promise<T> {
return fetch(url, {
method: "POST",
body: body,
headers: {"Content-Type": "application/json"}
})
.then(res => res.json())
}
}
このコードでは、以下のような役割分担を実現しています:
- get: 指定されたURLにGETリクエストを送り、レスポンスをJSON形式で返します
- post: 指定されたURLにPOSTリクエストを送り、レスポンスをJSON形式で返します
これでフロントエンドのアーキテクチャ適用例の解説は終了です。次はバックエンドの実装に進みましょう。
バックエンド構築
バックエンドでは、アーキテクチャ設計に基づき以下の層に分けて実装します:
- Model: データの構造を定義
- Entity: データベーススキーマを定義
- Controller: HTTPリクエストを処理
- Service: ビジネスロジックを実装
- Repository: データベース操作を抽象化
- 設定ファイル: アプリケーション設定を管理
Model
はじめにModelの例を示します。ModelではHttp通信でやりとりするデータ型を定義します。今回TodoModelはtextフィールドを持ちます。
// TodoModel.kt
data class TodoModel (
val text: String,
)
Entity
続いてEntityを作成します。Entityではデータベーススキーマを定義します。
@Entityアノテーションを用いてtodosテーブルを定義しています。
// TodoEntity.kt
import jakarta.persistence.Entity
import jakarta.persistence.Id
import java.util.*
@Entity(name = "todos")
data class TodoEntity (
@Id
val id: UUID = UUID.randomUUID(),
val text: String
)
Controller
続いてControllerを作成していきます。ControllerはHttp通信の窓口を担います。
今回は/api/todos
のエンドポイントにGET
もしくはPOST
リクエストが与えられるとService層が持つメソッドを呼び出す仕組みとなっています。
// TodoController.kt
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/todos")
class TodoController(private val todoService: TodoService) {
@GetMapping
fun get(): List<TodoModel> {
return todoService.get()
}
@PostMapping
fun post(@RequestBody body: String): TodoModel {
return todoService.post(body)
}
}
Service
次に、Service層を実装します。この層ではRepositoryを利用して、データベース操作を委譲しています。Service層では通常ロジックを記述しますが、今回は特にロジックがないので、Repositoryを呼ぶだけのシンプルな構造となっています。
// TodoService.kt
@Service
class TodoService(private val todoRepository: TodoRepository) {
fun get(): List<TodoModel> {
return todoRepository.findAll().map {
TodoModel(it.text)
}
}
fun post(text: String): TodoModel {
val savedTodo = todoRepository.save(TodoEntity(text = text))
return TodoModel(text = savedTodo.text)
}
}
Repository
次に、Repository層を実装します。この層ではデータベース操作を行います。今回はデータベースにインメモリのH2、データベース操作にはSpring Data JPAのCRUDRepository
を使用しています。
// TodoRepository.kt
import org.springframework.data.repository.CrudRepository
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
interface TodoRepository: CrudRepository<TodoEntity, UUID> {}
application.yml
最後にapplication.yml
を編集しましょう。Springbootを立ち上げた直後はapplication.property
というファイルがありますが、今回はymlに変更しています。こちらは好みだと思いますので、好きな方で記述してください。
application.yml(拡張子を変更しています)
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:testdb;MODE=PostgreSQL
username: sa
password: password
以上でバックエンド側の実装も完了です。
最後にフロントバックの両方のサーバーを起動し、Todoの登録・表示が可能か確認してみてください。
まとめ
本記事では、Web開発の基礎講座ではあまり触れられない「アーキテクチャ設計」について、フロントエンドとバックエンド双方の基本設計例を紹介しました。記事全体を通して、層ごとに役割を明確に分けることで、保守性や拡張性を高める方法を解説しました。
細かいコードの説明は割愛しましたが、まずは実際にコードを書きながら、手を動かして学んでみてください。わからない部分があれば、その都度調べて理解を深めることで、スキルアップにつながるはずです。本記事が、アプリケーション開発における設計力向上の一助となれば幸いです。
なお、今後の記事では本記事の続編として「テストの実装例」についても取り上げる予定です。ぜひそちらもご覧いただけると嬉しいです!