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,
- it's annoying that the operation can fail because the caller inappropriately passed
Some(id)
rather thanNone
- 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>;