In the previous post, I used event sourcing to handle commands and recreate the current state of the Artwork entity. In this post, I'll be adding validation to the value objects used to implement the command and event objects.
The ArtworkId Value Object
Let's start with the
ArtworkId value object. This value object is currently implemented as a single case discriminated union.
type ArtworkId = ArtworkId of Guid
While this implementation avoids primitive obsession by making the ID of an artwork entity into a first-class type, there is no validation. For the ArtShowTools domain model, a valid
ArtworkId is required to be non-empty. The first step is to expand the
ArtworkError type to include an error representing an invalid ID.
type ArtworkError = | ArtworkAlreadyExists | ArtworkDoesNotExist | WrongArtwork | InvalidArtworkTitle | InvalidArtworkYear | InvalidArtworkId
Next, I make the
ArtworkId constructor private and add some methods to construct and deconstruct
type ArtworkId = private ArtworkId of Guid module ArtworkId = let fromGuid (guid: Guid) : Result<ArtworkId, ArtworkError> = if (guid = Guid.Empty) then Error InvalidArtworkId else Ok(guid |> ArtworkId) let toGuid (id: ArtworkId) : Guid = let (ArtworkId guid) = id guid
fromGuid method which constructs
ArtworkId instance takes a
Guid value and returns a
Result containing the valid ArtworkId or
To verify the behavior, I add some tests.
let guid = Guid("AD5BB7A9-4AC8-409E-9736-55E0CBB9EED0") [<Test>] let ``ArtworkId.fromGuid fails on empty Guid`` () = let result = Guid.Empty |> ArtworkId.fromGuid match result with | Ok _ -> Assert.Fail("method should fail") | Error error -> match error with | InvalidArtworkId -> () | _ -> Assert.Fail("unexpected error type") [<Test>] let ``ArtworkId.fromGuid succeeds`` () = let result = guid |> ArtworkId.fromGuid match result with | Ok artworkId -> Assert.That(artworkId |> ArtworkId.toGuid, Is.EqualTo(guid)) | Error _ -> Assert.Fail("method should succeed")
Refactoring the Existing Tests
Changing the scope of the
ArtworkId constructor to private will break the existing domain model tests. For example, this declaration no longer compiles.
let id = Guid("350EA2A6-6316-44DE-9316-2D545E5CA2C5") |> ArtworkId
Refactoring it to use the new
fromGuid method requires adding pattern matching so the test value is initialized properly or fails the test if the value is not valid.
let id = match Guid("350EA2A6-6316-44DE-9316-2D545E5CA2C5") |> ArtworkId.fromGuid with | Ok id -> id | Error _ -> failwith "invalid Id' let wrongId = match Guid("D96CD8A5-E757-4798-8753-769E710AFA5A") |> ArtworkId.fromGuid with | OK id -> id | Error _ -> failwith "invalid id"
Unfortunately, this approach results in a lot of duplicated code just to initialize test values. Since I'll be applying this refactoring to all the value objects, I decided to implement a simple generic helper method to abstract test value creation.
let failOnError<'T, 'E> (message: string) (result: Result<'T, 'E>) : 'T = match result with | Ok v -> v | Error _ -> failwith message
This helper method reduces the complexity of declaration test values significantly.
let id = Guid("350EA2A6-6316-44DE-9316-2D545E5CA2C5") |> ArtworkId.fromGuid |> Testing.failOnError "invalid id" let wrongId = Guid("D96CD8A5-E757-4798-8753-769E710AFA5A") |> ArtworkId.fromGuid |> Testing.failOnError "invalid id"
For completeness, I also added tests for the new helper method.
[<Test>] let ``Testing.failOnError handles Error correctly`` () = let ex = Assert.Throws (fun x -> Error "error" |> failOnError "fail" |> ignore) Assert.That(ex.Message, Is.EqualTo("fail")) [<Test>] let ``Testing.failOnError handles Ok correctly`` () = let value = Ok "ok" |> failOnError "fail" Assert.That(value, Is.EqualTo("ok"))
I verify all the tests now pass and push the code changes to the prototype repository.
With a solid approach in place for validating value objects, I'll update the rest of the code in the prototype repository: ArtworkTitle and ArtworkYear. Once that validation is in place, I plan to add some persistence to the application by using EventStoreDb (https://www.eventstore.com/) to store my event streams.