第12章:Service層でControllerを太らせない🍔➡️🥗
この章は「機能が増えたらControllerがデブになる問題💦」を、Service層でスッキリ解決する回だよ〜!😆✨ CampusTodoを、あとから機能追加しても崩れにくい形にしていくよ💪🧠
0) いまどきTSまわり小ネタ(最新状況)🧊✨
- TypeScriptは5.9系がドキュメント更新されてる(2026-01-12更新)ので、学習の基準はここでOKだよ📘✨ (TypeScript)
- Microsoft側では **TypeScript 6.0は“橋渡し”、7.0はネイティブ化(Go移植)**の話が進んでるよ🚀(ビルド高速化が主役) (Microsoft for Developers)
- Node.jsは v25がCurrent、v24がActive LTSって並び(2026-01-12更新)🟩🟦 (Node.js)
- Viteは「Baseline(2026-01-01基準)」の話などが公式ガイドに出てる(ブラウザターゲット方針が明記)🌍✨ (vitejs)
(ね、いまは“ツールが爆速化”に向かってる流れが強いのよ🤖⚡)
1) まず問題:Fat Controllerってなに?😇🍔
機能が増えると、Controllerがこうなりがち👇
- DOMから値を取る🖱️
- 入力チェックする🧾
- Modelを作る📦
- Todo配列を更新する🧺
- エラー文言も考える💬
- View更新もする🎨
- ついでにログも…📣
👉 これが積み重なると 「Controllerが何でも屋になって、読めない・直せない・テストしにくい」 が起こるよ〜😭💥
2) Service層の役割(超だいじ)🧠✨

Service層はひとことでいうと…
✅ 「アプリの“やりたいこと(ユースケース)”を実行する係」 🎮✨
CampusTodoなら、ユースケースは例えば:
- Todoを追加する➕✅
- 完了を切り替える🔁✅
- タイトルを編集する✏️
- 削除する🗑️
- (次章以降で)保存する💾
ざっくり責務の分け方🍱
- Controller:イベント受ける・Service呼ぶ・結果をViewへ渡す🚦
- Service:手順の中心(入力→Model操作→結果まとめ)🧩
- Model:ルールを守る(不変条件)🛡️
- View:表示だけ🎨
3) どれをServiceに移す?迷ったらこの基準🧭✨

Serviceに寄せるのは👇
- 「追加」みたいな動詞の処理(ユースケース)🏃♀️
- 「複数ステップ」がある処理(検証→作成→保存→返却)🔁
- 「UIと関係ない」処理(純粋にアプリの都合)🧠
逆にServiceに置かない👇
-
DOM操作・HTML生成(それView!)🚫
-
alert()とかUI通知(それController/View!)🚫 -
ルールの本体(それModel!)🚫
- Serviceはルールを“使う”側だよ👀✨
4) 実装してみよう:Service導入でController痩せる🏋️♀️✨
ここからは「追加処理」をServiceに移すよ➕✅ (次章のLocalStorageにも繋がる“良い形”で!)
4-1) Result型(成功/失敗を返す箱)📦✨
「失敗してもアプリ落とさない😌」ために、Serviceはこう返すのが便利👇
// src/shared/result.ts
export type Result<T> =
| { ok: true; value: T }
| { ok: false; errors: string[] };
export const ok = <T>(value: T): Result<T> => ({ ok: true, value });
export const fail = (errors: string[]): Result<never> => ({ ok: false, errors });
4-2) Model(TodoItem)🛡️📦
Model側で「空タイトル禁止」みたいな最低限のルールを守るよ✨ (第9章の“不変条件”の考え方ね🛡️)
// src/model/todoItem.ts
export type TodoId = string;
export type TodoItem = {
id: TodoId;
title: string;
done: boolean;
createdAt: number; // epoch ms
};
export const TodoRules = {
validateTitle(title: string): string[] {
const t = title.trim();
const errors: string[] = [];
if (t.length === 0) errors.push("タイトルが空だよ🥺 何する課題か書いてね!");
if (t.length > 60) errors.push("タイトルが長すぎるかも…!60文字以内にしてみよ📝");
return errors;
},
};
export const TodoFactory = {
create(title: string, now = Date.now()): TodoItem {
// ルールはServiceでも確認するけど、最終防衛線はModel側にも置けると強い💪
return {
id: crypto.randomUUID(),
title: title.trim(),
done: false,
createdAt: now,
};
},
};
4-3) “保存場所(今はメモリ)”を薄く分離🧺✨
まだRepository章じゃないから、まずは簡単に「置き場所」だけ作るよ(後で差し替えやすくなる👍)
// src/model/todoStore.ts
import { TodoItem, TodoId } from "./todoItem";
export class TodoStore {
private todos: TodoItem[] = [];
getAll(): TodoItem[] {
return [...this.todos];
}
add(todo: TodoItem): void {
this.todos = [todo, ...this.todos];
}
toggleDone(id: TodoId): boolean {
const idx = this.todos.findIndex(t => t.id === id);
if (idx < 0) return false;
const current = this.todos[idx];
this.todos[idx] = { ...current, done: !current.done };
return true;
}
}
4-4) Service(ユースケースの中心)🥗✨
ここが主役! Controllerが抱えてた「追加処理の手順」を全部ここへ移すよ〜🧠🔁
// src/service/todoService.ts
import { Result, ok, fail } from "../shared/result";
import { TodoFactory, TodoItem, TodoRules, TodoId } from "../model/todoItem";
import { TodoStore } from "../model/todoStore";
export class TodoService {
constructor(private readonly store: TodoStore) {}
addTodo(title: string): Result<TodoItem> {
const errors = TodoRules.validateTitle(title);
if (errors.length > 0) return fail(errors);
const todo = TodoFactory.create(title);
this.store.add(todo);
return ok(todo);
}
toggleDone(id: TodoId): Result<void> {
const changed = this.store.toggleDone(id);
if (!changed) return fail(["そのTodoが見つからなかったよ🥲(一覧が古いかも)"]);
return ok(undefined);
}
getAll(): TodoItem[] {
return this.store.getAll();
}
}
4-5) Controller(イベントだけ担当にして痩せる)🚦✨
Controllerは「DOM→Service→View」だけやる感じにするよ! “手順の中心”がなくなるのでスッキリ✨
// src/controller/todoController.ts
import { TodoService } from "../service/todoService";
import { TodoView } from "../view/todoView";
export class TodoController {
constructor(
private readonly service: TodoService,
private readonly view: TodoView
) {}
init(): void {
this.view.bindAdd((title) => this.onAdd(title));
this.view.bindToggle((id) => this.onToggle(id));
this.render();
}
private onAdd(title: string): void {
const result = this.service.addTodo(title);
if (!result.ok) {
this.view.showErrors(result.errors);
return;
}
this.view.clearErrors();
this.view.clearInput();
this.render();
}
private onToggle(id: string): void {
const result = this.service.toggleDone(id);
if (!result.ok) {
this.view.showErrors(result.errors);
return;
}
this.view.clearErrors();
this.render();
}
private render(): void {
const todos = this.service.getAll();
this.view.render(todos);
}
}
4-6) View(受け口だけ用意するイメージ)🎨✨
Viewは「表示」と「イベントを外へ渡す」だけにするよ💡 (この章では最低限の形だけね!)
// src/view/todoView.ts
import { TodoItem } from "../model/todoItem";
export class TodoView {
constructor(
private readonly input: HTMLInputElement,
private readonly addButton: HTMLButtonElement,
private readonly list: HTMLElement,
private readonly errorArea: HTMLElement
) {}
bindAdd(handler: (title: string) => void): void {
this.addButton.addEventListener("click", () => handler(this.input.value));
}
bindToggle(handler: (id: string) => void): void {
this.list.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
const btn = target.closest<HTMLButtonElement>("[data-action='toggle']");
if (!btn) return;
const id = btn.dataset.id;
if (!id) return;
handler(id);
});
}
render(todos: TodoItem[]): void {
this.list.innerHTML = todos
.map(
(t) => `
<li>
<span>${t.done ? "✅" : "⬜"} ${escapeHtml(t.title)}</span>
<button data-action="toggle" data-id="${t.id}">切替🔁</button>
</li>
`
)
.join("");
}
showErrors(errors: string[]): void {
this.errorArea.innerHTML = errors.map(e => `<div>⚠️ ${escapeHtml(e)}</div>`).join("");
}
clearErrors(): void {
this.errorArea.innerHTML = "";
}
clearInput(): void {
this.input.value = "";
}
}
const escapeHtml = (s: string): string =>
s.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
5) ここが嬉しいポイント(Service層のご褒美)🎁✨
- Controllerが痩せる=読みやすい🥰📖
- 「追加」みたいな処理をServiceだけ見れば理解できる👀
- 次章のLocalStorageで「保存する」が増えても 👉 Serviceに足すだけで済む💾✨
- テストするときもServiceが主役になる(第16章に繋がる!)🧪🌸
6) よくある失敗あるある😵💫(先回りで潰す!)
❌ 失敗1:ServiceがDOM触り始める
document.querySelectorとかやりだしたら黄色信号🚥 👉 それView/Controller側!
❌ 失敗2:Serviceが“神クラス”になっていく
TodoServiceに全部入れると、結局またデブ化🐷 👉 「ユースケース単位」で分けるのもアリ(例:AddTodoService)✨
❌ 失敗3:Modelがただのデータ袋になってルールが消える
- ルールをServiceに全部置くと散りやすい💦 👉 ルールはModel側へ寄せる(第9章の勝ち筋🛡️)
7) ミニ演習✍️✨(今日の手を動かすとこ)
演習A:toggleDoneもServiceへ移して、Controllerをさらに痩せさせよう🔁✅
- もうほぼ上の例でできてるけど、既存コードがあるなら移植してみてね!
演習B:Serviceに「編集 editTitle」を追加してみよう✏️✨
-
editTitle(id, newTitle)を作って- validate
- 更新
- Result返す をやってみてね!
8) AI活用コーナー🤖💡(使うと爆速になるやつ)
①「この処理どこに置く?」判定
プロンプト例:
- 「この処理はController/Service/Model/Viewのどこが適切?理由も1行ずつで。処理はこれ→(貼る)」🧠✨
② Fat Controllerを“抽出リファクタ”してもらう
- 「このControllerからServiceに移すべき処理を3つ挙げて、抽出後のクラス案を出して」✂️✨
③ Result型の設計を整える
- 「Result型を使って、エラーをユーザー向け文言と開発者向け情報に分ける設計案を出して」📦🔍
(※出てきたコードは“差分”で眺めて、納得したら取り込むのがコツだよ🫶✨)
9) まとめ🌸✨
- Controllerが太る原因は「ユースケースの手順」を抱え込むこと🍔
- Service層=ユースケースの中心にすると、Controllerが痩せる🥗
- Modelはルール、Viewは表示、Controllerは交通整理、Serviceは実行部隊🚦⚔️
- 次章の永続化(LocalStorage)でServiceがさらに効いてくるよ💾✨
必要なら、この章の形をそのまま使って「第13章:LocalStorage保存」をServiceに自然に組み込む形で、超スムーズに繋げる構成も作れるよ〜!😆📚✨