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.