The documentation on Grain versioning in Microsoft Orleans starts off with this

"👉WARNING: This page describes how to use grain interface versioning. The versioning of Grain state is out of scope."

With a stateful system handling multiple state versions is a must if we want to deploy new versions without taking the whole system offline. Working with F# we have some powertools in our belt, and for dealing with multiple versions we decided to try out Discriminated Unions, and they worked our great. One can of course apply the same technique in C# with a bit more checks.

tl;dr;

The short version (phun intented) is:
Discriminated union with one entry per version and a module which is capable of upgrading old versions as they are loaded from storage. Writing state always write the latest version.

Example

Let's say we have a state where we wish to go from a single image to an array if images, just changing the field type would not work. Lets call this ImageState

The goal is for our grain implementation not to worry too much about versions, to do this we separate the in memory representation, state, in our case called ImageState from the DTO representation called ImageStateDto.

The in memory representation which the grain works with ImageState has a simple list of images uri's

type ImageState = { images: Uri list }

Our stored representation of this can be both the new version and old version

type ImageStateDtoV1 = { image: Uri option }
type ImageStateDtoV2 = { images: Uri list}
type ImageVersionedStateDto = 
    | V1 of ImageStateDtoV1
    | V2 of ImageStateDtoV2
Update:
Be sure to start with at least two cases and test with your binary serializer. With Orleans default binary serializer, single case discriminated unions will yield different format, hence fail deserialization

Since Orlans must be able to create new, empty versions of the state object we'll wrap this with a class and also add a module which can convert from Dto to State and from State to Dto.

type ImageStateDto(state: ImageVersionedStateDto) =
    member val State = state with get, set
    new () = ImageStateDto({ images = []} |> V2)

module ImageStateDto =
    let fromDto (dto:ImageStateDto)  =
        match dto.State with
        | V1 dto -> 
            match dto.image with
            | Some i -> { ImageState.images = [i]}
            | None _ -> { ImageState.images = []}
        | V2 dto -> 
            { ImageState.images = dto.images}
    let toDto (state:ImageState) : ImageStateDto =
        { ImageStateDtoV2.images = state.images} |> V2 |> ImageStateDto

The ImageStateDto is the class which will be injected into the grain constructor as IPersistentState<ImageStateDto> . Inside the grain we wrap the IPersistentState to read the state using the fromDto method. Instead of calling WriteStateAsync directly we have our own internal method using ImageStateDto.toDto before calling WriteStateAsync.  A sample grain may look like this

type ProfileImage =
    inherit Grain
    
    val private persistence : IPersistentState<ImageStateDto> 

    new (persistence) = {
        persistence = persistence
    }
    member this.State 
        with get() = this.persistence.State |> ImageStateDto.fromDto
    
    member private this.WriteStateAsync(state)  =
        this.persistence.State <- state |> ImageStateDto.toDto
        this.persistence.WriteStateAsync()

It may seem a bit tedious, but we've found this to work very well for our needs.