Exposing an API
Last time we set up our domain, and kept it isolated from the real world. This is good for automated testing, and for isolating scope of changes. However, it’s not very useful, so let’s dive into the integration aspects. Our two bounded contexts need a way to communicate.
The “bounded” part of a bounded context is crucial, so there are really only two* viable approaches to getting data from one to another.
Use a Copy of Published Data
We could add a local copy of the reviews data to our products context. Then we could subscribe to events published by the reviews context and update our local data. This would (potentially) give us a lot of reliability. If our reviews context was down, the products context could continue to operate and would catch up once the reviews context came back up. In this case though, we don’t require that level of reliability. We are ok with the reviews functionality of the products context being unavailable if the reviews context goes down.
Use a Public API
If we provide an API designed for clients to use, we can maintain our boundaries. This is a good first step because it’s simpler and more familiar. In practice, this means an API exposed over a network, because a bounded context is probably going to need a data store, and that’s the sort of detail that is hard to expose to a client** while maintaining encapsulation. The most popular choices these days are JSON over HTTP using REST or RPC (or RPC while saying REST), but anything that maintains the boundary and provides encapsulation can work, even (heresy) SOAP with XML. In some environments (.NET for one), the tooling makes SOAP a very simple choice that can work well.
Infrastructure Concerns
Our goal here is to add json serialization/deserialization and persistance steps to our existing workflow. We don’t want to pollute our domain model with implementation concerns like JSON and HTTP though, so we’ll be using a new project with Suave for a web server and will expose the following two calls:
Submit a review
curl -X POST -d '{"Rating":"2", "Title": "title3", "Review": "text that is longer", "ProductId": "a"}' http://localhost:8080/review
Get all reviews for a product
curl -X GET http://localhost:8080/reviews/a
A client would call SubmitReview, pass the necessary input, get back an acknowledgement that it was submitted or an error. The error could be a client error (HTTP 4xx) or a server error (HTTP 5xx).
Just for the sake of simplicity, our review bounded context will persist the submitted reviews in memory at this stage.
In our domain, we defined the following signature:
type SubmitReview =
ProfanityChecker //dep for CheckProfanity
-> CheckProfanity //dependency
-> ReviewFormDto //input
-> Result<ProfanityCheckedReview, SubmitReviewError>
We need to fill in the “real” dependencies, and add serialization and persistance to the edges.
First to add the persistance on the back end, and facilitate domain and infrastructure errors.
let submitReview' reviewFormDto =
reviewFormDto
|> (ReviewSubmission.SubmitReview.submitReview dummyprofanityChecker checkProfanity)
|> Result.tee (fun review ->
match review with
| ProfanityCheckedReview.Clean approved ->
approvedReviews <- approved :: approvedReviews
| ProfanityCheckedReview.Detected profane ->
unapprovedReviews <- profane :: unapprovedReviews
)
|> Result.mapError DomainError
Notice the saving into in-memory lists. In a real implementation with a real data store, we’d call fromDomain to map to a serializable DTO, then either 1) map to a relational db, or 2) serialize to json, xml or similar, and save to a data store.
Add pulling the json from the http request, deserializing into our dto, and piping into our workflow using Suave, and we’ve got a working system.
let app =
choose [
POST >=> choose [
path "/review" >=>
request (fun req ->
deserialize<ReviewFormDto> req
|> Result.mapError SubmitReviewWorkflowError.DeserializationException
|> Result.bind submitReview'
|> mapResponse)
]
]
startWebServer defaultConfig app
Our next call, to get all reviews for a product is pretty simple.
let app =
choose [
GET >=> choose [
pathScan "/reviews/%s" (fun id ->
warbler (fun _ ->
reviewsByProduct id
|> mapResponse))
]
(Suave.RequestErrors.NOT_FOUND "404!")
]
>=> Writers.setMimeType "text/html; charset=utf-8"
startWebServer defaultConfig app
There is no domain functionality here, it’s just a query against our data store. In this case just filtering our list, but would be different against a proper data store.
let reviewsByProduct id =
let productId = (ProductId id) //todo: use smart ctor
approvedReviews
|> List.filter (fun i -> i.ProductId = productId)
|> List.map ReviewFormDto.fromDomain
|> Result.Ok
* Integrating through the db is common and easy, but has pretty brutal downsides.
** Sometimes projects successfully support multiple data stores and let clients configure them.