More advanced pipeline composition
June 10th, 2019
Part of the Functional Programming: Clean architecture and DDD series
When we add more functional composition tools to our belt, we can start composing usecase pipelines that are both terse and descriptive.
Operators
- From previous article:
map
:(value => newValue) => Result<newValue, ...>
flatMap
:(value => newResult) => newResult
toTup
:(value => newValue) => readonly [newValue, value]
tee
:(value => any) => Result<value, ...>
resultTuple
:(...[Result<..., ...>]) => Result<readonly [value, value2, ...], error[]>
Sample
type CreateError = CombinedValidationError | InvalidStateError | ValidationError | ApiError | DbError
// ({ templateId: string, pax: Pax, startDate: string }) => Result<TrainTripId, CreateError>
pipe(
flatMap(validateCreateTrainTripInfo), // R<{ pax: PaxDefinition, startDate: FutureDate, templateId: TemplateId}, CombinedValidationError>
flatMap(toTup(({ templateId }) => getTrip(templateId))), // R<[TripWithSelectedTravelClass, { pax... }], ...>
map(([trip, proposal]) => TrainTrip.create(proposal, trip)), // R<TrainTrip, ...>
tee(db.trainTrips.add), // R<TrainTrip, ...>
map(trainTrip => trainTrip.id), // R<TrainTripId, ...>
)
The validateCreateTrainTripInfo
function:
// ({ templateId: string, pax: Pax, startDate: string}) => Result<({ pax: PaxDefinition, startDate: FutureDate, templateId: TemplateId }), CombinedValidationError>
pipe(
flatMap(({ pax, startDate, templateId }) =>
resultTuple(
PaxDefinition.create(pax).pipe(mapErr(toFieldError("pax"))),
FutureDate.create(startDate).pipe(mapErr(toFieldError("startDate"))),
validateString(templateId).pipe(mapErr(toFieldError("templateId"))),
).pipe(mapErr(combineValidationErrors)),
),
map(([pax, startDate, templateId]) => ({
pax, startDate, templateId,
})),
)
Both are taken from usecases/createTrainTrip.ts
This validator facilitates domain level validation,
not to be confused with REST level DTO validation. It prepares the validated DTO data for input to the domain factory TrainTrip.create
. These domain rules are neatly packaged in the Value objects FutureDate
and PaxDefinition
, reducing complexity and knowledge creep in the factory.
Again, if tc39 proposal-pipeline-operator would land, we can write more terse and beautiful code.
CombinedValidationErrors
We're wrapping each ValidationError
into a FieldValidationError
, so that we have the name of the field in the error context, then at the end we combine them into a single error, which can be easily examined and serialized to e.g JSON on the REST api to be consumed and examined by the client.
e.g:
if (err instanceof CombinedValidationError) {
ctx.body = {
fields: combineErrors(err.errors),
message,
}
ctx.status = 400
}
const combineErrors = (ers: any[]) => ers.reduce((prev: any, cur) => {
if (cur instanceof FieldValidationError) {
if (cur.error instanceof CombinedValidationError) {
prev[cur.fieldName] = combineErrors(cur.error.errors)
} else {
prev[cur.fieldName] = cur.message
}
}
return prev
}, {})
Source
As always you can also find the full framework and sample source at patroza/fp-app-framework
What's Next
Next in the series, I plan to examine the question: "When to return errors, and when to throw them?"
Part of the Functional Programming: Clean architecture and DDD series