How to model an ID that sometimes must be present, and sometimes not.

I'm currently writing a client library for Criipto that partially implements the actions available on the Fusebit API. This article, however, isn't about the Fusebit API, so even if you don't know what that is, read on. The Fusebit API is just an example.

This article, rather, is about how to model the absence or presence of an Id property.

User example #

The Fusebit API is an HTTP API that, as these things usually do, enables you to create, read, update, and delete resources. One of these is a user. When you create a user, you supply such data as firstName, lastName, and primaryEmail:

POST /v1/account/acc-123/user HTTP/2
authorization: Bearer 938[...]
content-type: application/json
{
  "firstName""Rhea",
  "lastName""Curran",
  "primaryEmail""recurring@example.net"
}

HTTP/2 200
content-type: application/json; charset=utf-8
{
  "id""usr-8babf0cb95d94e6f",
  "firstName""Rhea",
  "lastName""Curran",
  "primaryEmail""recurring@example.net"
}

Notice that you're supposed to POST the user representation without an ID. The response, however, contains an updated representation of the resource that now includes an id. The id (in this example usr-8babf0cb95d94e6f) was created by the service.

To summarise: when you create a new user, you can't supply an ID, but once the user is created, it does have an ID.

I wanted to capture this rule with the F# type system.

Inheritance #

Before we get to the F# code, let's take a detour around some typical C# code.

At times, I've seen people address this kind of problem by having two types: UserForCreation and CreatedUser, or something like that. The only difference would be that CreatedUser would have an Id property, whereas UserForCreation wouldn't. While, at this time, the rule of three doesn't apply yet, such duplication still seems frivolous.

How does an object-oriented programmer address such a problem? By deriving CreatedUser from UserForCreation, of course!

public class CreatedUser : UserForCreation
{
    public string Id { getset; }
}

I'm not too fond of inheritance, and such a design also comes with a built-in flaw: Imagine a method with the signature public CreatedUser Create(UserForCreation user). While such an API design clearly indicates that you don't have to supply an ID, you still can. You can call such a Create method with a CreatedUser object, since CreatedUser derives from UserForCreation.

CreatedUser user = resource.Create(new CreatedUser
{
    Id = "123",
    FirstName = "Sue",
    LastName = "Flay",
    Email = "suoffle@example.org"
});

Since CreatedUser contains an ID, this seems to suggest that you can call the Create method with a user with an ID. What would you expected from such a possibility? In the above code example, what would you expect the value of user.Id to be?

It'd be reasonable to expect user.Id to be "123". This seems to indicate that it'd be possible to supply a client-generated user ID, which would then be used instead of a server-generated user ID. The HTTP API, however, doesn't allow that.

Such a design is misleading. It suggests that CreatedUser can be used where UserForCreation is required. This isn't true.

Generic user #

I was aware of the above problem, so I didn't even attempt to go there. Besides, I was writing the library in F#, not C#, and while F# enables inheritance as well, it's not the first modelling option you'd typically reach for.

Instead, my first attempt was to define user data as a generic record type:

type UserData<'a> =
    {
        Id : 'a
        FirstName : string option
        LastName : string option
        Email : MailAddress option
        Identities : Identity list
        Permissions : Permission list
    }

(The Fusebit API also enables you to supply Identities and Permissions when creating a user. I omitted them from the above C# example code because this detail is irrelevant to the example.)

This enabled me to define an impure action to create a user:

// ApiClient -> UserData<unit> -> Task<Result<UserData<string>, HttpResponseMessage>>
let create client (userData : UserData<unit>) = task {
    let jobj = JObject ()
 
    userData.FirstName
    |> Option.iter (fun fn -> jobj.["firstName"<- JValue fn)
    userData.LastName
    |> Option.iter (fun ln -> jobj.["lastName"<- JValue ln)
    userData.Email
    |> Option.iter
        (fun email -> jobj.["primaryEmail"<- email |> string |> JValue)
    jobj.["identities"<-
        userData.Identities
        |> List.map Identity.toJToken
        |> List.toArray
        |> JArray
    jobj.["access"<-
        let aobj = JObject ()
        aobj.["allow"<-
            userData.Permissions
            |> List.map Permission.toJToken
            |> List.toArray
            |> JArray
        aobj
 
    let json = jobj.ToString Formatting.None
 
    let relativeUrl = Uri ("user", UriKind.Relative)
 
    let! resp = Api.post client relativeUrl json
 
    if resp.IsSuccessStatusCode
    then
        let! content = resp.Content.ReadAsStringAsync ()
        let jtok = JToken.Parse content
        let createdUser = parseUser jtok
        return Ok createdUser
    else return Error resp }

Where parseUser is defined like this:

// JToken -> UserData<string>
let private parseUser (jobj : JToken) =
    let uid = jobj.["id"] |> string
    let fn = jobj.["firstName"] |> Option.ofObj |> Option.map string
    let ln = jobj.["lastName"] |> Option.ofObj |> Option.map string
    let email =
        jobj.["primaryEmail"]
        |> Option.ofObj
        |> Option.map (string >> MailAddress)
    {
        Id = uid
        FirstName = fn
        LastName = ln
        Email = email
        Identities = []
        Permissions = []
    }

Notice that, if we strip away all the noise from the User.create action, it takes a UserData<unit> as input and returns a UserData<string> as output.

Creating a value of a type like UserData<unit> seems a little odd:

let user =
    {
        Id = ()
        FirstName = Some "Helen"
        LastName = Some "Back"
        Email = Some (MailAddress "hellnback@example.net")
        Identities = []
        Permissions = []
    }

It gets the point across, though. In order to call User.create you must supply a UserData<unit>, and the only way you can do that is by setting Id to ().

Not quite there... #

In the Fusebit API, however, the user resource isn't the only resource that exhibits the pattern of requiring no ID on creation, but having an ID after creation. Another example is a resource called a client. Adopting the above design as a template, I also defined ClientData as a generic record type:

type ClientData<'a> =
    {
        Id : 'a
        DisplayName : string option
        Identities : Identity list
        Permissions : Permission list
    }

In both cases, I also realised that the record types gave rise to functors. A map function turned out to be useful in certain unit tests, so I added such functions as well:

module Client =
    let map f c =
        {
            Id = f c.Id
            DisplayName = c.DisplayName
            Identities = c.Identities
            Permissions = c.Permissions
        }

The corresponding User.map function was similar, so I began to realise that I had some boilerplate on my hands.

Besides, a type like UserData<'a> seems to indicate that the type argument 'a could be anything. The map function implies that as well. In reality, though, the only constructed types you'd be likely to run into are UserData<unit> and UserData<string>.

I wasn't quite happy with this design, after all...

Identifiable #

After thinking about this, I decided to move the generics around. Instead of making the ID generic, I instead made the payload generic by introducing this container type:

type Identifiable<'a> = { Id : string; Item : 'a }

The User.create action now looks like this:

// ApiClient -> UserData -> Task<Result<Identifiable<UserData>, HttpResponseMessage>>
let create client userData = task {
    let jobj = JObject ()
 
    userData.FirstName
    |> Option.iter (fun fn -> jobj.["firstName"<- JValue fn)
    userData.LastName
    |> Option.iter (fun ln -> jobj.["lastName"<- JValue ln)
    userData.Email
    |> Option.iter
        (fun email -> jobj.["primaryEmail"<- email |> string |> JValue)
    jobj.["identities"<-
        userData.Identities
        |> List.map Identity.toJToken
        |> List.toArray
        |> JArray
    jobj.["access"<-
        let aobj = JObject ()
        aobj.["allow"<-
            userData.Permissions
            |> List.map Permission.toJToken
            |> List.toArray
            |> JArray
        aobj
 
    let json = jobj.ToString Formatting.None
 
    let relativeUrl = Uri ("user", UriKind.Relative)
 
    let! resp = Api.post client relativeUrl json
 
    if resp.IsSuccessStatusCode
    then
        let! content = resp.Content.ReadAsStringAsync ()
        let jtok = JToken.Parse content
        let createdUser = parseUser jtok
        return Ok createdUser
    else return Error resp }

Where parseUser is defined as:

// JToken -> Identifiable<UserData>
let private parseUser (jtok : JToken) =
    let uid = jtok.["id"] |> string
    let fn = jtok.["firstName"] |> Option.ofObj |> Option.map string
    let ln = jtok.["lastName"] |> Option.ofObj |> Option.map string
    let email =
        jtok.["primaryEmail"]
        |> Option.ofObj
        |> Option.map (string >> MailAddress)
    let ids =
        match jtok.["identities"with
        | null -> []
        | x -> x :?> JArray |> Seq.map Identity.parse |> Seq.toList
    let perms =
        jtok.["access"]
        |> Option.ofObj
        |> Option.toList
        |> List.collect (fun j ->
            j.["allow"] :?> JArray
            |> Seq.choose Permission.tryParse
            |> Seq.toList)
    {
        Id = uid
        Item =
            {
                FirstName = fn
                LastName = ln
                Email = email
                Identities = ids
                Permissions = perms
            }
    }

The required input to User.create is now a simple, non-generic UserData value, and the successful return value an Identifiable<UserData>. There's no more arbitrary ID data types. The ID is either present as a string or it's absent.

You could also turn the Identifiable container into a functor if you need it, but I've found no need for it so far. Wrapping and unwrapping the payload from the container is easy without supporting machinery like that.

This design is still reusable. The equivalent Client.create action takes a non-generic ClientData value as input and returns an Identifiable<ClientData> value when successful.

C# translation #

There's nothing F#-specific about the above solution. You can easily define Identifiable in C#:

public sealed class Identifiable<T>
{
    public Identifiable(string id, T item)
    {
        Id = id;
        Item = item;
    }
 
    public string Id { get; }
    public T Item { get; }
 
    public override bool Equals(object obj)
    {
        return obj is Identifiable<T> identifiable &&
               Id == identifiable.Id &&
               EqualityComparer<T>.Default.Equals(Item, identifiable.Item);
    }
 
    public override int GetHashCode()
    {
        return HashCode.Combine(Id, Item);
    }
}

I've here used the explicit class-based syntax to define an immutable class. In C# 9 and later, you can simplify this quite a bit using record syntax instead (which gets you closer to the F# example), but I chose to use the more verbose syntax for the benefit of readers who may encounter this example and wish to understand how it relates to a less specific C-based language.

Conclusion #

When you need to model interactions where you must not supply an ID on create, but representations have IDs when you query the resources, don't reach for inheritance. Wrap the data in a generic container that contains the ID and a generic payload. You can do this in languages that support parametric polymorphism (AKA generics).



Wish to comment?

You can add a comment to this post by sending me a pull request. Alternatively, you can discuss this post on Twitter or somewhere else with a permalink. Ping me with the link, and I may respond.

Published

Monday, 03 January 2022 08:57:00 UTC

Tags



"Our team wholeheartedly endorses Mark. His expert service provides tremendous value."
Hire me!
Published: Monday, 03 January 2022 08:57:00 UTC