Typestate pattern for CRUD objects?

I remember seeing a blog post which I can no longer find that discussed a design pattern for objects used with CRUD APIs - being able to reuse the same record type between create and read operations, but parametrising the type to impose different constraints.

Can anyone remember this or give me some pointers based on my recollections below?

The gist of the article was that given a struct like,

#[derive(Serialize, Deserialize)]
struct Record {
    id: String,
    blah: ...
}

I can implement a read operation just fine, but the create operation is problematic because we require the creator to assign the identifier rather than the caller. Ok, so we change the definition to:

#[derive(Serialize, Deserialize)]
struct Record {
    id: Option<String>,
    blah: ...
}

fn create(record: Record) -> Record;

fn read(id: &str) -> Record;

now I can code the create operation, but,

  1. it's annoying that the operation can fail because the caller inappropriately passed Some(id) rather than None
  2. it's annoying that readers keeping having to check for None when the field is guaranteed to be populated after creation

So next we make use a type param for the id field, and this is where my recollection gets hazy:

#[derive(Serialize, Deserialize)]
struct Record<Id: Serialize, Deserialize> {
    id: Id,
    blah: ...
}

// can't incorrectly supply an id value any more,
fn create(record: Record<()>) -> Record<String>;

// id value is available on read result
fn read(id: &str) -> Record<String>;

What I'd actually like (I can't remember if the original article touched on this) is to be able to apply this pattern to multiple fields in the record, where each field might have a different type. I've given the example of the id field above, and would like to apply a similar pattern to things like created, modified etc, without having a proliferation of type parameters on the Record struct, and without making serde angry.

// back to the naive structure
#[derive(Serialize, Deserialize)]
struct Record {
    id: Option<String>,
    created: Option<DateTime<Utc>>,
    modified: Option<DateTime<Utc>>,
    blah: ...,
    // ... many other fields ...
}

I have thought about breaking the read-only fields out into a sub-struct, but looks a bit annoying to work with record.read_only.id etc. Maybe this is the only way?

#[derive(Serialize, Deserialize)]
struct Record<RO: Serialize, Deserialize> {
    // apply #[serde(flatten)] ?
    read_only: RO,
    blah: ...,
    // ... many other fields ...
}
#[derive(Serialize, Deserialize)]
struct RecordReadOnly {
    id: String,
    created: DateTime<Utc>,
    modified: DateTime<Utc>,
}

fn create(record: Record<()>) -> Record<RecordReadOnly>;

fn read(id: &str) -> Record<RecordReadOnly>;

Most solutions to this problem will involve multiple structs in some way. I would personally do it as a containing struct so that the inner struct does not need to be generic:

#[derive(Serialize, Deserialize)]
struct Read<T> {
    id: String,
    created: DateTime<Utc>,
    modified: DateTime<Utc>,

    #[serde(flatten)]
    data: T,
}

#[derive(Serialize, Deserialize)]
struct Record {
    blah: ...
}

Then you create records with Record and read them with Read<Record>, and Read is reusable for other kinds of records too.

4 Likes

Thanks! Reversing those dependencies does indeed look prettier.