#はじめに
値オブジェクトの悩みポイントとどう付き合っていくかを見させていただいてのですが、
悩みがちな項目に
- Nullableな値を取り扱う場合
がありました。
NullObjectパターンじゃないの?と自分は思っていたのですが、ネットで言及している情報があまりなかったのでここが良いよっていう点を投稿してみます。
#業務知識
以下のようなドメインで考えます。
[](
@startuml
abstract 値オブジェクト {
}
class 名前 {
#_名前:string
}
名前 --|> 値オブジェクト
class Null名前 {
}
Null名前 -left-|> 名前
enum 性別 {
男
女
}
class 性別種別 {
#_性別:性別
}
性別種別 .left.> 性別
性別種別 --|> 値オブジェクト
class 性別種別選択外 {
}
性別種別選択外 -left-|> 性別種別
class 生年月日 {
#_生年月日:DateTime
}
生年月日 --|> 値オブジェクト
class Null生年月日 {
}
Null生年月日 -left-|> 生年月日
class ユーザーID {
-_id:GUID
}
ユーザーID --|> 値オブジェクト
class ユーザー <<集約兼エンティティ>> {
-_id:ユーザーID
-_性別種別:性別種別
-_名前:名前
-_生年月日:生年月日
}
ユーザー *-down- ユーザーID
ユーザー *-down- 生年月日
ユーザー *-down- 名前
ユーザー *-down- 性別種別
@enduml
)
- Nullを許容しない値オブジェクト(ユーザーID)
- nullと空文字は別扱いしたい値オブジェクト(名前)
- DateTimeとかNullにできない値オブジェクト(生年月日)
- 選択肢系の値オブジェクト(性別種別)
を用意してみました。
#前提
値オブジェクトの抽象クラスには値オブジェクトを実装するを使用しています。値オブジェクトの特性を満たせていればなんでも良いです。
値オブジェクトの生成はstaticファクトリメソッド派です。
#Nullを許容しない値オブジェクト
例えばユーザーIDのようなNullを許容しない値オブジェクトは以下のようにガチガチに作れば良いと思ってます。
public class ユーザーID : ValueObject
{
private Guid _id { get; }
public string Guid文字列 => _id.ToString();
public static ユーザーID CreateNew() => new ユーザーID(Guid.NewGuid());
public static ユーザーID Create(object o)
=> o switch {
null => throw new ArgumentException("Guidに変換出来ません。Nullです。", "_ユーザーID"),
string s => new ユーザーID(文字列をGuidに変換(s)),
Guid g => new ユーザーID(g),
_ => throw new ArgumentException("Guidに変換出来ません。" + o.ToString(), "_ユーザーID"),
};
private static Guid 文字列をGuidに変換(string s)
{
Guid g;
var result = Guid.TryParse(s, out g);
return (result) ? g : throw new ArgumentException("Guidに変換出来ません。" + s, "_ユーザーID");
}
private ユーザーID(Guid _id) => this._id = _id;
protected override IEnumerable<object> GetAtomicValues()
{
yield return _id;
}
}
#nullと空文字は別扱いしたい値オブジェクト
例えば名前のように(今回想定しているシステムでは)必須ではない項目に関しては別途Null名前のように名前を継承したNullObjectを用意すれば使う側は動作の違いを意識する必要がなくなるはず。
public class 名前 : ValueObject
{
protected string _名前 { get; }
public virtual string 名前文字列 => _名前;
public virtual string 名前様付 => _名前 + "様";
public static 名前 Create(object _名前)
=> _名前 switch {
string s when string.IsNullOrEmpty(s) => new Null名前(),
string s when s.Length > 60 => throw new ArgumentException("名前は60文字以下", nameof(_名前)),
string s => new 名前(s),
_ => throw new ArgumentException("文字列以外です。" + _名前.ToString(), "_名前")
};
private 名前(string _名前) => this._名前 = _名前;
protected 名前() => this._名前 = null;
protected override IEnumerable<object> GetAtomicValues()
{
yield return _名前;
}
}
public class Null名前 : 名前
{
public override string 名前文字列 => "";
public override string 名前様付 => "";
}
#DateTimeとかNullにできない値オブジェクト
例えば生年月日のように(今回想定しているシステムでは)必須ではない項目に関しては別途Null生年月日のように生年月日を継承したNullObjectを用意すれば使う側は動作の違いを意識する必要がなくなるはず。
今回はDateTime.MinValueを有り得ない値として表現しました。DateTimeをNull許容型にして操作するのも有りかと。
public class 生年月日 : ValueObject
{
protected DateTime _生年月日 { get; }
public virtual string 生年月日スラッシュ => _生年月日.ToString("yyyy/MM/dd");
public virtual string 生年月日ハイフン => _生年月日.ToString("yyyy-MM-dd");
public static 生年月日 Create(object o)
=> DateTimeに変換(o) switch {
null => new Null生年月日(),
DateTime d when d.Equals(DateTime.MinValue) => new Null生年月日(),
DateTime d when d > DateTime.Now => throw new ArgumentException("今日より未来の生年月日は不可", "_生年月日"),
var d => new 生年月日(d.Value),
};
private static DateTime? DateTimeに変換(object o)
=> o switch {
string s when string.IsNullOrEmpty(s) => null,
string s => 文字列をDateTimeに変換(s),
DateTime d => d,
_ => throw new ArgumentException("DateTimeに変換出来ません。" + o.ToString(), "_生年月日"),
};
private static DateTime? 文字列をDateTimeに変換(string s)
{
DateTime? d = null;
DateTime d2;
var result = DateTime.TryParse(s, out d2);
if (result) d = d2;
return (result) ? d : throw new ArgumentException("DateTimeに変換出来ません。" + s, "_生年月日");
}
private 生年月日(DateTime _生年月日) => this._生年月日 = _生年月日.Date;
protected 生年月日() => _生年月日 = DateTime.MinValue;
protected override IEnumerable<object> GetAtomicValues()
{
yield return _生年月日;
}
}
public class Null生年月日 : 生年月日
{
public override string 生年月日スラッシュ => "";
public override string 生年月日ハイフン => "";
}
#選択肢系の値オブジェクト
例えば性別種別のように(今回想定しているシステムでは)必須ではない項目で、画面上の都合で選択していないという状態を表現するのにもNullObjectで良いかと。
public enum 性別
{
男 = 1,
女 = 2
}
public class 性別種別 : ValueObject
{
protected 性別? _性別 { get; }
public virtual string 文字列 => Enum.GetName(typeof(性別), _性別.Value);
public virtual int 値 => (int)(_性別.Value);
public static 性別種別 Create(object o)
=> o switch {
null => new 性別種別選択外(),
int i when i == 0 => new 性別種別選択外(),
var v => new 性別種別(性別に変換(v))
};
public static 性別 性別に変換(object o)
=> o switch {
int i => (性別)Enum.ToObject(typeof(性別), i),
string s => (性別)Enum.Parse(typeof(性別), s),
_ => throw new ArgumentException("性別種別に変換できません。" + o.ToString(), "_性別")
};
public static 性別種別 Create男() => Create(1);
public static 性別種別 Create女() => Create(2);
public static 性別種別 Create選択外() => Create(null);
private 性別種別(性別 _性別) => this._性別 = _性別;
protected 性別種別() => _性別 = null;
protected override IEnumerable<object> GetAtomicValues()
{
yield return _性別;
}
}
public class 性別種別選択外 : 性別種別
{
public override string 文字列 => "選択外";
public override int 値 => 0;
}
public class 性別種別一覧
{
private static List<性別種別> _一覧 { get; } = new List<性別種別>{
性別種別.Create選択外(),
性別種別.Create男(),
性別種別.Create女(),
};
private static List<性別種別> _選択外無一覧 { get; } = new List<性別種別>{
性別種別.Create男(),
性別種別.Create女(),
};
public static List<性別種別> 一覧 => _一覧;
public static List<性別種別> 選択外無一覧 => _選択外無一覧;
public static List<(string, int)> 名称と値一覧 => _一覧.Select(x => (x.文字列, x.値)).ToList();
}
業務知識として選択なしというのを扱うのならば素直にenumに追加して上げても良いはず。
#例えばプリミティブな値から復元するとき
プリミティブな値からユーザーを復元する際の例を書いてみます。
public class ユーザー
{
public ユーザーID ID { get; }
public 名前 名前 { get; }
public 生年月日 生年月日 { get; }
public 性別種別 性別種別 { get; }
public ユーザー(ユーザーID _ID, 名前 _名前, 生年月日 _生年月日, 性別種別 _性別種別)
{
ID = _ID;
名前 = _名前;
生年月日 = _生年月日;
性別種別 = _性別種別;
}
}
public interface Iユーザーファクトリ {
ユーザー Create(object _ID, object _名前, object _生年月日, object _性別種別);
}
public class ユーザーファクトリ : Iユーザーファクトリ
{
public ユーザー Create(object _ID, object _名前, object _生年月日, object _性別種別)
=> new ユーザー(
ユーザーID.Create(_ID),
名前.Create(_名前),
生年月日.Create(_生年月日),
性別種別.Create(_性別種別)
);
}
こんな感じでnullか否かを意識することなくすっきり書けます。許容外の値が来た時のエラーハンドリングは必要ですけど。
#例えばDBに保存するとき
ユーザーをリポジトリを介して保存する例を書いてみます。
public class ユーザーDAO
{
public string ID { get; set; }
public string 名前 { get; set; }
public string 生年月日 { get; set; }
public int 性別種別 { get; set; }
}
というようなDAOがあったとして
public interface Iユーザーリポジトリ {
void Save(ユーザー _ユーザー);
}
public static class なんかのフレームワーク向け拡張メソッド {
public static string なんかのフレームワーク向け格納(this 名前 _名前) => _名前.名前文字列;
public static string なんかのフレームワーク向け格納(this 生年月日 _生年月日) => _生年月日.生年月日ハイフン;
public static int なんかのフレームワーク向け格納(this 性別種別 _性別種別) => _性別種別.値;
public static string なんかのフレームワーク向け格納(this ユーザーID _id) => _id.Guid文字列;
}
public class ユーザーリポジトリ : Iユーザーリポジトリ
{
private なんかのフレームワーク なんかのフレームワーク { get; }
public ユーザーリポジトリ() => なんかのフレームワーク = new なんかのフレームワーク();
public void Save(ユーザー _ユーザー)
{
ユーザーDAO ユーザーDAO = なんかのフレームワーク.Get(ユーザー.ID);
ユーザーDAO.名前 = ユーザー.名前.なんかのフレームワーク向け格納();
ユーザーDAO.生年月日 = ユーザー.名前.なんかのフレームワーク向け格納();
ユーザーDAO.性別種別 = ユーザー.名前.なんかのフレームワーク向け格納();
なんかのフレームワーク.Save(ユーザーDAO);
}
}
値を詰め込むときもNullか否かを意識せずに詰め込めます。
フレームワーク特有の詰め込み方がある場合には拡張メソッドなどで特有の処理を書いたほうが良いかと。
ドメインの方にフレームワーク特有の処理を書いてしまうと業務知識が汚染される気がします。
#どうしてもNullか否かを分岐したいとき
その場合は
bool IsNull()
なんかを値オブジェクトに用意するのではなく
if (o is Null名前)
のように型判定で分岐しましょう。
#まとめ
業務知識としてはnullは許したくなくてもDB等保存側の制限でNull許容にしなければならないときがあります。
その際はなあなあにせずきちんと業務知識として検討しないといけません。
(nullをそのまま代入するか、別値として内部では処理するか、とか)
その際にNullObjectとして型分けしておけば処理の差分が書きやすいかと思っています。
値オブジェクトのNullableな値を取り扱う場合についてNullObjectパターン以外で便利な方法があればご教示頂きたいです。
あと値オブジェクトのNullObjectパターンだとこの場合に対応出来ない、とか。