This article isn’t part of the previous F# vs C# series (part 1, part 2, part 3), because that series focuses on F#’s token efficiency.
This article focuses on orchestrators.
An orchestrator is a piece of code sitting in the middle. Its main purpose is to orchestrate workflow logic — the sequence, branching, and composition of calls to other services. In practice, an orchestrator also contains domain logic, for example when domain logic depends on data provided by other services.
With those concepts in mind, both F# and C# applications have been refactored to lean further toward clean architecture, strengthening the separation between domain/business logic and infrastructure. In both applications, Cosmos DB-specific query building was moved down to the persistence layer, where it belongs. The domain model no longer depends on any persistence-specific abstraction. More precisely, I removed any reference to Json.NET. Now the domain model doesn’t depend on any specific implementation and is technology-agnostic.
For C# application specifically (code here), I no longer use reflection to resolve the partition key for specific domain models — a fragile approach that breaks when a model changes. Instead, I tied partition key resolution to the domain models via the IPartitionable interface.
Additionally, other refactoring was done outside of the clean architecture changes. Duplicated file-handling logic across controllers was consolidated behind a shared abstraction. Exception handling was also simplified: several narrow, overlapping exception types have been replaced with one consistent hierarchy shared by both entity and file-storage errors. In the order orchestrator I try to mimic transactionality/rollback logic in case of failures, to add more complexity when comparing with the F# way to do the same. Transactional support in non-relational databases is limited — Cosmos DB, for example, supports transactions only within a single partition. If you need full transactional support, use a relational database.
Regarding F# application (code here), all references to CosmosPartitionKey were removed from the API, because CosmosPartitionKey is a Cosmos DB infrastructural concept and belongs to infrastructure. I moved it from the application level down to the infrastructure layer.
The API now passes domain concepts like ProductId and ProductCategory and infrastructure maps them to Cosmos partition key values. For that, single-case discriminated unions were defined, which are essentially wrappers for other types. This approach, which I discussed in F# vs C# — Token Efficiency, serves several purposes here:
Prevents accidental mix-ups of same-shape values —
ProductId,CustomerId,OrderIdandProductCategoryare all strings underneath, but they are different types, so you cannot accidentally pass one where another is expectedMakes domain intent explicit — A function signature like
getProductById: ProductId -> ProductCategory -> _communicates domain intent much better thangetProductById: string -> string -> _Keeps infrastructure concerns out of API/domain — we pass domain concepts at the application boundary, and repositories translate them to
CosmosPartitionKey
This is another example of how type-driven development adds compile-time safety.
Orchestration logic has been added to both C# and F# projects, including data validation as part of the orchestration.
Someone could argue that data validation belongs in the API layer, not the orchestrator. But domain logic needs consistent data regardless of whether the API layer changes its validation or is bypassed entirely — otherwise we end up with inconsistent data. Validation could be split between the API layer and the orchestrator, where the API layer is doing validation for input format and early user feedback, and the orchestrator does validation for business invariants — a domain object must never exist in an invalid state, regardless of who constructs it. That said, I added validation to the orchestrator on purpose and that purpose will become clear later.
As written before, this article focuses on orchestration in F#.
So, let’s move on.
Have you ever heard of Railway Oriented Programming? If not, you can read more about it here. ROP is a way of handling errors in functional-style code, popularized by Scott Wlaschin. The core idea is to use “train tracks”. So, instead of cluttering code with exception handling and if/else/then chains, you chain functions. With successful chaining when everything is going fine — success track. And divert it on the failure track when something went wrong. Once you're on the failure track, you stay there — subsequent functions get skipped, and the error just rides through to the end, where you handle it once.
ROP is based on monads, which is a concept from category theory rooted in endofunctors. You know
A monad is a monoid in the category of endofunctors, what's the problem?
:)
Haskell is the language that made monadic programming practical and mainstream.
class Applicative m => Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b
(>>) :: m a -> m b -> m b
Along with other functors, monads found their way into mainstream languages.
Nowadays one can’t imagine programming without (endo)functors.
Monad-like Promises (Promise), for instance, are at the core of JavaScript and TypeScript languages.
Think of monads as adapters which enable chaining disparate things together — to put it simply.
Below is how we would implement a ROP compatible Monad using Haskell’s Monad definition and a fictional Something a = Exists a | Nothing type constructor
1. m = Something — wraps a value or represents absence: Exists a or Nothing
2. return :: a -> Something a — wraps a value as Exists a
3. (>>=) :: Something a -> (a -> Something b) -> Something b
a. If input is Nothing → skip f entirely, return Nothing
b. If input is Exists a → unwrap a, feed it to f, get back Something b (which is Exists b or Nothing)
c. Return that Something b
So, the important part here is to define a Something type constructor with two options, for success and failure tracks.
A type constructor is a type-level function which takes type arguments and produces a concrete type.
Result<'T, 'TFailure> takes 'T and 'TFailure as type arguments and produces the concrete type Result<'T, 'TFailure> — a discriminated union with two cases, Success of 'T and Failure of 'TFailure, whose case constructors build the actual values (e.g. Success 5 or Failure "bad input").
I defined the Result<'T, 'TFAILURE> type constructor upfront and used it as return type in all functions at infrastructure level, which would allow us to chain those functions together.
In fact, those functions return System.Threading.Tasks.Task<Result<'T, 'TFAILURE>>. Therefore, I defined a new TaskResult<'T, 'TFAILURE> type constructor, which is an alias for Task<Result<'T, 'TFAILURE>> to precisely match the functions’ return type to be able to chain them.
Therefore, the Monad module implements the wrapper returning and the chainer (>>=) for the former type constructor.
And TaskMonad module implements the wrapper returningTask and the chainer (=>>=) for the latter, respectively.
Below are the complete implementation definitions.
As I mentioned before in F# vs C# — Token Efficiency, avoid any type annotations and use type inference instead.
For (=>>=) I made an exception, because TaskResult<'a,'b> is just an alias for Task<Result<'T, 'TFAILURE>>. Thus, nothing prevents us from passing values of Task<Result<'T, 'TFAILURE>> type instead. But the latter declaration looks ugly compared to the former. So, when you hover with the mouse over the usage of the (=>>=) you see the following declaration in quick doc val (=>>=) : input: TaskResult<'a,'b> -> f: ('a -> TaskResult<'c,'b>) -> _. The declaration can serve as an instruction to what argument type should be used for input and f parameters. TaskResult<'c,'b> is just more readable.
So now let’s look at the TaskMonad’s usage in our code.
Let’s use module Shopping.Customer.Domain as an example.
uploadFileUseCase function is the entry point for file upload/update use case. It chains all the steps necessary for successful upload/update:1. Wrap the input file into TaskResult type constructor
2. Validate customer metadata
3. Check customer’s existence
4. Upload/update file
As I previously mentioned, step 2 should be done in API. The purpose of adding it in orchestrator was to add more chaining complexity as an example.
The (fun file -> f file stream) lambda enables partial application without helper functions for parameter flipping.
You see how I use function composition to build orchestration cases.
In F# everything is based on composition — composition of data, expressions or functions, which when called also return expressions.
Monads allow us to compose (chain) disparate functions. Type constructors play the most important part in this process. In ROP they enable chaining of success and failure tracks.
Just look at how elegant and streamlined I do the orchestration.
returningTask file =>>= validateCustomerMetadata =>>= validateCustomerExists =>>= (fun file -> f file stream)
Just a single line of code. No exception handling and if/else/then chaining. We chain functions, instead.
Conclusions
As I mentioned before in F#, and other FP languages, it is all about composition. You compose data, expressions and functions.
ROP, which is based on FP concept of monads, is also about function composition. We compose success and failure execution flows. And with ROP we can build streamlined and elegant orchestration, without verbose and difficult-to-read code.
That is what I wanted to demonstrate to you in this article.
Top comments (0)