Table of contents
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 ArtworkId
instances.
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
The fromGuid
method which constructs ArtworkId
instance takes a Guid
value and returns a Result
containing the valid ArtworkId or InvalidArtworkId
error.
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.
Next Steps
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.