Flutterで実践する型安全な設計：Todoタイトルを値オブジェクトで管理する方法

# はじめに
Flutterでの型安全なバリデーションを設計したので、共有します。
バリデーションを漏れなくシンプルに実装できます。

# 目次
1. 参考コード
1. 前回の記事
1. 型安全な設計とは
1. TodoTitleの実装
1. TodoTitleの使用方法
1. Controller層とUsecases層の役割


# 参考コード

https://github.com/life-with-meat/flutter_todo_clean_arch_riverpod/tree/releases/base_validation

# 前回の記事

https://qiita.com/life_with_meat/items/c036426809b56742a36c


# 型安全な設計とは
title のようなユーザー入力には、以下のようなバリデーションルールが必要です。

- 入力必須
- 文字数制限（1文字以上、50文字以下）
- 日本語などの形式制限（今回は省略）

これらを毎回 String に対して実装していると

- 重複コードが増える
- 実装漏れでバグが発生する

といった問題が起こりがちです。



型を定義すると以下のメリットがあります。
- 型の時点でルール違反を防げる
- ルールをまとめられる
- ルールを再利用できる

#### Stringのままの場合
```dart:Before

@freezed
abstract class TodoModel with _$TodoModel {
  factory TodoModel({
    required String id,
    required String title,
    required bool isDone,
  }) = _TodoModel;

  factory TodoModel.fromJson(Map<String, dynamic> json) =>
      _$TodoModelFromJson(json);
}
```

#### 専用の型を作成した場合
```dart:After

@freezed
abstract class TodoModel with _$TodoModel {
  factory TodoModel({
    required String id,
    required TodoTitle title,
    required bool isDone,
  }) = _TodoModel;

  factory TodoModel.fromJson(Map<String, dynamic> json) =>
      _$TodoModelFromJson(json);
}
```



# TodoTitleを作成
TodoTitleは、バリデーションを内包した値オブジェクトです。
正しいタイトルでなければ、インスタンスを作成できない仕組みになっています。


<details>
<summary>コードを見る</summary>

```dart:/domain/todo_title.dart

/// Todoのタイトルを表す値オブジェクト
///
/// タイトルは1文字以上50文字以下である必要があります。
/// 空文字列は許可されません。
@JsonSerializable()
class TodoTitle {
  /// タイトルの最大文字数
  static const int maxLength = 50;

  /// タイトルの最小文字数
  static const int minLength = 1;

  /// タイトルの値
  final String value;

  /// バリデーションルール
  static final TextFieldValidator validator = TextFieldValidator(
    maxLength: maxLength,
    minLength: minLength,
  );

  const TodoTitle._(this.value);

  /// 新しいTodoTitleを作成します。
  ///
  /// [input]が空の場合は[ArgumentError]をスローします。
  /// [input]が[maxLength]を超える場合は[ArgumentError]をスローします。
  /// [input]が[minLength]未満の場合は[ArgumentError]をスローします。
  ///
  /// 例：
  /// ```dart
  /// final title = TodoTitle("新しいタスク"); // OK
  /// final emptyTitle = TodoTitle(""); // ArgumentError: 入力してください
  /// final tooLongTitle = TodoTitle("a" * 51); // ArgumentError: 50文字以内で入力してください
  /// ```
  factory TodoTitle(String input) {
    final error = validator.validate(input);
    if (error != null) throw ArgumentError(error);
    return TodoTitle._(input.trim());
  }

  /// JSONからTodoTitleを作成します。
  ///
  /// [json]は'value'キーを持つMapである必要があります。
  /// 値は文字列である必要があります。
  factory TodoTitle.fromJson(Map<String, dynamic> json) =>
      TodoTitle(json['value'] as String);

  /// TodoTitleをJSONに変換します。
  ///
  /// 戻り値は'value'キーを持つMapです。
  Map<String, dynamic> toJson() => {'value': value};

  @override
  String toString() => value;

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is TodoTitle && other.value == value;
  }

  @override
  int get hashCode => value.hashCode;
}

```
</details>

# TodoTitleを使用
以下は、TodoUsecases内でTodoTitleを使って安全にタイトルを扱っている例です。


<details>
<summary>コードを見る</summary>

```dart:/application/todo_usecases.dart

class TodoUsecases {
  final TodoRepository repository;

  TodoUsecases(this.repository);

  Future<List<TodoModel>> fetch() => repository.fetchTodos();

  Future<void> add(String rawTitle) async {
    try {
      final title = TodoTitle(rawTitle);

      final todo = TodoModel(
        id: DateTime.now().toIso8601String(),
        title: title,
        isDone: false,
      );
      await repository.createTodo(todo);
    } catch (e) {
      throw Exception('保存できません: ${e is ArgumentError ? e.message : e}');
    }
  }
}

```
</details>




# Controller層とUsecases層がある場合
バリデーションはUsecases層で行うようにします。
Controller層は状態管理のみにし、ビジネスロジックを持たないように心がけます。

### Controller
- ユーザー操作を受けてstateを操作
- ユースケースの呼び出し、結果の受け取りと再描画

### Usecases

- データのバリデーション
- ドメインルールに基づく変換（copyWith(...)など）
- Repositoryとのやりとり