githubEdit

arrow-progressWorkflows

Domain Types

Types.fs defines two types:

  • ProductDomain: A single-case union implementing the IDomain interface. 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 arg

Domain 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.fsarrow-up-right:

  • The AdjustStock command is delegated directly to the Warehouse access client.

  • The DetermineStock query 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 principlearrow-up-right and leads to over-engineering. Features remain easily accessible through the API contractarrow-up-right:

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 sourcearrow-up-right

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:

The savePrices instruction already has the correct return type, so no adaptation is needed.

circle-exclamation

SavePrices

This feature requires a workflow to handle validation:

  • If ListPrice is defined, it must be positive.

  • If RetailPrice is of type Regular (not SoldOut), it must be positive as well.

🔗 Source codearrow-up-right:

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 codearrow-up-right) 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 codearrow-up-right):

  • The regular Bind uses the >>= bind operator directly.

  • The two other overloads rely on the bindResult function that operates on a Result but returns it wrapped in a Program.

  • The first one Bind(result: Result<_, _>, f) supports binding a Result directly and elevating it to a Program. This is the one used in this workflow to bind validate prices |> liftGuardClauses.

  • The second one Bind(program: Program<Result<_, _>>, f) supports binding a Program containing a Result. In practice, it is this Bind that 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 codearrow-up-right

circle-info

💡 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 .editorconfigarrow-up-right) while locally overriding formatting rules via these // ↩ comments.

Last updated