I'm currently working on a repository of methods for a Domain Driven Design project.
The method for getting a DTO from the repository returns the following type: Async<Result<DTO.Worker option, exn>>
, and then I run the following code to transform that DTO into a Domain.Worker
:
methodResult
|> Async.map ((Result.map (Option.map DTO.Worker.toDomain)))
The Domain.Worker
record type has a discriminated union as one of it's values, and we can't use them directly in the database, so there's an extra step outside of the domain model that maps the discriminated union to numbers so we can use save that into the db.
module Domain
type WorkerStatus =
| Status1
| Status2
| Status3
type Worker = {
...
Status : WorkerStatus
}
The issue is that in the DTO.Worker.toDomain
method I'm using pattern matching for mapping those numbers back to the discriminated union:
module DTO
type Worker = {...}
with
static member toDomain (dbo : Worker) : Domain.Worker = {
...
Domain.Worker.Status =
match dbo.Status with
| 1 -> Domain.WorkerStatus.Status1
| 2 -> Domain.WorkerStatus.Status2
| 3 -> Domain.WorkerStatus.Status3
| _ -> raise (exn "data in the database has the wrong format")
}
I could get away with raising exceptions while we were starting the project but now that I'm cleaning up the code I need to wrap this method in a result too, and that raises two questions:
- How to wrap the code in a result following F# standars? I'm still learning F# so the only way I can think of is creating a mutable bool variable at the start of the
toDomain
method, and changing it if we hit any errors in the pattern matching, then returning the DTO transformed into a Domain model or an error based on that boolean. I also tried surrounding the method with atry...with...
block and leave theraise exn
, but my colleague said it wasn't a good solution too. - How can I flatten the new nested Result type? if we change the code so the
toDomain
method returns a Result instead of the Domain object, we'll end up with anAsync<Result<Result<Domain.Worker, exn> option, exn>>
type, and that's not useful as we are expectingAsync<Result<Domain.Worker option, exn>>
everywhere else in the code.
CodePudding user response:
Short-term suggestion
I think you've painted yourself into a corner with these type signatures, so there's no simple solution, but here's the least invasive approach I can think of that seems consistent with your requirements.
First, modify toDomain
so it returns a Result
instead of throwing an exception:
static member toDomain (dbo : Worker) =
let statusResult =
match dbo.Status with
| 1 -> Ok Domain.WorkerStatus.Status1
| 2 -> Ok Domain.WorkerStatus.Status2
| 3 -> Ok Domain.WorkerStatus.Status3
| _ -> Error (exn "data in the database has the wrong format")
statusResult
|> Result.map (fun status ->
{
//...
Domain.Worker.Status = status
})
Now, as you mentioned, the problem is that you have a Result<Option<Result<'a, exn>>, exn>
when you want a Result<Option<'a>, exn>
, so we need a conversion function:
let convert = function
| Ok (Some (Ok x)) -> Ok (Some x)
| Ok (Some (Error exn)) -> Error exn
| Ok None -> Ok None
| Error exn -> Error exn
When you're transforming your DTO into a domain value, you then need:
methodResult
|> Async.map ((Result.map (Option.map DTO.Worker.toDomain)) >> convert)
I'm not saying this is a good idea, but it should at least allow you to untangle the knot for now.
Long-term suggestion
I think it's unwise to mix Result
and Option
types the way you have. If I understand your design correctly, a value of Ok None
currently means that the data access layer executed successfully, but didn't find the requested object. I can see why you ended up with this design, but I think it's more trouble than it's worth. Instead, I would suggest that every operation return something like Result<'a, Error>
instead, where the Error
type could be:
type Error = Exn of exn | NotFound
So, for example, Ok None
would then become Error NotFound
instead. Once you make that change, you stay within a single Result monad, and F# can make your life a lot easier. For example, you could then use a computation expression like this:
let getDomainResult queryParams =
result {
let! dto = getDto queryParams
return! convertToDomain dto
}
(Note: I've ignored Async
here for simplicity, but AsyncResult
might work even better for you. In either case, just stay away from Option
types in the same workflow. There's nothing you can do with Option
that can't be done with Result
.)