In this second part of the article, we will discuss the changes made to the shopping app by adding two more subdomains to make the web application more realistic — Customer and Order.
In our shopping application, we have products, and we have customers who order these products.
Of course, a shopping domain consists of many more subdomains.
However, our focus will be limited to codebase growth and code complexity when introducing new functionality or new subdomains.
Here, we focus on token efficiency — how effectively a programming language can produce high‑quality, maintainable software using the fewest syntactic tokens (symbols, keywords, punctuation), thereby reducing syntactic overhead.
By syntactic overhead, I primarily mean the ceremonial and verbose code that adds structure but contributes little to the actual business logic.
After adding the Customer and Order subdomains to our C# shopping application, we significantly increased the syntactic overhead.
By using a typical C# “Ports and Adapters” style, a lot of structural code was added.
It becomes a highly ceremonial process, where you must declare an interface (port) and then provide an implementation (adapter) for it.
Program to an interface, not to an implementation.
Just look at the diagram below to see how complex our project has become and compare it with the equivalent diagram from part 1.
The code changes are here.
We won’t discuss implementation details, because there is nothing particularly special about them — except for placing an order, which includes more complex business logic.
The main focus is the significant increase in structural code (interface/implementation definitions, dependency injection, etc.) — code that does not directly contribute to business logic.
And the more subdomains we add, the more this structural code grows.
I know Copilot can generate such repetitive code for you.
But the question is — do we really need to do it in an OOP way?
With F# — no.
No, because in F#, each function signature effectively acts as an interface.
Any function can be replaced by another function with the same signature, often in combination with partial application and module naming.
So, if we want to swap a Cosmos DB implementation for a MongoDB one, we proceed in a similar way:
create a MongoDB implementation (similar to the Cosmos DB version)
define an abstraction via function composition and partial application
keep the same function signature and module structure
Then we simply replace the reference from the Cosmos DB project to the MongoDB one — without modifying any domain logic in Shopping.Products.Domain module.
So, what in C# is achieved through “Ports and Adapters” using interfaces and implementations, in F# is achieved through function signatures, partial application, and module organization.
Now back to the F# solution.
I’ve added Customer and Order subdomains and performed some refactoring.
The code changes are here.
In the Product repository, I replaced the generic ‘T with the specific Product type, which should have been done from the beginning.
However, this resulted in the following error:
Value restriction: The value ‘getById’ has an inferred generic function type val getById: (string -> ‘_a -> Threading.Tasks.Task<Shopping.Common.Types.Result<Product,Shopping.Common.Types.ReadItemFailureReason>>)
getById isn’t a function but an identifier bound to a function of type (string -> ‘_a -> Threading.Tasks.Task<Shopping.Common.Types.Result<Product,Shopping.Common.Types.ReadItemFailureReason>>), because getByIdAsync<Product> (getContainer ()) call, returns a function waiting for missing parameters. A typical case of partial application in F#.
The error was caused by the unresolved generic ‘a parameter propagating upward through the entire call chain.
box based getPartitionKey chain:
box accepts 'a
→ getPartitionKey: 'a -> PartitionKey
→ getByIdAsync<Product>: Container -> string -> 'a -> Task<Result<Product,...>>
→ getByIdAsync<Product> (getContainer ()): string -> 'a -> Task<...>
→ let getById = getByIdAsync<Product> (getContainer ())
← partial application, 'a still unresolved → VALUE RESTRICTION
We could fix it using explicit type annotation of string -> obj -> Task<Result<Product,ReadItemFailureReason>> type.
But avoid any type annotations and use type inference instead.
Type inference in F# means the compiler automatically figures out the types of values and functions from how you use them, so you often don’t need to write explicit type annotations. With type inference come such advantages like faster coding, better readability, etc. But most importantly easier refactoring, in case of type changes of for example input parameters.
To resolve this cleanly, I introduced a discriminated union
type CosmosPartitionKey =
| StringKey of string
| IntKey of int
so that all types can be resolved at compile time and partial application remains safe across the entire call chain.
Advantages of this approach:
- Type safety at compile time — the DU makes invalid partition key types impossible to represent. With
box, passing any type compiles fine and only fails at runtime withNotSupportedException. - No runtime type checking — The
boxversion uses:?type tests — runtime reflection. The DU version is a straight pattern match, resolved entirely at compile time. - Exhaustive checking — The compiler warns you if a new DU case is added (e.g.
BoolKey) butgetPartitionKeyis not updated. Withbox, you’d silently hit theraisebranch at runtime. - Eliminates the generic leak — As discussed, the DU gives
getPartitionKeya concrete signature, making the entire call chain safe for partial application. - Removes the defensive
raise— The box version needs a catch-all | _ -> raise(NotSupportedException(…)) because any type could be passed. The DU version has no impossible cases to guard against. - Self-documenting — The type
CosmosPartitionKeyin the signature immediately communicates the intent and valid inputs — no need to read the implementation to understand what is accepted.
DU version moves all validation from runtime to compile time, thus eliminating an entire class of bugs, especially runtime bugs.
Below is how DU based getPartitionKey chain looks now:
getPartitionKey: CosmosPartitionKey -> PartitionKey ← concrete, no 'a
→ getByIdAsync<Product>: Container -> string -> CosmosPartitionKey -> Task<Result<Product,...>>
→ getByIdAsync<Product> (getContainer ()): string -> CosmosPartitionKey -> Task<...>
→ let getById = getByIdAsync<Product> (getContainer ())
← safe, CosmosPartitionKey is concrete → NO VALUE RESTRICTION
Make illegal states unrepresentable.
This is type-driven development — you model what exists and what is valid in your domain as types, and the compiler enforces correctness throughout. The correctness of your business logic is therefore guided and enforced by the model design.
In C# type-driven development is also possible. But is very verbose because of the type system. Which in newer versions of C# tries to mimic F#’s type system by introducing records, pattern matching and recently discriminated unions. But all this are just syntactic sugar, which has little to do with algebraic types or real power of F#’s pattern matching.
Now let’s compare the number of projects in C# and F#.
We have 20 in C# against 8 in F#.
There is a significant increase in C# projects and files, because in C# we need:
- separate projects per layer/concern (interfaces, implementations, adapters) to enforce dependency direction
- explicit separation of abstraction and implementation — every interface needs a concrete class separately declared and registered
- explicit class/interface declarations for every abstraction (no structural/implicit typing)
- dedicated files per type (one class/interface per file convention)
- DI wiring boilerplate in
Program.csbinding interfaces to implementations - DTOs + mappers per domain as separate types and files
The number of files in C# solution has also significantly grown, because C# (and OOP in general) encourages separation of concerns where each file/class a single responsibility.
Idiomatic F# does not force abstraction-to-implementation separation as a structural requirement. Interfaces and classes are mostly used for .NET OO interoperability,
Below are C# and F# Order services for comparison, as most complex service for now.
C#
F#
Notice, in F# version there are no abstraction-to-implementation separation, class and constructor declarations, and no DI. As with Product repository, we abstract/hide the implementation in Customer and Order repositories by again using partial application.
So, without abstraction-to-implementation separation, there is no need for separate projects per layer/concern with additional explicit separation of abstraction and implementation and no need for DI. Therefore, there is less structural syntactic overhead. Therefore F# code is more token efficient.
Take a look at the place order business logic. Notice how elegant recursion, pattern matching, and piping make the F# implementation. Conversely, the C# implementation looks cluttered with sequential loops, conditional checks, and exception handling.
In Program.fs we still have initially scaffolded starter F# code.
Unlike Program.cs which contains a lot of changes because of two new subdomains.
Below is a new detailed diagram of the web API with all its dependencies, after adding the two new subdomains. Compare the previous version in part 1 of the article.
Once again, dependencies are module-level with direct project references. The domain orchestrates calls to repository modules rather than interface implementations. There is no need for structural code.
There is also the reason why we have less files in our F# solution. Because F# is based on modules, where we can have separate modules for data and modules for functions operating on that data. Or we can have modules which include data and functions operating on that data. We can still separate concerns, but we do so at module-level.
Also compare high-level diagrams with the second one reflecting domain changes.
High-level diagram after Customer and Order subdomains were added
Even after adding two new domain layers, Customer and Order, all three domain layers still reference repository projects directly and use module functions as contracts (no explicit .NET interface projects).
Now you can clearly see:
With less F# code, you can achieve the same result that requires significantly more structural and repetitive syntax in C#.
That is exactly what token efficiency in a programming language means.
And this isn’t the final conclusion.
In part 3, I present more opinionated conclusions about F#.





Top comments (0)