状態遷移制約の最適解

状態遷移制約の最適解

1. ユースケースで守る

enum OrderStatus {
	draft,
	progress,
	complete,
	cancel,
}

class Order {
	OrderStatus _status;
	OrderStatus get status => _status;

	Order();

	// あらゆる状態に変更できる
	void updateStatus(OrderStatus status) {
		_status = status;
	}
}

class CompleteOrderUseCase {

	Future<Response> call(Request request) async {
		final order = Order();

		// 呼び出し元で呼び出し可否がわかる
		if (order.status == OrderStatus.cancel) {
			throw StateError("Cancel状態でCompleteの呼び出しはサポートしていません");
		}

		order.updateStatus(OrderStatus.complete);

		await repository.save(order);

		return Response();
	}
}

2. ドメインメソッドで守る

enum OrderStatus {
	draft,
	progress,
	complete,
	cancel,
}

class Order {
	OrderStatus _status;
	OrderStatus get status => _status;

	Order();

	void markAsDraft() {
		if (_status == OrderStatus.complete) {
			throw StateError('markAsDraft は OrderStatus.complete の状態をサポートしていません');
		}
		if (_status == OrderStatus.cancel) {
			throw StateError('markAsDraft は OrderStatus.cancel の状態をサポートしていません');
		}
		_status = OrderStatus.draft;
	}

	void markAsProgress() {
		if (_status == OrderStatus.complete) {
			throw StateError('markAsProgress は OrderStatus.complete の状態をサポートしていません');
		}
		if (_status == OrderStatus.cancel) {
			throw StateError('markAsProgress は OrderStatus.cancel の状態をサポートしていません');
		}
		_status = OrderStatus.draft;
	}

	void markAsComplete() {
		if (_status == OrderStatus.draft) {
			throw StateError('markAsComplete は OrderStatus.draft の状態をサポートしていません');
		}
		if (_status == OrderStatus.cancel) {
			throw StateError('markAsComplete は OrderStatus.cancel の状態をサポートしていません');
		}
		_status = OrderStatus.draft;
	}

	void markAsCancel() {
		if (_status == OrderStatus.complete) {
			throw StateError('markAsCancel は OrderStatus.complete の状態をサポートしていません');
		}
		if (_status == OrderStatus.cancel) {
			throw StateError('markAsCancel は OrderStatus.cancel の状態をサポートしていません');
		}
		_status = OrderStatus.draft;
	}
}

class CompleteOrderUseCase {

	Future<Response> call(Request request) async {
		final order = Order();
		// 呼び出し先で呼び出し可否がわかる
		order.markAsComplete();

		await repository.save(order);

		return Response();
	}
}

3. データ型で守る

sealed class Order {}

class DraftOrder extends Order {
	CancelOrder cancel() {
		return CancelOrder();
	}
}

class ProgressOrder extends Order {
	CancelOrder cancel() {
		return CancelOrder();
	}
}

class CompleteOrder extends Order {}

class CancelOrder extends Order {}

class CancelOrderUseCase {

	Future<Response> call(Request request) async {
		final order = ProgressOrder();

		// 呼び出し元で呼び出し可否がわかる
		// インターフェースによって呼び出し可能性がわかる
		const canceledOrder = switch (order) {
			DraftOrder() => order.cancel(),
			ProgressOrder() => order.cancel(),
			CompleteOrder() => throw StateError('CompleteOrder は cancel をサポートしていません'),
			CancelOrder() => throw StateError('CancelOrder は cancel をサポートしていません'),
		};

		await repository.save(canceledOrder);

		return Response();
	}
}

なぜ型で守る選択をするのか

これまで3つの表現全てを実際に開発現場で経験してきた。

この中で、自由に選択できるなら自分は「型で守る」を選択する。

「型で守る」を選択する理由

  1. 考えることが減る: 制約が型により自明化されることで、過剰に思考を割く必要がなくなり、他の重要な課題に集中できる。
  2. 早期フィードバック: コンパイルや静的解析の段階でエラーを検出できるため、レビュー後や運用後ではなく、開発時点で問題を解決できる。
  3. 安全性の向上: 実行時エラーを防ぎ、ユーザー体験を向上させる。

型による制約が働いたほうが、過剰に思考を割いてケアする問題が減り、より重要な問題に思考を割けられる様になる。

「制約が足枷になって問題を難しくする」という反論も見受けられるが、制約が全くないよりも制約がコードに落とし込まれて、静的解析時点でできること or できないことが明示されているほうが良い。

よく「コンパイル時点、もしくは静的解析時点でわかるほうが良い」というコメントを見ることがある。

これは "実行時エラーになってサービス利用者にマイナスな体験を届けなくて済むことをコンパイルというプロセスで弾ける" のと、"開発者に対して正しい実装に至るためのフィードバックの距離が近いほうが良い" というニュアンスだと捉えて理解している。

開発で得られるフィードバックは近ければ近いほうが良い。

ビルド後よりもビルド前。レビュー後よりもレビュー前。実装後よりも実装前。

開発はどんなステップでも早い時点で違反に気付けたほうが効率が良い。

実装の意思決定にはなるべく早い時点でフィードバックが効かせられる様に、構造を活用すると良いのかもしれない。構造 = 型の利活用という潮流にも頷ける。

SHARES