第15章 shared/utils沼を回避②:契約(Contract)中心の共有にする📜🎯
この章のゴール: 「みんなが使うから shared に入れよ〜🥺」を卒業して、“共有するなら契約だけ” に寄せられるようになること💪🌸 すると、依存が暴れなくなって、変更がラクになって、テストもしやすくなるよ〜!🎉
1) まず “shared/utils沼” の正体を再確認🕳️😵💫
shared や utils が沼ると、だいたいこんな症状が出るよ👇
- どこからでも import できる(=依存が四方八方に飛ぶ)🌀
- 便利関数の名を借りた業務ルールが混ざる(=変更が怖い)😱
- 誰がオーナーか不明(=直す人がいない)🤷♀️
- 「ついでにここも…」が増えて 巨大化 🐘
そして最悪パターン👇 domain/app/adapters が “shared” でつながって循環 し始めます🌀💥
2) “契約(Contract)” ってなに?🤝✨
ここでいう 契約 は、
- 境界をまたぐときの “約束ごと”(=共通言語)
- 「欲しい能力」や「やりとりの形」だけを定義するもの
って感じ☺️💡
つまり…
✅ 契約は「型・インターフェース・メッセージの形」 ❌ 契約は「便利な処理の寄せ集め」ではない
3) contracts/ に置いていいもの・ダメなもの🧺✅❌
✅ contracts/ に置いていい(置く価値ある)もの🎁
境界を守るための “言葉” だけ✨
-
Ports(外側に頼む能力)
- 例:
UserRepository,Clock,PaymentGateway
- 例:
-
DTO(境界を越えるデータの形)
- 例:
UserDto,TodoDto,CreateTodoRequest
- 例:
-
Events(イベントの形)
- 例:
DomainEvent,TodoCreated
- 例:
-
Errors(境界で共有するエラー種別)
- 例:
AppError,ErrorCode
- 例:
-
識別子・薄い型
- 例:
type UserId = string & { __brand: "UserId" }(ブランディング)✨
- 例:
-
設定の形(Configの型)
- 例:
DbConfig,FeatureFlags
- 例:
❌ contracts/ に置いちゃダメ(沼る)もの🧨
“処理” を置くと一気に shared 化するよ…!
- 業務ロジック(割引計算、権限判定、料金ルールなど)💸
- 具体ライブラリ依存(DB/HTTP/SDK/フレームワーク)📦
- 便利関数の詰め合わせ(format, parse, convert 乱立)🧂
- domain の中身そのものを外に渡す(Entity丸出し)🥲
4) どこに contracts を置く?おすすめ2パターン📁✨

パターンA:単一リポジトリ(最初はこれでOK)🐣
src/
domain/
app/
contracts/ ← ここが「共有の中心」📌
adapters/
依存のイメージはこう👇 domain/app → contracts ← adapters (contracts は “参照される側” になりやすい)
パターンB:monorepo / packages(大きくなったら)📦✨
packages/
contracts/
domain/
app/
adapters/
「契約だけのパッケージ」があると、物理的にも依存が切れやすいよ✂️😊 (こういうTSのDDD/ヘキサゴナル構成の実例リポジトリもあるよ)(GitHub)
5) TypeScriptで “契約だけ共有” を成立させるコツ🧠✨
コツ①:契約は 型中心(実行コードを増やさない)🧊
契約に実行コードが増えると、
- 「つい便利関数も置くか〜」が始まる
- import した瞬間に副作用が混ざる
ってなりがち😭
最近の TypeScript では、tsc --init がより実用的な初期設定になってて、例えば verbatimModuleSyntax や noUncheckedSideEffectImports みたいに「importの扱いを安全にする」方向の設定も推されてるよ🛡️✨(TypeScript)
コツ②:import type / export type をクセにする🧡
contracts は「型だけ」で成立することが多いから、
- 実行時依存を増やさない
- バンドルや循環の事故を減らす
のに効くよ😊
(verbatimModuleSyntax もこの流れに相性いいやつ)(TypeScript)
コツ③:契約の破壊変更を避ける(地味に超重要)🧨
契約は “みんなが触る” から、破壊変更が地獄になりやすい…😱
おすすめルール👇
- 追加はOK(フィールド追加・Union追加など)✨
- 削除・名前変更は慎重に(互換性が壊れる)💥
- 必要なら version を持たせる(イベントやDTO)🔢
6) ミニ例:ToDoで「contracts中心」を体験しよう🧪💖
フォルダ構成(イメージ)📁
src/
contracts/
todo.ts
ports.ts
errors.ts
app/
createTodo.ts
domain/
todo.ts
adapters/
todoRepo.memory.ts
todoRepo.db.ts
main.ts
6-1) contracts:境界の言葉だけ置く📜✨
// src/contracts/todo.ts
export type TodoId = string & { readonly __brand: "TodoId" };
export type TodoDto = {
id: TodoId;
title: string;
done: boolean;
};
// src/contracts/ports.ts
import type { TodoDto, TodoId } from "./todo";
export interface TodoRepository {
findById(id: TodoId): Promise<TodoDto | null>;
save(todo: TodoDto): Promise<void>;
}
// src/contracts/errors.ts
export type ErrorCode =
| "TODO_NOT_FOUND"
| "TITLE_EMPTY";
export type AppError = {
code: ErrorCode;
message: string;
};
ここには 計算・変換・DB処理は置かない よ!🙅♀️
6-2) app:契約(Port)にだけ依存してユースケースを書く🧅✨
// src/app/createTodo.ts
import type { TodoRepository } from "../contracts/ports";
import type { TodoDto, TodoId } from "../contracts/todo";
import type { AppError } from "../contracts/errors";
export type CreateTodoInput = { id: TodoId; title: string };
export type Result<T> =
| { ok: true; value: T }
| { ok: false; error: AppError };
export async function createTodo(
repo: TodoRepository,
input: CreateTodoInput
): Promise<Result<TodoDto>> {
if (input.title.trim() === "") {
return { ok: false, error: { code: "TITLE_EMPTY", message: "タイトルが空だよ〜🥺" } };
}
const todo: TodoDto = { id: input.id, title: input.title, done: false };
await repo.save(todo);
return { ok: true, value: todo };
}
ポイント👇 ✅ app は 契約しか知らない(DBもHTTPも知らない)😌✨
6-3) adapters:外側で実装して差し込む🧰💕
// src/adapters/todoRepo.memory.ts
import type { TodoRepository } from "../contracts/ports";
import type { TodoDto, TodoId } from "../contracts/todo";
export function createMemoryTodoRepo(): TodoRepository {
const store = new Map<TodoId, TodoDto>();
return {
async findById(id) {
return store.get(id) ?? null;
},
async save(todo) {
store.set(todo.id, todo);
},
};
}
7) ここが大事!「共有=契約」の判断フロー🧭✨
迷ったらこの順で考えるとラクだよ😊
- それは 境界をまたぐ “言葉”?(型・I/F・DTO・イベント) → YES ✅ contracts
- それは 業務ルール? → YES ✅ domain(または app)
- それは 外部I/Oの都合?(DB/HTTP/SDK) → YES ✅ adapters
- それは ただの便利処理? → まず “各層のローカル” に置く(sharedにしない)🧹
8) ミニ演習:contracts に入れる?入れない?📝🎯
次の候補を「contracts / domain / adapters / ローカル便利」に仕分けしてみてね😊✨
PaymentGatewayinterfaceformatYen(amount: number): stringUserDto(APIで返す形)DiscountCalculator(割引ルール)DbUserRow(DBのテーブル形)TodoCreated(イベントの型)
答えの目安👇 1→contracts✅ / 2→ローカル便利(基本sharedにしない)🧹 / 3→contracts✅ / 4→domain✅ / 5→adapters✅ / 6→contracts✅
9) AI活用コーナー🤖🪄(めちゃ便利!)
コピペで使えるやつ置いとくね💕
- 「この
shared/utilsの関数一覧を、contracts / domain / app / adapters に分類して、理由もつけて」 - 「このユースケースが必要とする Port interface を提案して(メソッド名・戻り値も)」
- 「
TodoDtoの破壊変更にならない進化案(追加・非推奨)を3パターン出して」 - 「contracts を型だけに保つための import type 変換案 を出して」
- 「
contractsが肥大化しそうな兆候チェックリストを作って」✅
10) よくある事故とリカバリ集🚑💡
事故①:contracts に “便利関数” が入り始めた🍲
対処:
- 便利関数は 各層のローカル に戻す
- 本当に共通なら「pureで副作用ゼロ」だけ厳選✨
事故②:DTOがdomainを丸コピし始めた🫠
対処:
- DTOは「外に見せる最小限」だけ
- domainの都合で外が壊れないように、境界で翻訳 する(第10〜11章の考え方)🔁
事故③:lintがうるさくて諦めそう🥺
最近は ESLint 側でも設定を型安全にする defineConfig() が入ってきてたりするので、将来 “境界ルールを仕組みで守る” ときに楽になるよ🛡️✨(ESLint)
(ここは次章の自動化につながるやつ!)
11) 最後に:この章のチェックリスト✅💖
PR前にこれ見てね😊
- contracts に 業務ロジック 入ってない?🧨
- contracts が DB/HTTP/SDK に依存してない?📦
- app/domain は 契約(Port/DTO)だけ見てる?🧅
-
import typeで 実行時依存を増やしてない?🧊 - 契約の変更が 破壊変更 になってない?(削除/名前変更)💥
必要なら、この章の内容をベースにして、あなたの想定してる題材(ToDo以外でもOK!家計簿とか予約とか📒✨)に合わせた 「contracts設計のテンプレ」 も作るよ〜😊💖