Search code examples
c#f#domain-driven-designoption-type

How to build F# type fulfilling business rules?


I´m trying to build a type in F#, where when I get an object of that type I can be sure it´s in a valid state.
The type is called JobId and it just holds a Guid.
The business rule is: It must be a Guid - but no empty Guid.
I´ve already implemented the type in C# but now I would like to port it to a F# class library.

That´s the C# type:

public sealed class JobId
{
    public string Value { get; }

    private JobId(string value)
        => Value = value;

    public static JobId Create()
        => new JobId(Guid.NewGuid().ToString("N"));

    public static Option<JobId> Create(Guid id)
        => id == Guid.Empty
        ? None
        : Some(new JobId(id.ToString("N"));

    public static Option<JobId> Create(string id)
    {
        try
        {
            var guid = new Guid(id);
            return Create(guid);
        }
        catch (FormatException)
        {
            return None;
        }
    }
}

So how do I build that in F#? Thanks!

Update 1:
I tried to implement it as discriminated union type like this:

type JobId =
    | JobId of string

But the problem is, that I can´t define any business rules with that approach.
So the final question is: How to ensure that the string in JobId ist in a certain format?


Solution

  • Discriminated unions and F# records keep the internal representation public, so this only works in cases where all values of the internal representation are valid. If you need to define a primitive type that does some checks, then you need a type that hides its internals. In this particular case, I would just use a pretty much direct F# equivalent of your C# code:

    type JobId private (id:string) = 
      member x.Value = id 
      static member Create() =
        JobId(Guid.NewGuid().ToString("N"))
    
      static member Create(id:Guid) =
        if id = Guid.Empty then None
        else Some(new JobId(id.ToString("N")))
    
      static member Create(id:string) =
        try JobId.Create(Guid(id))
        with :? FormatException -> None
    

    Note that there are two cases that you want to protect against - one is string value that's not actually a Guid and the other is an empty Guid. You can use the type system to protect against the first case - just create a DU where the value is Guid rather than string!

    type JobId = 
      | JobId of Guid
    

    Alas, there is no way of ensuring that this guid is not empty. However, a nicer solution than the above might be to define NonEmptyGuid (using a class like above) that represents only non-empty guids. Then your domain model could be:

    type JobId = 
      | JobId of NonEmptyGuid
    

    This would be especially nice if you were using NonEmptyGuid elsewhere in your project.