workflow-implementation
Implement domain workflows as composable pipelines (C# by default, F# on request) following "Domain Modeling Made Functional." Use this skill whenever implementing a workflow, composing pipeline steps
SKILL.mdplugins/dmf/skills/workflow-implementation/SKILL.md
GitHubImplementing Workflows as Pipelines (DMF Methodology)
This skill covers implementing domain workflows by composing small, focused functions into pipelines. It assumes domain types already exist (see dmf:domain-modeling skill).
Language
Code in this skill is F#, the notation of the book.
Write C# unless the user asks for F# or the project is an F# project: read ${CLAUDE_PLUGIN_ROOT}/references/csharp-mapping.md first and express every pattern below with it.
The project profile's Mapping section names the functional library and wins over the mapping's defaults.
Core Architecture
A workflow is a pipeline of transformations:
UnvalidatedOrder
|> validateOrder -- UnvalidatedOrder -> Result<ValidatedOrder, ValidationError>
|> priceOrder -- ValidatedOrder -> Result<PricedOrder, PricingError>
|> acknowledgeOrder -- PricedOrder -> OrderAcknowledgmentSent option
|> createEvents -- PricedOrder -> ... -> PlaceOrderEvent list
Each step:
- Is a standalone, stateless function
- Takes explicit dependencies as parameters
- Documents its effects in the return type
- Can be tested in isolation with fake dependencies
Implementation Sequence
Follow this order when implementing a workflow:
- Simple type constructors -
OrderId.create,String50.create, etc. - Helper functions -
toCustomerInfo,toAddress,toValidatedOrderLine, etc. - Each pipeline step -
validateOrder,priceOrder,acknowledgeOrder,createEvents - Pipeline composition - The top-level
placeOrderfunction - Composition root - Wire up real dependencies
Step 1: Implement Without Effects First
Start by implementing each step WITHOUT Result or Async. Use exceptions for errors.
This lets you focus on the transformation logic and composition.
// First pass: no Result, just throw on error
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
let orderId =
unvalidatedOrder.OrderId |> OrderId.create // throws if invalid
let customerInfo =
unvalidatedOrder.CustomerInfo |> toCustomerInfo
let shippingAddress =
unvalidatedOrder.ShippingAddress |> toAddress checkAddressExists
let lines =
unvalidatedOrder.Lines
|> List.map (toValidatedOrderLine checkProductCodeExists)
{
OrderId = orderId
CustomerInfo = customerInfo
ShippingAddress = shippingAddress
BillingAddress = billingAddress
Lines = lines
}
Then add Result back in a second pass (see Step 4).
Step 2: Helper Functions
Each helper converts an unvalidated sub-structure to a domain type.
let toCustomerInfo (customer: UnvalidatedCustomerInfo) : CustomerInfo =
let firstName = customer.FirstName |> String50.create
let lastName = customer.LastName |> String50.create
let emailAddress = customer.EmailAddress |> EmailAddress.create
let name : PersonalName = { FirstName = firstName; LastName = lastName }
{ Name = name; EmailAddress = emailAddress }
let toAddress (checkAddressExists: CheckAddressExists) unvalidatedAddress =
let checkedAddress = checkAddressExists unvalidatedAddress
let (CheckedAddress checkedAddress) = checkedAddress
let addressLine1 = checkedAddress.AddressLine1 |> String50.create
// ... remaining fields ...
{ AddressLine1 = addressLine1; (* ... *) }
Key pattern: Dependencies that a helper needs are passed as parameters to that helper, NOT accessed globally.
Step 3: Function Adapters
When a function's output doesn't match the next function's input, create an adapter.
Predicate to Passthrough
Convert 'a -> bool to 'a -> 'a (returns the input if predicate passes, else fails):
let predicateToPassthru errorMsg f x =
if f x then x
else failwith errorMsg
// Usage: adapt checkProductCodeExists from bool-returning to passthrough
let toProductCode (checkProductCodeExists: CheckProductCodeExists) productCode =
let checkProduct productCode =
let errorMsg = sprintf "Invalid: %A" productCode
predicateToPassthru errorMsg checkProductCodeExists productCode
productCode
|> ProductCode.create
|> checkProduct
Tee (dead-end adapter)
Convert 'a -> unit to 'a -> 'a (call the function for side effects, return input):
let tee f x =
f x
x
// Usage: slot logging into a pipeline
// x |> tee (fun order -> log "Processing order %A" order.OrderId)
Read references/function-adapters.md for the full catalog.
Step 4: Add Result-Based Error Handling
The Result Module
module Result =
let bind f = function
| Ok success -> f success
| Error failure -> Error failure
let map f = function
| Ok success -> Ok (f success)
| Error failure -> Error failure
let mapError f = function
| Ok success -> Ok success
| Error failure -> Error (f failure)
let sequence (results: Result<'a,'e> list) : Result<'a list, 'e> =
let prepend firstR restR =
match firstR, restR with
| Ok first, Ok rest -> Ok (first :: rest)
| Error e, _ -> Error e
| _, Error e -> Error e
List.foldBack prepend results (Ok [])
The result Computation Expression
type ResultBuilder() =
member _.Return(x) = Ok x
member _.Bind(x, f) = Result.bind f x
member _.ReturnFrom(x) = x
let result = ResultBuilder()
Rewriting with result { }
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
result {
let! orderId =
unvalidatedOrder.OrderId
|> OrderId.create
|> Result.mapError ValidationError
let! customerInfo =
unvalidatedOrder.CustomerInfo |> toCustomerInfo
let! shippingAddress =
unvalidatedOrder.ShippingAddress
|> toAddress checkAddressExists
let! lines =
unvalidatedOrder.Lines
|> List.map (toValidatedOrderLine checkProductCodeExists)
|> Result.sequence
return {
OrderId = orderId
CustomerInfo = customerInfo
ShippingAddress = shippingAddress
Lines = lines
}
}
Key rules:
let!unwraps aResult- use for any step that can faillet(no bang) for steps that always succeed- All errors in the block must be the same type → use
Result.mapErrorto lift returnwraps the final value inOk
Converting Error Types with mapError
Each step may have its own error type. The pipeline needs a common error type.
type PlaceOrderError =
| Validation of ValidationError
| Pricing of PricingError
| RemoteService of RemoteServiceError
let placeOrder : PlaceOrderWorkflow =
fun unvalidatedOrder ->
result {
let! validatedOrder =
validateOrder checkProductExists checkAddressExists unvalidatedOrder
|> Result.mapError PlaceOrderError.Validation
let! pricedOrder =
priceOrder getProductPrice validatedOrder
|> Result.mapError PlaceOrderError.Pricing
let acknowledgmentOption =
acknowledgeOrder pricedOrder
let events =
createEvents pricedOrder acknowledgmentOption
return events
}
Handling Lists of Results
When mapping produces Result<'a,'e> list but you need Result<'a list, 'e>:
let! lines =
unvalidatedOrder.Lines
|> List.map (toValidatedOrderLine checkProductCodeExists) // Result list
|> Result.sequence // list Result → Result of list
For better performance, combine map + sequence into traverse.
Step 5: Dependency Injection via Partial Application
Dependencies flow top-down: top-level function receives all deps, passes them to children.
// Low-level: needs checkProductCodeExists
let toProductCode checkProductCodeExists productCode = ...
// Mid-level: also needs it (to pass to toProductCode)
let toValidatedOrderLine checkProductCodeExists unvalidatedOrderLine = ...
let productCode = unvalidatedOrderLine.ProductCode |> toProductCode checkProductCodeExists
...
// Top-level: receives ALL deps, passes them down
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder -> ...
Baking in Dependencies with Partial Application
Before composing the pipeline, partially apply dependencies:
let placeOrder
checkProductExists // dependency
checkAddressExists // dependency
getProductPrice // dependency
createAcknowledgmentLetter // dependency
sendOrderAcknowledgment // dependency
: PlaceOrderWorkflow =
fun unvalidatedOrder ->
let validatedOrder =
unvalidatedOrder
|> validateOrder checkProductExists checkAddressExists
let pricedOrder =
validatedOrder |> priceOrder getProductPrice
let acknowledgmentOption =
pricedOrder
|> acknowledgeOrder createAcknowledgmentLetter sendOrderAcknowledgment
let events = createEvents pricedOrder acknowledgmentOption
events
Pre-building Dependencies
If a dependency itself has sub-dependencies, bake those in before passing:
// checkAddressExists needs endpoint + credentials
let checkAddressExists endPoint credentials address = ...
// Bake in before passing to the workflow
let placeOrderWorkflow =
let endPoint = config.AddressServiceEndpoint
let credentials = config.AddressServiceCredentials
let checkAddressExists = checkAddressExists endPoint credentials
placeOrder checkProductExists checkAddressExists getProductPrice ...
Step 6: The Composition Root
The top-level function that wires real implementations to all dependencies.
let app : WebPart =
// Set up real services
let checkProductExists = ProductService.checkExists connectionString
let checkAddressExists = AddressService.check endPoint credentials
let getProductPrice = PricingService.getPrice connectionString
let createAcknowledgmentLetter = AcknowledgmentService.createLetter
let sendOrderAcknowledgment = EmailService.send smtpConfig
// Wire up the workflow
let placeOrder =
PlaceOrderWorkflow.placeOrder
checkProductExists
checkAddressExists
getProductPrice
createAcknowledgmentLetter
sendOrderAcknowledgment
// Set up routing
choose [
POST >=> path "/placeOrder"
>=> deserializeOrder
>=> placeOrder
>=> postEvents
>=> toHttpResponse
]
Step 7: Adding Async
For the full async+result pipeline, use AsyncResult and asyncResult { }:
type AsyncResult<'success, 'failure> = Async<Result<'success, 'failure>>
module AsyncResult =
let ofResult x = async { return x }
let map f x = async { let! r = x in return Result.map f r }
let bind f x = async { let! r = x in match r with Ok s -> return! f s | Error e -> return Error e }
let mapError f x = async { let! r = x in return Result.mapError f r }
Read references/function-adapters.md for the full adapter catalog.
Read references/error-handling.md for the complete railroad-oriented programming guide.
Testing
All dependencies are fake-able because they're function parameters:
[<Test>]
let ``If product exists, validation succeeds``() =
// Arrange: stub dependencies
let checkAddressExists address = CheckedAddress address
let checkProductCodeExists productCode = true
let unvalidatedOrder = { ... }
// Act
let result =
validateOrder checkProductCodeExists checkAddressExists unvalidatedOrder
// Assert
match result with
| Ok _ -> () // pass
| Error e -> Assert.Fail(sprintf "Expected Ok, got Error: %A" e)
[<Test>]
let ``If product doesn't exist, validation fails``() =
let checkProductCodeExists productCode = false // always fail
// ... rest of test
No mocking libraries needed. Just define lambdas.
Checklist
- Each step is a pure, stateless function
- All dependencies are explicit function parameters
- Error types are aligned via
mapErrorbefore composition (C#: every failure is built from the workflow's error union) -
result { }orasyncResult { }hides the bind/map plumbing (C#: LINQ query syntax or a fluent chain) - Lists of results use
Result.sequence(C#: the project'sSequencehelper, orF.HarvestErrorsto report every failure) - Composition root is the only place real implementations are wired
- Each step is independently testable with fake dependencies
Output Format
Save the output as 06-workflow-pipelines.md in the design directory cc-docs/design/<topic>/ (see ${CLAUDE_PLUGIN_ROOT}/references/design-directory.md for choosing <topic>).
Start the file with a > Source: line naming the files it consumed (used for staleness checks).
Input files: 04-domain-model.md (domain types + workflow type signatures) + 05-call-chains.md §7 if it exists (workflow-to-service mapping from the lowy plugin, showing which service chain implements each workflow).
If 05-call-chains.md does not exist, organize the output by the workflows in 01-domain-discovery.md §6 (if it exists) and the workflow signatures in 04-domain-model.md, place each workflow in the bounded context that owns it, wire dependencies in the composition root, and say in the output that no call-chain mapping was used.
Output file: 06-workflow-pipelines.md - consumed by Phase 8 (dmf:serialization-persistence - to identify I/O points that need bridging).
Organize the output by workflow. For each workflow, include:
- Pipeline overview (step sequence with type signatures)
- Each step's implementation (function body)
- Adapter functions (shape mismatches resolved)
- Error type unification (
mapErrorto common pipeline error; C#: the error union and itsToError()adapter) - Composed pipeline (the full chain with partial application)
- Composition root wiring (how dependencies are injected at the top level)
- Test examples (fake dependencies, expected inputs/outputs)