Workflows
Domain Types
Types.fs defines two types:
ProductDomain: A single-case union implementing theIDomaininterface. This marker type identifies the domain and distinguishes it from other domains in the solution.ProductWorkflow: The base class for workflows in the Product domain. This design choice prioritizes convenience of use. The code is straightforward enough to justify this exception to the inheritance avoidance rule.
namespace Shopfoo.Product.Workflows
open Shopfoo.Domain.Types.Errors
open Shopfoo.Effects
type ProductDomain =
| ProductDomain
interface IDomain with
member _.Name = "Product"
[<AbstractClass>]
type ProductWorkflow<'arg, 'ret>() =
abstract member Run: 'arg -> Program<Result<'ret, Error>>
interface IDomainWorkflow<ProductDomain> with
member val Domain = ProductDomain
interface IProgramWorkflow<'arg, 'ret> with
member this.Run arg = this.Run argDomain Workflow Design Choice
Which features warrant a workflow implementation? Two approaches lead to different designs.
Favor Simplicity
This is the approach chosen in the Shopfoo solution.
Evaluate each feature to determine whether it would benefit from workflow implementation.
Generally, commands are most suitable candidates. They typically contain business complexity and/or orchestrate multiple Data layer calls. In contrast, queries usually lack sufficient complexity and can be delegated directly to the Data layer.
However, exceptions exist, as seen in Api.fs:
The
AdjustStockcommand is delegated directly to the Warehouse access client.The
DetermineStockquery is implemented as a workflow.
Favor Domain Expressiveness in File Structure
This design mandates that each feature has its own workflow, making them visible in the file tree within the Workflows folder. This results in numerous pass-through workflows that simply invoke a single instruction—typically used only once (not shared across workflows)—which connects to the Data layer during program interpretation.
In my opinion, this violates the KISS principle and leads to over-engineering. Features remain easily accessible through the API contract:
Domain Workflow Examples
Let's examine characteristic workflows in order of increasing complexity.
RemoveListPrice
This feature requires a workflow to orchestrate multiple instructions: getPrices and savePrices. It's one of the simplest workflows.
🔗 Code source
The RemoveListPriceWorkflow class, like all workflow classes, explicitly implements the Singleton pattern without relying on the IoC container. Indeed, the Api class that we'll see later is the only place in production code where workflow instances are used.
As a reminder, the Run method has the signature 'arg -> Program<Result<'ret, Error>>, coming from the IProgramWorkflow<'arg, 'ret> interface. However, the getPrices instruction returns a Program<Prices option>. Therefore, it must be adapted to the type expected as the return of Run. For this, we successively use two helpers from the Program module:
First
requireSome(source code) which converts anOption<'a>to aResult<'a, DataRelatedError>Then
mapDataRelatedError(source code) which transforms aResult<'a, DataRelatedError>into the expectedResult<'a, Error>
The savePrices instruction already has the correct return type, so no adaptation is needed.
These adaptations are among the most delicate aspects when writing a program. When forgotten, the compilation error appears after the "faulty" line and indicates that no overload can be found for the Bind method. This cryptic error message, located in the wrong place, doesn't help understand how to fix the problem. If you don't remember the need to adapt the return type, you can always annotate the values with the expected types. The error is then located in the right place and its message is more precise, which helps somewhat, though it still requires careful analysis to properly understand and resolve the issue.
SavePrices
This feature requires a workflow to handle validation:
If
ListPriceis defined, it must be positive.If
RetailPriceis of typeRegular(notSoldOut), it must be positive as well.
🔗 Source code:
In the Shopfoo codebase, validation occurs in two stages: guard clauses returning Result<'a, GuardClauseError> are transformed into Validation<'a, GuardClauseError> (an alias for Result<'a, GuardClauseError list>). These guard clauses are then aggregated using the validation computation expression with the let! ... and! ... syntax, revealing applicative behavior.
The result of validate prices needs to be adapted regarding the error track. We use the liftGuardClauses (source code) to obtain the required Result<unit, Error> type.
Then, the workflow uses the fact that the program CE provides two overloads for the Bind method to handle the Result type (source code):
The regular
Binduses the>>=bind operator directly.The two other overloads rely on the
bindResultfunction that operates on aResultbut returns it wrapped in aProgram.The first one
Bind(result: Result<_, _>, f)supports binding aResultdirectly and elevating it to aProgram. This is the one used in this workflow to bindvalidate prices |> liftGuardClauses.The second one
Bind(program: Program<Result<_, _>>, f)supports binding aProgramcontaining aResult. In practice, it is thisBindthat is most commonly used in workflows.
This design simplifies program composition, as error track management is already sufficiently delicate on its own.
DetermineStock
This query feature benefits from workflow implementation. It handles both orchestration of multiple instructions—getSales and getStockEvents—and a business rule to determine current stock based on stock events and sales, similar to event sourcing.
🔗 Source code
💡 Formatting Tip
Throughout the codebase, you'll occasionally find // ↩ comments, like the one after Program.getSales sku here. These ensure consistent automatic formatting by Fantomas. Without it, the expression let! (sales: Sale list) = ... would be formatted on a single line (like with let! prices in RemoveListPriceWorkflow), while let! stockEvents = ... spans 4 lines, creating asymmetry that hinders code readability.
This represents a compromise allowing reasonably long lines (up to 150 characters, see .editorconfig) while locally overriding formatting rules via these // ↩ comments.
Last updated