リスコフの置換原則(LSP)を学びなおしてみた
はじめに
SOLID原則のリスコフの置換原則(LSP)について理解を深めたかったので、実際にコードに落とし込んだときの発見を記事にしました。
Before: 「子クラスは親クラスの代わりになれないといけない」...程度のふわっとした理解。
After: 「型システムとコンポジションを使って、契約(Contract)を強制する」という実践的な理解。
このBefore/Afterの過程を、SaaSの権限管理を題材に共有します。
LSPとは
リスコフの置換原則(Liskov Substitution Principle)を一言で言うと、「親クラス(基本型)ができることは、子クラス(派生型)でも必ずできなければならない」という原則です
「利用側(クライアント)が、渡されたオブジェクトの中身を知らなくても、親クラスだと思って安心して使えるか?」
事前条件と事後条件
LSPを満たすためのルールとして以下のものがあります[1]。
| 条件 | 説明 | ルール | 今回のケース |
|---|---|---|---|
| 事前条件 (Precondition) | メソッド実行前に満たすべき条件 | 子クラスで強めてはいけない | 引数の型チェックなど。Memberだからといって特別な引数を要求してはいけない。 |
| 事後条件 (Postcondition) | メソッド実行後に保証される状態 | 子クラスで弱めてはいけない | 親が「削除成功」を約束しているのに、子が「例外エラー」を返してはいけない。 |
今回は、これらの条件を「コンポジション(委譲)」を使うことで、満たします。
【Bad Pattern】LSP違反例
シナリオとして以下を想定します。
【管理者(Admin)】と【一般メンバー(Member)】が存在しており、ユーザ削除機能を作る。
ここで安易な継承を使うとこうなります。
class User {
deleteUser(targetId: string) {
// デフォルトの実装...
}
}
class Member extends User {
// 【LSP違反】
// 親は「削除できる」と言っているのに、子は「できない」と言い張る
override deleteUser(targetId: string) {
throw new Error("権限がありません");
}
}
問題点 開発者としては User インターフェースを利用して、ユーザ削除を一括処理(バッチ処理など)したい場合があります。
function deleteBadUsers(users: User[]) {
users.forEach(u => u.deleteUser('target_id')); // ここでMemberが混じるとクラッシュする!
}
このコードはコンパイルを通りますが、実行時に落ちます。これは User 型としての責務(ポリモーフィズム)を果たせていません。「Memberの時は呼んではいけない」という暗黙のルールを開発者が覚え続ける必要があり、バグの温床になります。
【Solution】改善例
この問題を解決するために、2つの武器を使います。
- Discriminated Unions (判別可能な共用体): 型レベルで構造を分ける。
- Policy Pattern (Strategy): 複雑なルールを値オブジェクトに逃がす。
Step1:契約(Policy)の定義
まず、「何が出来るか」という振る舞いの契約をInterfaceとして定義します。ここではUser本体ではなく、Policyという別の値オブジェクトに責務を持たせます。
// domain/policy.ts
// 契約: すべての権限はこの問いに答えられなければならない
export interface PermissionPolicy {
canDeleteUser(targetUserId: string, currentUserId: string): boolean;
}
// Adminの振る舞い: 無条件で削除可能
export class AdminPolicy implements PermissionPolicy {
canDeleteUser(_target: string, _current: string): boolean {
return true;
}
}
// Memberの振る舞い: 自分自身なら削除可能
export class MemberPolicy implements PermissionPolicy {
canDeleteUser(targetUserId: string, currentUserId: string): boolean {
return targetUserId === currentUserId;
}
}
Step2:エンティティ(User)への適用
Userエンティティは、継承するのではなく、このPolicyを所有します(UML的にはComposition)。
UML図 継承(IS-A)から委譲(HAS-A)への転換
// domain/user.ts
import { PermissionPolicy } from "./policy";
// 判別用のタグ。ユニオン型を利用。
export type UserRole = "admin" | "member";
export class User {
constructor(
public readonly id: string,
public readonly name: string,
public readonly role: UserRole,
// 【Point】Policyは値オブジェクトとして保持する。
// User自身が判断ロジックを持つと肥大化するため、委譲する。
private _policy: PermissionPolicy
) {}
// 振る舞いの委譲 (Delegation)
deleteUser(targetUserId: string): void {
// 事前条件のチェックをPolicyに任せる
if (this._policy.canDeleteUser(targetUserId, this.id)) {
console.log(`[Success] User ${this.name} deleted user ${targetUserId}.`);
} else {
console.log(`[Deny] User ${this.name} is NOT allowed.`);
}
}
}
これで利用側はどう変わる?
Bad Patternでは、クライアント側(Controllerなど)で型チェックをする必要がありました。
Before (Bad):
// 呼び出し側が、相手の型を気にしている(カプセル化の破壊)
if (user instanceof Member) {
// エラーになるから呼ばないでおこう...
} else {
user.deleteUser(targetId);
}
After (Good):
// ポリモーフィズムとして利用できる
user.deleteUser(targetId);
クライアント側は Userの内部事情(AdminかMemberか)を知る必要がなく、ただメソッドを呼ぶだけで、LSPに従った安全な挙動が保証されます。
補足:循環参照の回避と値オブジェクト
ここで_policyを「値オブジェクト(Value Object)」として扱っている点に注目してください。
もし、Policyの中にUserそのものを渡してしまったらどうなるでしょうか?
// NG: 循環参照が発生する
interface PermissionPolicy {
canDeleteUser(user: User, targetId: string): boolean;
}
これだと、User は Policy を知り、Policy は User を知っている状態(循環参照)になり、切り出しが不完全になります。
今回はcanDeleteUser(targetId, currentId)のように、プリミティブなID(値)だけを渡す設計にしました。これにより、Policy は User クラスの存在を知る必要がなくなり、完全に独立した(テストしやすい)部品となります。
循環参照のデメリットについて
循環参照があると、以下のような具体的な弊害が発生する可能性があります。
- 変更の影響範囲が予測不能になる-密結合 Userを変更したらPolicyが壊れ、Policyを直したらUserが壊れるということが発生するかも。どちらか一方を単体で修正・改善することが難しくなります
-
テストが難しくなる Policyの単体テストを書きたいだけなのに、Userオブジェクトを生成(モック)しなければなりません。しかしUserを作るにはPolicyが必要で……という無限ループに陥ります。 「IDだけを渡す」設計なら、
canDeleteUser("target_1", "user_1")のように、文字列だけで簡単にテストが記述できます。 - 再利用性の低下 PolicyがUserに依存していると、例えば「Group」や「Organization」など、User以外のエンティティにも同じ権限ロジックを適用したくなったときに使いまわしが困難になります。プリミティブな値(数値や文字列)に制限することで、特定のエンティティに縛られない設計が可能になります。
永続化と再構築
さて、ここからは永続化(DBに保存)した後に、データを取得してオブジェクトに変換する場合の話になります。
DB: role:"admin"という文字列が保存されている
アプリ側(インフラ層): AdminPolicyという振る舞いが必要
この「データ」から「振る舞い」への変換をRepository(インフラ層)で行います。DDDで言うところの「再構築(Reconstitution)」です。
// infra/repository.ts
import { User, UserRole } from "../domain/user";
import { AdminPolicy, MemberPolicy, PermissionPolicy } from "../domain/policy";
// DBの型定義 (Anemic Model)
type UserRecord = {
id: string;
name: string;
role: string;
};
export class UserRepository {
// 解説用にインメモリDBを使用
private db: Map<string, UserRecord> = new Map();
async findById(id: string): Promise<User | null> {
const record = this.db.get(id);
if (!record) return null;
// ★ここで「ただのデータ」を「ドメインオブジェクト」に変換する
return this.toDomain(record);
}
/**
* マッピングロジック
* (Factory的な責務だが、DBの型を知っている必要があるのでインフラ層に配置)
* (一般にFactoryはドメイン層の責務)
*/
private toDomain(record: UserRecord): User {
const role = record.role as UserRole;
let policy: PermissionPolicy;
// 識別子(role)から振る舞い(Policy)を選択・結合する
//
// 【トレードオフ】
// ここで switch 文を使うことは OCP(開放閉鎖の原則) に反しています。
// しかし、Roleが増えるたびにクラスファイルを修正するコストと、
// ここで一元管理するわかりやすさを天秤にかけ、
// 「凝集度」を優先してあえて Switch を採用してます。
switch (role) {
case "admin":
policy = new AdminPolicy();
break;
case "member":
policy = new MemberPolicy();
break;
default:
// DBに不正な値がある場合はここで防ぐ
throw new Error(`Unknown role: ${role}`);
}
return new User(record.id, record.name, role, policy);
}
}
この設計により、以下のメリットが生まれます。
- ドメインの純粋性:
UserやPolicyはDBの構造を知りません。 - 安全な結合: 文字列からオブジェクトへの変換は、Repositoryという「境界」でのみ行われます。
まとめ
LSP(リスコフの置換原則)はクライアントがそのオブジェクトの中身を知らなくても、契約通りに扱えることを保証するための原則です。
本記事では以下の手法を紹介しました。
-
安易な継承の禁止:
extendsによる安易な実装継承を避け、LSP違反の温床を断つ。 -
Policyパターン: 振る舞いの違いを
Interface(PermissionPolicy)に切り出し、User に委譲する。 - Repositoryでの再構築: DB上のデータをドメインオブジェクトへと安全に変換する。
-
ロバート・C・マーチン著『アジャイルソフトウェア開発の奥義』 P.151 ↩︎
Discussion