diff --git a/Directory.Build.props b/Directory.Build.props index 8f2f303..b5987ae 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ https://github.com/jet/FsCodec fsharp unionconverter eventcodec JsonPickler JsonIsomorphism UnionConverter json converters typeshape Apache-2.0 - Copyright © 2016-22 + Copyright © 2016-23 true diff --git a/README.md b/README.md index 1cedcd8..cca40c3 100644 --- a/README.md +++ b/README.md @@ -163,15 +163,17 @@ This adds all the converters used by the `serdes` serialization/deserialization ## ASP.NET Core with `System.Text.Json` -The equivalent for the native `System.Text.Json`, as v6, thanks [to the great work of the .NET team](https://github.com/dotnet/runtime/pull/55108), is presently a no-op. +The equivalent for the native `System.Text.Json`, as of v6, thanks [to the great work of the .NET team](https://github.com/dotnet/runtime/pull/55108), is presently a no-op. -The following illustrates how opt into [`autoTypeSafeEnumToJsonString` and/or `autoUnionToJsonObject` modes](https://github.com/jet/FsCodec/blob/master/tests/FsCodec.SystemTextJson.Tests/AutoUnionTests.fs), and `rejectNullStrings` for the rendering of View Models by ASP.NET: +The following illustrates how to opt into [`autoTypeSafeEnumToJsonString` and/or `autoUnionToJsonObject` modes](https://github.com/jet/FsCodec/blob/master/tests/FsCodec.SystemTextJson.Tests/AutoUnionTests.fs), and `rejectNullStrings` for the rendering of View Models by ASP.NET: // Default behavior throws an exception if you attempt to serialize a DU or TypeSafeEnum without an explicit JsonConverterAttribute // let serdes = FsCodec.SystemTextJson.Options.Default |> FsCodec.SystemTextJson.Serdes // If you use autoTypeSafeEnumToJsonString = true or autoUnionToJsonObject = true, serdes.Serialize / Deserialize applies the relevant converters - let serdes = FsCodec.SystemTextJson.Options.Create(autoTypeSafeEnumToJsonString = true, autoUnionToJsonObject = true, rejectNullString = true) |> FsCodec.SystemTextJson.Serdes + let serdes = + FsCodec.SystemTextJson.Options.Create(autoTypeSafeEnumToJsonString = true, autoUnionToJsonObject = true, rejectNullString = true) + |> FsCodec.SystemTextJson.Serdes services.AddMvc(fun options -> ... ).AddJsonOptions(fun options -> @@ -566,33 +568,149 @@ There are two events that we were not able to decode, for varying reasons: _Note however, that we don't have a clean way to trap the data and log it. See [Logging unmatched events](#logging-unmatched-events) for an example of how one might log such unmatched events_ -### Handling introduction of new fields in JSON - -The below example demonstrates the addition of a `CartId` property in a newer version of `CreateCart`. It's worth noting that -deserializing `CartV1.CreateCart` into `CartV2.CreateCart` requires `CartId` to be an optional property or the property will -deserialize into `null` which is an invalid state for the `CartV2.CreateCart` record in F# (F# `type`s are assumed to never be `null`). - -``` + +### Handling versioning of events in F# with FsCodec + +As a system evolves, the types used for events will inevitably undergo changes too. There are thorough guides such as +[Versioning in an Event Sourced System by Greg Young](https://leanpub.com/esversioning); this will only scratch the surface, +with some key F# snippets. + +High level rules: + 1. The most important rule of all is that you never want to relinquish Total Matching, i.e. never add a `_` catch all case +to a match expression. + 2. The simplest way to add a new field in a backward compatible manner is by adding it as an `option` and then using + pattern matching to handle presence or absence of the value. + 3. Where it becomes impossible to use the serialization-time conversion mechanisms such as + [`JsonIsomorphism`](#jsonisimorphism) ([See example in Propulsion](https://github.com/jet/propulsion/blob/master/src/Propulsion.DynamoStore/AppendsIndex.fs#L17)) + the next step is to mint a new Event Type with a different body type. e.g. if we have a `Properties`, but it becomes + necessary to use a instead `PropertiesV2`: + ```fsharp + type Properties = { a: string } + type PropertiesV2 = { a: string; b: int } + type Event = + | PropertiesUpdated of {| properties: Properties |} + | PropertiesUpdatedV2 of {| properties: PropertiesV2 |} + ``` + The migration steps would be: + - update all decision functions to only produce `PropertiesUpdatedV2` + - pull out helper functions for pattern matches and do the upconversion inline in the fold + ```fsharp + module Fold = + let applyUpdate state (e : PrppertiesV2) = ... + let evolve state = function + | Events.PropertiesUpdated e -> applyUpdate state e + | Events.PropertiesUpdatedV2 e -> applyUpdate state { a = e.a; b = PropertiesV2.defaultB } + ``` + +### Avoiding versioning by optional or nullable fields + +The following demonstrates the addition of a `CartId` property (which is an F# `type`) in a newer version of `CreateCart`. +```fsharp module CartV1 = type CreateCart = { name: string } - type Events = - | Created of CreateCart - interface IUnionContract +module CartV2Null = + type CreateCart = { name: string; cartId: CartId } -module CartV2 = +module CartV2Option = type CreateCart = { name: string; cartId: CartId option } - type Events = - | Created of CreateCart - interface IUnionContract + +module CartV2Nullable = + type CreateCart = { name: string; count: Nullable } +``` + +While the `CartV2Null` form can be coerced into working by using `Unchecked.defaultof<_>` mechanism (or, even worse, +by using the `AllowNullLiteral` attribute), this is not recommended. + +Instead, it's recommended to follow normal F# conventions, wrapping the new field as an `option` as per `CartV2Option`. + +For Value Types, you could also use `Nullable`, but `option` is recommended even for value types, for two reasons: +- it works equally for Value Types (`struct` in C#, `type []` in F#) + and Reference Types (`class` in C#, `type` in F#) without requiring different code treatment when switching +- F# has much stronger built-in support for pattern matching and otherwise operation on `option`s + +See the [`Adding Fields Example`](https://github.com/jet/FsCodec/blob/master/tests/FsCodec.NewtonsoftJson.Tests/PicklerTests.fs#L45) for further examples + +### Upconversion by mapping Event Types + +The preceding `option`al fields mechanism is the recommended default approach for handling versioning of event records. +Of course, there are cases where that becomes insufficient. In such cases, the next level up is to add a new Event Type. + +```fsharp +module EventsV0 = + type Properties = { a: string } + type PropertiesV2 = { a: string; b: int } + type Event = + | PropertiesUpdated of {| properties: Properties |} + | PropertiesUpdatedV2 of {| properties: PropertiesV2 |} +``` + +In such a situation, you'll frequently be able to express instances of the older event body type in terms of the new one. +For instance, if we had a default ([Null object pattern](https://en.wikipedia.org/wiki/Null_object_pattern) value for `b` +you can upconvert from one event body to the other, and allow the domain to only concern itself with one of them. + +```fsharp +module EventsUpDown = + type Properties = { a: string } + type PropertiesV2 = { a: string; b: int } + module PropertiesV2 = + let defaultB = 2 + /// The possible representations within the store + [] + type Contract = + | PropertiesUpdated of {| properties: Properties |} + | PropertiesUpdatedV2 of {| properties: PropertiesV2 |} + interface TypeShape.UnionContract.IUnionContract + /// Used in the model - all decisions and folds are in terms of this + type Event = + | PropertiesUpdated of {| properties: PropertiesV2 |} + + let up : Contract -> Event = function + | Contract.PropertiesUpdated e -> PropertiesUpdated {| properties = { a = e.properties.a; b = PropertiesV2.defaultB } |} + | Contract.PropertiesUpdatedV2 e -> PropertiesUpdated e + let down : Event -> Contract = function + | Event.PropertiesUpdated e -> Contract.PropertiesUpdatedV2 e + let codec = Codec.Create(up = (fun struct (_, c) -> up c), + down = fun e -> struct (down e, ValueNone, ValueNone)) + +module Fold = + + type State = unit + // evolve functions + let evolve state = function + | EventsUpDown.Event.PropertiesUpdated e -> state +``` + +The main weakness of such a solution is that the `upconvert` and `downconvert` functions can get long (if your Event Types list is long). + +See the [`Upconversion example`](https://github.com/jet/FsCodec/blob/master/tests/FsCodec.NewtonsoftJson.Tests/PicklerTests.fs#75). + +#### Upconversion via Active Patterns + +Here are some techniques that can be used to bridge the gap if you don't go with full upconversion from a +Contract DU type to a Domain one. + +```fsharp +module Events = + type Properties = { a: string } + type PropertiesV2 = { a: string; b: int } + module PropertiesV2 = + let defaultB = 2 + type Event = + | PropertiesUpdated of {| properties: Properties |} + | PropertiesUpdatedV2 of {| properties: PropertiesV2 |} + let (|Updated|) = function + | PropertiesUpdated e -> {| properties = { a = e.properties.a; b = PropertiesV2.defaultB } |} + | PropertiesUpdatedV2 e -> e +module Fold = + type State = { b : int } + let evolve state : Events.Event -> State = function + | Events.Updated e -> { state with b = e.properties.b } ``` -FsCodec.SystemTextJson looks to provide an analogous mechanism. In general, FsCodec is seeking to provide a pragmatic middle way of -using NewtonsoftJson or SystemTextJson in F# without completely changing what one might expect to happen when using JSON.NET in -order to provide an F# only experience. +The main reason this is not a universal solution is that such Active Patterns are currently limited to 7 cases. -The aim is to provide helpers to smooth the way for using reflection based serialization in a way that would not surprise -people coming from a C# background and/or in mixed C#/F# codebases. +See the [`Upconversion active patterns`](https://github.com/jet/FsCodec/blob/master/tests/FsCodec.NewtonsoftJson.Tests/PicklerTests.fs#L114). ## Adding Matchers to the Event Contract diff --git a/tests/FsCodec.NewtonsoftJson.Tests/PicklerTests.fs b/tests/FsCodec.NewtonsoftJson.Tests/PicklerTests.fs index 7b49be1..478d203 100644 --- a/tests/FsCodec.NewtonsoftJson.Tests/PicklerTests.fs +++ b/tests/FsCodec.NewtonsoftJson.Tests/PicklerTests.fs @@ -42,25 +42,89 @@ let [] ``Global GuidConverter`` () = test <@ "\"00000000-0000-0000-0000-000000000000\"" = resDashes && "\"00000000000000000000000000000000\"" = resNoDashes @> -module CartV1 = - type CreateCart = { Name: string } - - type Events = - | Create of CreateCart - interface TypeShape.UnionContract.IUnionContract - -module CartV2 = - type CreateCart = { Name: string; CartId: CartId option } - type Events = - | Create of CreateCart - interface TypeShape.UnionContract.IUnionContract - -let [] ``Deserialize missing field a as optional property None value`` () = - let expectedCreateCartV2: CartV2.CreateCart = { Name = "cartName"; CartId = None } - let createCartV1: CartV1.CreateCart = { Name = "cartName" } - - let createCartV1JSON = JsonConvert.SerializeObject createCartV1 - - let createCartV2 = JsonConvert.DeserializeObject(createCartV1JSON) - - test <@ expectedCreateCartV2 = createCartV2 @> +module ``Adding Fields Example`` = + + module CartV1 = + type CreateCart = { name: string } + + module CartV2Null = + type CreateCart = { name: string; CartId: CartId } + + module CartV2 = + type CreateCart = { name: string; CartId: CartId option } + + let [] ``Deserialize missing field as null value`` () = + let createCartV1: CartV1.CreateCart = { name = "cartName" } + // let expectedCreateCartV2: CartV2Null.CreateCart = { Name = "cartName"; CartId = null } // The type 'CartId' does not have 'null' as a proper value + + let createCartV1Json = JsonConvert.SerializeObject createCartV1 + + let createCartV2 = JsonConvert.DeserializeObject(createCartV1Json) + + test <@ Unchecked.defaultof<_> = createCartV2.CartId @> // isNull or `null =` will be rejected + + let [] ``Deserialize missing field as an optional property None value`` () = + let createCartV1: CartV1.CreateCart = { name = "cartName" } + + let createCartV1Json = JsonConvert.SerializeObject createCartV1 + + let createCartV2 = JsonConvert.DeserializeObject(createCartV1Json) + + test <@ Option.isNone createCartV2.CartId @> + +module ``Upconversion example`` = + + module Events = + type Properties = { a: string } + type PropertiesV2 = { a: string; b: int } + type Event = + | PropertiesUpdated of {| properties:Properties |} + | PropertiesUpdatedV2 of {| properties:PropertiesV2 |} + + module EventsUpDown = + type Properties = { a: string } + type PropertiesV2 = { a: string; b: int } + module PropertiesV2 = + let defaultB = 2 + /// The possible representations within the store + [] + type Contract = + | PropertiesUpdated of {| properties: Properties |} + | PropertiesUpdatedV2 of {| properties: PropertiesV2 |} + interface TypeShape.UnionContract.IUnionContract + /// Used in the model - all decisions and folds are in terms of this + type Event = + | PropertiesUpdated of {| properties: PropertiesV2 |} + + let up : Contract -> Event = function + | Contract.PropertiesUpdated e -> PropertiesUpdated {| properties = { a = e.properties.a; b = PropertiesV2.defaultB } |} + | Contract.PropertiesUpdatedV2 e -> PropertiesUpdated e + let down : Event -> Contract = function + | Event.PropertiesUpdated e -> Contract.PropertiesUpdatedV2 e + let codec = Codec.Create(up = (fun struct (_, c) -> up c), + down = fun e -> struct (down e, ValueNone, ValueNone)) + + module Fold = + + type State = unit + // evolve functions + let evolve state = function + | EventsUpDown.Event.PropertiesUpdated e -> state + +module ``Upconversion active patterns`` = + + module Events = + type Properties = { a: string } + type PropertiesV2 = { a: string; b: int } + module PropertiesV2 = + let defaultB = 2 + type Event = + | PropertiesUpdated of {| properties: Properties |} + | PropertiesUpdatedV2 of {| properties: PropertiesV2 |} + let (|Updated|) = function + | PropertiesUpdated e -> {| properties = { a = e.properties.a; b = PropertiesV2.defaultB } |} + | PropertiesUpdatedV2 e -> e + module Fold = + type State = { b : int } + let evolve state : Events.Event -> State = function + | Events.Updated e -> { state with b = e.properties.b }