Review Submission Domain

Our Review Submission Bounded context has several sections. First we’ll model the domain separate from any infrastructure concerns, then we’ll add those in at the edges.

Quick overview of our domain logic
validation errors (eg rating > 5) are returned to client
if profanity is detected, goes to pending, submission
confirmation is returned to client
if no profanity then it goes to approved, and is made available to the product domain

Our entire workflow is like so:

Bounded Context: Submission
Command: "Submit Review"
    triggered by: user submitting a review form
    input: a review form
    output: 
        Review Submitted Acknowledgement: acknowledgement sent to customer OR
        validation error
    other input: ProfanityChecker
    side-effects: Copies sent to reporting context
    events recorded: Review Approved, Review Pending

Process: "Submit Review"
    do Validate 
    if invalid return validation failure
    do profanity check
    if it has profanity
        strip out profanity
        output Review Pending to:
            Products
            Reporting
        stop
    output ApprovedReview to:
        Products
        Reporting
    return SubmitReviewAcknowledgement

SubProcess: Validate
    input: ReviewFormDto
    output: ReviewForm OR ValidationError
    dependencies: none

SubProcess: CheckProfanity
    input: ValidatedReviewForm
    output: ProfanityCheckedReview
    dependencies: ProfanityChecker

SubProcess: SendReview
    input: ProfanityCheckedReview
    output: none

SubProcess: CreateAcknowledgement
    input: ProfanityCheckedReview
    output: ReviewSubmittedAcknowledgement

Domain types


//validated
type Rating = Rating of int
type ContentText = ContentText of string
type Title = Title of string
type ProductId = ProductId of string

type ReviewForm = {
    Rating: Rating
    Title: Title
    Text: ContentText
    ProductId: ProductId
}
type WithProfanity = { Original: ReviewForm; Cleaned: ReviewForm }
type ProfanityCheckedReview = 
    | Clean of ReviewForm
    | Detected of WithProfanity

type ProfanityDetected = { Original: string; Adjusted: string }
type ProfanityChecked = 
    | None of string
    | HasProfanity of ProfanityDetected

Input Dto

//unvalidated input
type ReviewFormDto = {
    Rating: int
    Title: string
    Review: string
    ProductId: string
}

Process

type ProfanityChecker = string -> ProfanityChecked

type SubmitReview =
    ProfanityChecker //dep for CheckProfanity
        -> CheckProfanity //dependency
        -> ReviewFormDto //input
        -> Result<ReviewStatus, SubmitReviewError>

Input validation

To take our raw input from our client (ReviewFormDto) and map it into our domain where our various constraints hold true, we need a validation step. We will use the applicative approach laid out in Domain Modeling Made Functional.

At a high level, one toDomain (validate) function that takes our raw input, and returns either a domain object, which we can then trust, or a list of errors.

module ReviewFormDto =
module ReviewFormDto =

    let createValidatedReview rating title content productId = {
        ReviewForm.Rating = rating 
        ReviewForm.Title = title 
        ReviewForm.Text = content
        ReviewForm.ProductId = productId
        }

    let toDomain (dto:ReviewFormDto) : Result<ReviewForm, string list> =
        let rating = Rating.create dto.Rating
        let title = Title.create dto.Title
        let content = ContentText.create dto.Review
        let productId = ProductId.create dto.ProductId
        createValidatedReview 
            <!> rating 
            <*> title 
            <*> content
            <*> productId

This deserves more depth as it’s pretty slick. Will cover that in a future post.

Unit Tests

By pushing the infrastructure concerns to the edges, we can very easily unit test the domain layer. Here’s a simple test of the main workflow using Expecto:

testCase "SubmitReview" <| fun () ->
    let r = { 
        ReviewFormDto.Rating = 5
        ReviewFormDto.Title = "clean"
        ReviewFormDto.Review = "clean"
        ReviewFormDto.ProductId = "A"
        }
    let submitResult = submitReview profanityChecker checkProfanity r
    match submitResult with
    | Result.Ok _ -> ()
    | Result.Error x -> Tests.failtestf "%s. Expected Ok, was Error(%A)." "" x

Skipping the rest of the implementation. Check the source for details.

Public interface to our domain

Inside our bounded context, we can use rich types, but at the edges, for integrating with other bounded contexts, we need to expose a “public” api, which is kept as limited as possible to maintain flexibility inside our bounded context. We’ll add details along with the plumbing of actually exposing this api in the next post.