Freezedで使用するJsonConverterを詳しく書く

Freezedの中で自作のクラスを使用するときに、JsonConverterを作成します。
Freezedを使用してDDD(Domain Drive Development, ドメイン駆動型開発)っぽくValueObjectクラスを作成しているので、たまにこんなことできるかなぁ、と色々試しています。
その中での知見を共有します。

Freezedについては、以下をご参照ください。

【Flutter/Dart】状態管理に必要なfreezedパッケージを徹底解説 クラスの作成・生成方法からチップスまで【2022年2月版】

値の必須・Null許容に寄る違い

値が必須・NULL許容で、JsonConverterの書き方が変わります。値が必須・NULL許容でも、JsonConverterが同じ(以前の必須と同じ)で大丈夫になりました。その実験をします。

以下の様なクラスをFreezedで作成します。ここでは、ユーザ名・Eメールアドレス1は必須、Eメールアドレス2は非必須とします。

@freezed
class User with _$User {
  const User._(); //メソッド不要の場合、削除
  const factory User({
    @UserNameConverter() required UserName userName,
    @EmailAddressConverter() required EmailAddress emailAddress1,
    @EmailAddressConverter() EmailAddress? emailAddress2,
  }) = _User;
  factory User.fromJson(Map json) => _$UserFromJson(json);
}

class UserConverter implements JsonConverter<User, String> {
  const UserConverter();
  @override
  User fromJson(String jsonData) {
    return User.fromJson(json.decode(jsonData));
  }
  @override
  String toJson(User object) {
    return json.encode(object.toJson());
  }
}

ValueObjectを使ってなく、UserNameとかEmailAddressとかではなく、ただのStringであれば、上記だけで行けます。が、ここではDDDを使っているという前提のため、それぞれのクラスに対応したJsonConverterを作成していきます。

ValueObjectの基底クラス

DDDのValueObject(値オブジェクト)といって、Stringやintをそのまま使うのではなく、その値にふさわしいクラスを作成します。例えばユーザ名ですと、Stringで定義するのではなく、UserNameというクラスを作成して定義します。そして、UserNameに関する業務知識(名前は6文字以上、など)をこのクラスの中に書くことで、UserNameに関する知識がソースの色々なところに散らばらないようにします。


class ValueObject<T> {
  ValueObject(this.value);
  final T value;
  T call() => value;
}

ValueObjectのクラスとそのJsonConverter

ユーザ名のクラスとJsonConverter

class UserName extends ValueObject<String> {
  UserName(super.value);
}
class UserNameConverter implements JsonConverter<UserName, String> {
  const UserNameConverter();
  @override
  UserName fromJson(String value) {
    return UserName(value);
  }
  @override
  String toJson(UserName value) {
    return value.value;
  }
}

EmailのクラスとJsonConverter

必須と非必須の両方ともに使用するが、同じJsonConverterで良くなった。
class EmailAddress extends ValueObject<String> {
  EmailAddress(super.value);
}
class EmailAddressConverter implements JsonConverter<EmailAddress, String> {
  const EmailAddressConverter();
  @override
  EmailAddress fromJson(String value) {
    return EmailAddress(value);
  }
  @override
  String toJson(EmailAddress value) {
    return value.value;
  }
}

コンバートされたJsonの中身の確認

非必須のデータがnullだった場合、jsonにnullと設定されることを確認しました。別システムとの連携に使用したとき、項目がないケースと、項目があるがnullの値が設定されているケースで挙動が異なる場合があるか、確認しないとな、と思いました。
    User user1 = User(
        userName: UserName('userName'),
        emailAddress1: EmailAddress('emailAddress1'));
    expect(UserConverter().toJson(user1),
        '{"userName":"userName","emailAddress1":"emailAddress1","emailAddress2":null}');

失敗の余談/ DDDでジェネリックなJsonConverterの夢を見る

DDDとは、ドメイン駆動開発のことです。DDDについて知りたい方は、別のサイトを見てください。
そのなかで、ValueObjectというものがあります。このページで言うと、ユーザ名とEメールアドレスはどっちもStringだが、間違えて入れ替えるかも知れない。そこで、それぞれをクラスとして定義して、間違えないようにしよう、というのがValueObjectです。
ただ、変数毎にクラスが増えていきます。Freezedを使うと同じ数だけJsonConverterも作らなければなりません。そこで、「ジェネリック型でJsonConverterを作れないか」と考えました。
まずValueObjectという仮想クラスを作成して、すべてのValueObjectはそのクラスを継承します。そうすると、ジェネリックなJsonConverterが次のように書けます。

class ValueObjectConverter<S extends ValueObject>
    implements JsonConverter<S, String> {
  const ValueObjectConverter();
  @override
  S fromJson(String value) {
    return S(value);
  }
  @override
  String toJson(S value) {
    return value.value;
  }
}

しかし、ジェネリックでS(value)みたいな書き方でインスタンス生成はできません。
そこで、FreezedでJsonConveterを指定するときに、引数として変換するメソッドを入れようとしました。

class ValueObjectConverter<S extends ValueObject>
    implements JsonConverter<S, String> {
  const ValueObjectConverter(this.convert);
  final Function convert;
  @override
  S fromJson(String value) {
    return convert(value);
  }
  @override
  String toJson(S value) {
    return value.value;
  }
}

「俺ってば、天才!」と思いながら以下のようなソースをFreezedに差し込みます。

   @ValueObjectConverter((value) => UserId(value)) required UserId userId,

そうすると、「error: Arguments of a constant creation must be constant expressions.」という無慈悲なコンパイルエラーが、、、アノテーションの引数に設定できるのは、const表現のものらしいです。そしてFunctionはconstにできない、、、
Dartの制限の前に目的は達成できませんでしたが、ジェネリック型のインスタンス生成方法としては勉強になりました。そのため、書かせて頂きます。なんかいい方法を思いついた方は、ご連絡ください

NULL許容型のJsonConverter(昔の書き方、現在は使用しなくて良い)

この場合、NULL許容のEメールアドレス用のJsonConverterは以下のようになります。
EmailAddressの型全てに「?」を付けて、NULL許容を強調します。間違えると、ソースを作成しても、コンパイルエラーになります。


class EmailAddressConverter implements JsonConverter {
  const EmailAddressConverter();
  @override
  EmailAddress? fromJson(String value) {
    return value.isEmpty ? null : EmailAddress(value);
  }
  @override
  String toJson(EmailAddress? value) {
    return value == null ? '' : value();
  }
}