A very opininated C# functional framework for decoupling Data, State and Logic
C# was developed as an OOP language where data, state and logic are strongly coupled in instances
of mutable classes. This makes coding in a 'functional' paradigm difficult:
-
Encapsulating a State and passing it around with a dedicated access/mutation API (e.g, locking for
thread safety) is challenging. -
Creating immutable Data with value semantics is challenging and enforcing immutability across a
solution is challenging. Also Immutable containers are cumbersome to use and have reference semantics. -
Encapsulating pure Logic is challenging. Using static classes for logic is cumbersome and requires
passing relevant states to each function.
Newer language features (records, refs, lambdas etc) allow better functional programming in C#.
F Introduces a clear separation between State, Data and Logic along with support mechanisms:
-
Data are immutable types with value semantics (including for == and !=) implemented as C# records.
F also provides Data versions of .NET collections with enhanced API, see Collections:record EmployeeData(string Name, Set<string> Phones);Setis the F version ofImmutableHashSetand is itself Data and so can be ie stored in aSetor be itself a key in aMap(dictionary).
record PhonesData : SetBase<PhonesData, string> { public PhonesData() : base() { } public PhonesData(params string[] phones) : base(phones) { } } record EmployeeData(string Name, PhonesData Phones); var employeeList = new Lst<EmployeeData>();SetBaseis the base class forSet, InheritingSetBase(rather than usingSetdirectly) makesPhonesDataa
separate type to encourage type safety.Lstis the F Data version ofImmutableList.
record EmployeesMapData : MapBase<EmployeesMapData, string, EmployeeData>; var daveData = new EmployeeData("Dave", new PhonesData("65321457")); var repository = new EmployeesMapData(("dave", daveData)); var newDaveData = daveData with { Phones = daveData.Phones + "78901234" }; repository += ("dave", newDaveData); var dave = repository["dave"]; if (dave is not null) ...MapBaseis the base class forMap(the F version ofImmutableDictionary)Note the use of
+to add toPhonesand+=to add torepository.Fcollections prefer operator overloading
for adding/removing as they are more suitable and convenient for immutable types.
ForMap'+' will overwrite existing values.Also note the index operator returning
T?is preferred overTryGetValuefor non-nullable reference types as it is more natural.
TryGetValueis still available for nullable reference types or value types.
-
State - an instance of a class implementing
IStateorIReadOnlyStateto provide an explicit
clear API for creating, accessing and mutating the state:var johnData = new EmployeeData("John", new()); var johnState = new LockedState<EmployeeData>(johnData); IReadOnlyState<EmployeeData> JohnStateRO = johnState.ToIReadOnlyState; EmployeeData johnData = JohnStateRO.Val(); IState<EmployeeData> JohnStateRW = johnState.ToIState; JohnStateRW.Val((ref EmployeeData johnData) => { johnData = johnData with { Phones = johnData.Phones + "78901234" }; });Using a
LockedStatemakes access and mutation ofjohnStatethread safe (by acquiring a lock).JohnStateROis a thread safe read-only access to the State that can be passed around.
.Val()temporarily locks the state and returns it's current (immutable) value.
JohnStateRWis a thread safe read/write access to the State that can be passed around.
.Val((red ...)locks the state to allow mutation. Importantly it is the only way to mutate the Data in ajohnState.
.ToIReadOnlyStateand.ToIStateare recommended but optional and serve to make the intention (read-only vs read/write) explicit.IStates are really only useful as parameter toLogicclasses constructors.
-
Logic - a C#
classthat is initialized with access to specific states, this saves passing the state to each API call
and provides precise access control:class EmployeesLogic { readonly IState<EmployeesMapData> EmployeesMapState; readonly IReadOnlyState<ConfigData> ConfigState; public EmployeesModule(IStateRef<EmployeesMapData> employeesMapState, IStateVal<ConfigData> configState) { EmployeesMapState = employeesMapState; ConfigState) = configState; } public EmployeesMapData Val() => EmployeesMapState.Val(); public PhonesData? GetEmployeePhones(string name) => EmployeesMapState.Val()[name]?.Phones; public bool AddEmployeePhone(string name, string phone) { return EmployeesMapState.Val((ref EmployeesMapData employeesMap) => { var employee = employeesMap[name]; if (employee is null) return false; var newPhone = ConfigState.Val().PhoneCountryPrefix + phone; var mutatedEmployee = employee with { Phones = employee.Phones + newPhone }; employeesMap+= (name, mutatedEmployee); return true; }); } }EmployeesLogicis a Logic class - it's only (private & readonly) fields are theStatesthat its
methods can access/mutate. In this case it has a mutation access toEmployeesMapStateand a
read only access toConfigState.
EmployeesLogicprovides public read-only access to a private state via theVal()method,
this is a common (but optional) design decision.
GetEmployeePhonesusesVal()to get access to the current value ofEmployees.
AddEmployeePhoneuses aVal((ref ...)to mutateEmployeesMapState.Valhere also returns a value
to the surrounding scope.
UsingVal((ref ...)is the only way to changeEmployeesMapStateand because it is aLockedState
this operation is thread-safe (a lock is acquired internally).
-
Validator Debug time verifier that checks all types in the assembly adhere to the State/Data/Logic separation:
record BadData(string Name, HashSet<int> Phones); Validator.Run(); // throws exception: Data record BadData member Phones cannot be a classThe Validator though optional is in many ways the heart of
F. While it will definitely work to mix in elements ofFinto a project, ideally the entire code base is structured to decouple Data State and Logic usingFthroughout
and callingValidator.Run()for validating.
Inevitably in many cases, some .NET types that are notDatahave to be used. In some of these cases it
is possible to encapsulate these types inside aDatatype and move their mutable part to aState.
Other types cannot be converted (ie classes inheritingEntityFrameworkCore.DbContextwhich is not
immutable) and have to be managed carefully using[FIgnore].
- Add the
.csfiles to your project - In your sources add
using F; - call
Validator.Run()at program start