How to implement single-source-of-truth for a complex, immutable model?

Viewed 619

Consider two immutable classes:

public class Student
{
    public string Name { get; }

    public int Age { get; }

    // etc

    public IEnumerable<Teacher> Teachers { get; }

    // constructor omitted for brevity

    // implements structural equality
}

public class Teacher
{
    public string Name { get; }

    public int Age { get; }

    // etc

    public IEnumerable<Student> Students { get; }

    // constructor omitted for brevity

    // implements structural equality
}

Imagine a system that:

  • Allows the user to add/edit/remove Students and Teachers
  • Automatically manages Students as time passes (ie: suppose it tracks grades, absences, etc)
  • Automatically manages Teachers as time passes (ie: suppose it tracks schedules, vacation time, etc)

Ultimately, there will be a layer of state-management at the top of the system. Since we want to allow the user to manually manage Students and Teachers, we will have some single-source-of-truth data at the top (ie: IEnumerable<Student> and IEnumerable<Teacher> or similar).

However, since Students and Teachers can both contain values of each other, special care is needed when deleting or replacing. If you naively implement the replace/delete operations of Students by only modifying the single-source-of-truth IEnumerable<Student> (and ignoring any Teachers who have matching Student values), you'll end up with "ghost" Students throughout your system.

My current approach to deal with this is to iterate over all data that might have a matching value in the system and do additional replacements for them as well. In the example above, this means that if a Student or Teacher is replaced (edited) or deleted, then the algorithm must also replace/delete any Student or Teacher instances that have a matching value somewhere within their object hierarchy.

Here are some issues with this approach:

  • Scaling up: Over time, we might imagine this software system growing to include Courses, Semesters, TeacherAssistants, Grades, Credits, Degrees, etc. The code to "sweep over all data in all object hierarchies" suddenly becomes immensely complicated, the risk of bugs and unintended behavior increases greatly, and the state-management layer grows into a monolith of complicated operations and interactions.
  • Handling invalid data: When doing a replacement, a new instance of the data must be created for all data that is touched by the replacement. Many of these new instances might fail validation in their constructors. In this case, you would have to be sure that if any of the needed replacements fail, for any reason, that you either roll back the entire change, or fail to commit it in the first place.

Since these issues seem pretty extreme, it definitely hits me as "something is wrong with my modeling strategy".

An alternative approach would be to emulate "references" (similar to how Actor-systems typically operate), something like:

public class Student
{
    public string Name { get; }

    public int Age { get; }

    // etc

    public IEnumerable<Guid> Teachers { get; }

    // constructor omitted for brevity

    // implements structural equality
}

public class Teacher
{
    public string Name { get; }

    public int Age { get; }

    // etc

    public IEnumerable<Guid> Students { get; }

    // constructor omitted for brevity

    // implements structural equality
}

Then, in the state-management layer, store IDictionary<Guid, Student> and IDictionary<Guid, Teacher> instead.

The downside I can see to this approach would be that it negates one of the huge benefits of functional programming, "make invalid states unrepresentable".

We go from:

// I'm a list of Teachers! You'll always know we all exist.
public IEnumerable<Teacher> Teachers { get; }

to:

// I'm a list of references to Teachers in the state-management layer. Hopefully they exist ¯\_(ツ)_/¯
public IEnumerable<Guid> Teachers { get; }

While this is certainly fine in a system where we want the user to be aware of "broken references" (thus making them a valid state, not an invalid one), if we want to do seamless editing by sweeping the system and validating first, this solution seems like it introduces unnecessary invalid state (a bit reminiscent of the all-references-might-be-null problem, prevalent in many languages).

Before committing to one approach over the other:

  1. Are there any other major strategies for implementing single-source-of-truth for a complex, immutable model?
  2. Does this problem have a name?
  3. Are there any materials out there exploring solutions to this problem?
  4. When it comes to complex systems that need to scale, which solutions are likely to yield the highest correctness to complexity ratio, and why?
2 Answers

The way to achieve this is to realise that a single point-of-truth comes with some trade-offs. You clearly want to achieve this in a functional way, which is laudable, but obviously some mutation must happen to make the single point-of-truth change to represent the new state. The key question is how to make this as robust as possible, but also using a functional approach.

Let's start with the point-of-truth first though.

Any single point of truth in a multithreaded application will have synchronisation issues. A good way around that is to use locking or even an STM system. I'll use the STM system from language-ext to do this (because it's lock-free, it's a functional framework, and has a lot of the other desirable things you'll need: structural record types, immutable collections, etc.)

Disclaimer: I'm the author of language-ext

Firstly, the decision to have the collections of students and teachers within the types is problematic. Not so much from a state management point-of-view, but from a logic point of view. It's much better to take a relational DB approach and move the relations outside of the types:

So, we'll start by creating a static Database class. It's static to indicate it's the single point-of-truth, but you could make in an instance class if you like:

public static class Database
{
    public static readonly Ref<Set<Student>> Students;
    public static readonly Ref<Set<Teacher>> Teachers;
    public static readonly Ref<Map<Teacher, Set<Student>>> TeacherStudents;
    public static readonly Ref<Map<Student, Set<Teacher>>> StudentTeachers;

    static Database()
    {
        TeacherStudents = Ref(Map<Teacher, Set<Student>>());
        StudentTeachers = Ref(Map<Student, Set<Teacher>>());
        Students = Ref(Set<Student>());
        Teachers = Ref(Set<Teacher>());
    }
 ...

This uses:

  • Ref which is the special type for managing the STM system
  • Map which is like Dictionary but immutable and has lots of other useful features
  • Set which is like SortedSet but immutable and has lots of other useful features

So, you can see there are two sets, one for Student one for Teacher. Those are the actual records and then TeacherStudents and StudentTeachers which are maps to sets. Those are the relations.

Your Student and Teacher types now look like so:

[Record]
public partial class Student
{
    public readonly string Name;
    public readonly int Age;
}

[Record]
public partial class Teacher
{
    public readonly string Name;
    public readonly int Age;
}

This uses the Record feature of language-ext which will create types with structural equality, ordering, hash-code, With functions (for immutable transformation), default constructors, etc.

Now we'll add a function to add a teacher to the database:

public static Unit AddTeacher(Teacher teacher) =>
    atomic(() => 
    {
        Teachers.Swap(teachers => teachers.Add(teacher));
        TeacherStudents.Swap(teachers => teachers.Add(teacher, Empty));
    });

This uses the atomic function in language-ext to start an atomic transaction in the STM system. The calls to Swap will manage the change to the values. The benefit of using the STM system is that any parallel threads modifying the Database at the same time will check for collisions and will re-run the transaction in-case of failure. This allows for a more robust and reliable system of updates: either everything works, or nothing does.

You can hopefully see that a new Teacher is added to Teachers and an Empty set of Student is added to the TeacherStudents relations.

We can do a similar function for AddStudent

public static Unit AddStudent(Student student) =>
    atomic(() => 
    {
        Students.Swap(students => students.Add(student));
        StudentTeachers.Swap(students => students.Add(student, Empty)); // no teachers yet  
    });

It should be obvious that it's the same, but for students.

Next, we'll assign a student to a teacher:

public static Unit AssignStudentToTeacher(Student student, Teacher teacher) =>
    atomic(() => 
    {
        // Add the teacher to the student
        StudentTeachers.Swap(students => students.SetItem(student, Some: ts => ts.AddOrUpdate(teacher)));
        
        // Add the student to the teacher
        TeacherStudents.Swap(teachers => teachers.SetItem(teacher, Some: ss => ss.AddOrUpdate(student)));  
    });

This simply updates the relations and leaves the record types alone. It may look a little scary, but the need to use immutable types here means we have to dig into the set to add a value.

The un-assign, is the dual of the above, where AddOrUpdate becomes Remove:

public static Unit UnAssignStudentFromTeacher(Student student, Teacher teacher) =>
    atomic(() => 
    {
        // Add the teacher to the student
        StudentTeachers.Swap(students => students.SetItem(student, Some: ts => ts.Remove(teacher)));
        
        // Add the student to the teacher
        TeacherStudents.Swap(teachers => teachers.SetItem(teacher, Some: ss => ss.Remove(student)));  
    });

So, that's adding and assigning, let's now provide functionality for removing teachers and students.

public static Unit RemoveTeacher(Teacher teacher) =>
    atomic(() => {
        Teachers.Swap(teachers => teachers.Remove(teacher));
        TeacherStudents.Swap(teachers => teachers.Remove(teacher));
        StudentTeachers.Swap(students => students.Map(ts => ts.Remove(teacher)));
    });

public static Unit RemoveStudent(Student student) =>
    atomic(() => {
        Students.Swap(students => students.Remove(student));
        StudentTeachers.Swap(students => students.Remove(student));
        TeacherStudents.Swap(teachers => teachers.Map(ss => ss.Remove(student)));
    });

Note how not only is the record type removed, but also the relations. It's slightly more expensive to remove than add and query, but that's a fair trade.

Now we can do the lookup functions, which would get the most common real-world usage and are super-fast:

public static Option<Teacher> FindTeacher(string name, int age) =>
    Teachers.Value.Find(new Teacher(name, age));

public static Option<Student> FindStudent(string name, int age) =>
    Students.Value.Find(new Student(name, age));

public static Set<Student> FindTeacherStudents(Teacher teacher) =>
    TeacherStudents.Value
        .Find(teacher)
        .IfNone(Empty);

public static Set<Teacher> FindStudentTeachers(Student student) =>
    StudentTeachers.Value
        .Find(student)
        .IfNone(Empty);

And one final function to help find ghost students that have no teacher:

public static Set<Student> FindGhostStudents() =>
    toSet(StudentTeachers.Value.Filter(teachers => teachers.IsEmpty).Keys);

This is a simple one, it merely finds all the relations with no teachers.

Here's the full source in gist form; there are other techniques you could employ, like using an STM monad, an IO monad, or Reader monad to capture transactional behaviour and then applying it in a controlled way, but that's probably beyond the scope of this question.

Some notes about the actor model approach you mentioned

I use the actor model a lot (and have developed echo-process which uses this approach), it is certainly very powerful and I'd recommend using the actor-model for architecting any system, especially if you pick a system that has a supervision hierarchy, it can give clarity, structure, and control.

Sometimes the actor system can get in the way though (with systems like this), it kind of depends how far you want to take it. Actors are single threaded, so that becomes a bottleneck (which is also a reason for actors being so useful, as they're easy to reason about).

The single threadedness of actors is solved via having child actors that work is deferred to. So, for example if you have an actor that holds your state, something like the Database type above then you could could create child actors that do the writing and child actors that do the reading, it really depends how much work the actor is going to do. However, this comes with additional complexity. You could have a single write-actor (that does the expensive stuff), which then sends its state back to the parent when it's updated for the readers to then use.

I'll show you what the STM example looks like with an actor model, first I'll refactor the Database type to be a fully immutable state value:

[Record]
public partial class Database
{
    public static readonly Database Empty = new Database(default, default, default, default);
    
    public readonly Map<Teacher, Set<Student>> TeacherStudents;
    public readonly Map<Student, Set<Teacher>> StudentTeachers;
    public readonly Set<Student> Students;
    public readonly Set<Teacher> Teachers;

    public Database AddTeacher(Teacher teacher) =>
        With(Teachers: Teachers.Add(teacher),
             TeacherStudents: TeacherStudents.Add(teacher, default));  
    
    public Database AddStudent(Student student) =>
        With(Students: Students.Add(student),
             StudentTeachers: StudentTeachers.Add(student, default));  
    
    public Database AssignStudentToTeacher(Student student, Teacher teacher) =>
        With(StudentTeachers: StudentTeachers.SetItem(student, Some: ts => ts.AddOrUpdate(teacher)),
             TeacherStudents: TeacherStudents.SetItem(teacher, Some: ss => ss.AddOrUpdate(student)));

    public Database UnAssignStudentFromTeacher(Student student, Teacher teacher) =>
        With(StudentTeachers: StudentTeachers.SetItem(student, Some: ts => ts.Remove(teacher)),
             TeacherStudents: TeacherStudents.SetItem(teacher, Some: ss => ss.Remove(student)));

    public Database RemoveTeacher(Teacher teacher) =>
        With(Teachers: Teachers.Remove(teacher),
             TeacherStudents: TeacherStudents.Remove(teacher),
             StudentTeachers: StudentTeachers.Map(ts => ts.Remove(teacher)));

    public Database RemoveStudent(Student student) =>
        With(Students: Students.Remove(student),
             StudentTeachers: StudentTeachers.Remove(student),
             TeacherStudents: TeacherStudents.Map(ss => ss.Remove(student)));

    public Option<Teacher> FindTeacher(string name, int age) =>
        Teachers.Find(new Teacher(name, age));
    
    public Option<Student> FindStudent(string name, int age) =>
        Students.Find(new Student(name, age));

    public Set<Student> FindTeacherStudents(Teacher teacher) =>
        TeacherStudents
            .Find(teacher)
            .IfNone(Set<Student>());

    public Set<Teacher> FindStudentTeachers(Student student) =>
        StudentTeachers
            .Find(student)
            .IfNone(Set<Teacher>());

    public Set<Student> FindGhostStudents() =>
        toSet(StudentTeachers.Filter(teachers => teachers.IsEmpty).Keys);
}

I've used the Record code-gen again to provide the With function to make it easier to transform.

I'll then use the [Union] discriminated-union code-gen to create a number of message-types that can act as the operations the actor will perform. This saves a lot of typing!

[Union]
public interface DatabaseMsg
{
    DatabaseMsg AddTeacher(Teacher teacher);
    DatabaseMsg AddStudent(Student student);
    DatabaseMsg AssignStudentToTeacher(Student student, Teacher teacher);
    DatabaseMsg UnAssignStudentFromTeacher(Student student, Teacher teacher);
    DatabaseMsg RemoveTeacher(Teacher teacher);
    DatabaseMsg RemoveStudent(Student student);
    DatabaseMsg FindTeacher(string name, int age);
    DatabaseMsg FindStudent(string name, int age);
    DatabaseMsg FindTeacherStudents(Teacher teacher);
    DatabaseMsg FindStudentTeachers(Student student);
    DatabaseMsg FindGhostStudents();
}

Next, I'll create the actor itself. It consists of two functions: Setup and Inbox, which should be fairly self-explanatory:

public static class DatabaseActor
{
    public static Database Setup() =>
        Database.Empty;

    public static Database Inbox(Database state, DatabaseMsg msg) =>
        msg switch
        {
            AddTeacher (var teacher)                              => state.AddTeacher(teacher),
            AddStudent (var student)                              => state.AddStudent(student),
            AssignStudentToTeacher (var student, var teacher)     => state.AssignStudentToTeacher(student, teacher),
            UnAssignStudentFromTeacher (var student, var teacher) => state.UnAssignStudentFromTeacher(student, teacher),
            RemoveTeacher (var teacher)                           => state.RemoveTeacher(teacher),
            RemoveStudent (var student)                           => state.RemoveStudent(student),
            FindTeacher (var name, var age)                       => constant(state, reply(state.FindTeacher(name, age))),
            FindStudent (var name, var age)                       => constant(state, reply(state.FindStudent(name, age))),
            FindTeacherStudents (var teacher)                     => constant(state, reply(state.FindTeacherStudents(teacher))),
            FindStudentTeachers (var student)                     => constant(state, reply(state.FindStudentTeachers(student))),
            FindGhostStudents _                                   => constant(state, reply(state.FindGhostStudents())),
            _                                                     => state
        };
}

In echo-process the Inbox of an actor works like a fold in functional programming. Fold is usually something like:

fold :: (S -> A -> S) -> S -> [A] -> S

i.e. there's a function that takes a S and an A that returns a new S (the inbox), an S initial state (the setup), and a sequence of [A] values to fold over. The result being a new S state.

The sequence of A values in our case is the stream of messages. And so the actor can be seen as a fold over a stream of messages. This is a very powerful concept.

To setup the actor system and spawn a DatabaseActor we call:

ProcessConfig.initialise(); // call once only
var db = spawn<Database, DatabaseMsg>("db", DatabaseActor.Setup, DatabaseActor.Inbox);

We can then tell the actor that we want it to setup the database:

tell(db, AddStudent.New(s1));
tell(db, AddStudent.New(s2));
tell(db, AddStudent.New(s3));

tell(db, AddTeacher.New(t1));
tell(db, AddTeacher.New(t2));

tell(db, AssignStudentToTeacher.New(s1, t1));
tell(db, AssignStudentToTeacher.New(s2, t1));
tell(db, AssignStudentToTeacher.New(s3, t2));

And ask it about what's in there:

ask<Set<Teacher>>(db, FindStudentTeachers.New(s1))
    .Iter(Console.WriteLine);

Here's the full source in gist form

This is a very nice model for a growing architecture, because you encapsulate state, and can provide pure functional transformations over the state, not having to worry about the messiness of how all the mutation happens behind the scenes. It also creates an abstraction that means the actor could be sitting on another server, or it could be a router to 10 other actors that does load balancing, etc. They also tend to have good systems for handling failure.

The real world problems you'll see are:

  • Messaging just isn't as fast as direct method calls - this may not be such a big issue if you're looking for a really scalable system, because you can do true load balancing, and the small delay per message may be something you'd end up with anyway. But for high throughout systems, you may hit limitations.

  • Learning how to architect an actor hierarchy is a bit of an artform, but equally it offers a really powerful mechanism for properly controlling access to state, whether it's a database or an in-memory state value. The example above could very well talk to a real database, but also have a cache in its state value. And if the actor is the only route to the real database then you have an exceptional caching system.

  • Actors are independent - this can sometimes complicate the logic when you're trying to spread the load across multiple actors. In the example above, if you have child actors that do the writing, then something needs to either merge or coordinate the movement of the state to the parent to make it 'live' for the readers - and in the meantime the readers are reading old state. This shouldn't be a problem most of the time, because all systems work with slightly old state (can't beat the speed of light), and the actor will enforce state consistency, which is vital. BUT, in some circumstances, that eventually consistent model isn't good enough, so be wary.

Using the actor model has probably been one of the single biggest wins for my team (15 year old application that is 10 million+ lines of code), it has helped us refactor, load balance, and get cognitive clarity on a very complex code-base. We use echo-process because I wanted a more functional API to work with, but I don't especially support it in the way I do language-ext, so I'd definitely see what's out there now, as the field has moved on a lot in the past 5 years.

Immutablity

By the way, I agree with all of your reasoning (in the comment replies to your original post) about why you want to model your domain with entirely immutable types. You will see many detractors in the C# community who "have always done it this way" or whatever.

Immutability and pure functionality will give you super powers as a developer and you're right to want to explore how to deal with that messy bit at the mutable root. If you instead think of all the root-mutable-references as a stream of values of type World then you can start to see a more abstract view of that mutatble root: your program is a fold over the stream of actions it performs. Its initial state value is the current state of the world, and the action returns a new World. Then there's no need for mutation. This is often represented in functional applications using recursion:

public static World RunApplication(World state, Seq<WorldActions> actions) =>
    actions.IsEmpty
        ? state
        : RunApplication(RunAction(state, actions.Head), actions.Tail);

If every function takes a World and returns a new World then you get a representation of time.

In reality it's quite hard to make this work, because clearly you can't capture the state of all files, all database rows, etc. before you start the application. In many ways though, this is what the actor system tries to do in a small way for each actor, it creates a mini-World when it starts up and then it manages the passage of time (changing of state) for the world. I like this mental model, it feels right, and it gives identity to the set of values that represent the change over time, not just the individual reference to state you hold right now.

An important design goal is separating logic from state, C# came from OOP where a class is a coupling of logic+state and therefore such separation is tricky to achieve with C#. However with the later versions of C# you can get very close ..

Below is code for a IState - a resource that you can only access through it's interface and that (in this implementation) locks the resource before accessing:

  public delegate void ActionRef<T>(ref T r1);
  public delegate RES FuncRef<T, RES>(ref T r1);


  // CONTRACT: T is immutable
  public interface IState<T>
  {
    void Ref(ActionRef<T> f);
    TRES Ref<TRES>(FuncRef<T, TRES> f);
    T Val { get; }
  }

Since T is immutable, if you are OK with a stale value (and most often you are, which is one of the main benefits of using immutability), you can use Val to get the state.
However the only way to modify the state in IState is by calling Ref like this

state.Ref((ref T t) => { ... });

This gives an implementation of IState the ability to add side effects to mutations - ie locking:

  // CONTRACT: T is immutable
  public class LockedState<T> : IState<T>
  {

    public LockedState(T val) => this.val = val;
    protected readonly object theLock = new object();
    protected T val;

    public void Ref(ActionRef<T> f) { lock (theLock) f(ref val); }
    public TRES Ref<TRES>(FuncRef<T, TRES> f) { lock (theLock) return f(ref val); }
    public T Val { get => val; }
  }


The only way to modify a LockedState is through it's Ref method which will lock the object.
However since T is immutable you can use the Val property to get it's current value which may get stale without locking

You can now define a state and create a Store to hold it ie like this:

  public class Student
  {
    public string Name { get; }
    public int Age { get; }
    public ImmutableHashSet<string> TeachersNames { get; }
    public Student(string name, int age, IEnumerable<string> teachersNames) => (Name, Age, TeachersNames) = (name, age, teachersNames.ToImmutableHashSet());
    // implements structural equality
  }

  public class Teacher
  {
    public string Name { get; }
    public int Age { get; }
    public ImmutableHashSet<string> StudentsNames { get; }
    public Teacher(string name, int age, IEnumerable<string> studentsNames) => (Name, Age, StudentsNames) = (name, age, studentsNames.ToImmutableHashSet());
    // implements structural equality
  }

  public class ClassroomState
  {
    public ImmutableHashSet<Teacher> Teachers { get; }
    public ImmutableHashSet<Student> Students { get; }
    public ClassroomState(ImmutableHashSet<Teacher> teachers, ImmutableHashSet<Student> students) => (Teachers, Students) = (teachers, students);

    // the store 
    public static readonly IState<ClassroomState> Store = new LockedState<ClassroomState>(new ClassroomState(ImmutableHashSet<Teacher>.Empty, ImmutableHashSet<Student>.Empty));
  }

For this simple demo, since the Store only has a single LockedState I chose to store it as a static member of ClassroomState, another option would be to use a dedicated class.

Now that we have the State and the Store, let's write some logic:
Logic is just that and so is implemented as a static class
Note the way Ref is invoked which takes a bit of getting used to ...

  public static class ClassRoomLogic
  {
    // just a shortcut
    static readonly IState<ClassroomState> Store = ClassroomState.Store;

    public static void AddTeacher(Teacher teacher)
    {
      Store.Ref((ref ClassroomState classroomState) => {
        // ! classroomState is locked here !
        var prevClassroomState = classroomState;
        var newTeachers = classroomState.Teachers.Add(teacher);
        classroomState = new ClassroomState(newTeachers, prevClassroomState.Students);
      });
    }

    public static void AddStudent(Student student)
    {
      Store.Ref((ref ClassroomState classroomState) => {
        // ! classroomState is locked here !

        // add student to classroomState.Students
        if (classroomState.Students.Any(s => s.Name == student.Name)) return; // student already there
        var newStudents = classroomState.Students.Add(student);

        // add/update all teachers
        var newTeachers = classroomState.Teachers;
        foreach (var teacherName in student.TeachersNames)
        {
          var prevTeacher = newTeachers.Where(t => t.Name == teacherName).FirstOrDefault();
          if (prevTeacher is null) continue; // this teacher does not exist (throw error)
          var newTeacher = new Teacher(prevTeacher.Name, prevTeacher.Age, prevTeacher.StudentsNames.Add(student.Name));
          newTeachers = newTeachers.Remove(prevTeacher).Add(newTeacher);
        }

        // mutate the state
        classroomState = new ClassroomState(newTeachers, newStudents);
      });
    }

    public static IEnumerable<string> GetAllStudentNamesOfTeacher(string teacherName)
    {
      var staleClassroomState = Store.Val; // NOT locked ! can get stale
      return staleClassroomState.Teachers.Where(t => t.Name == teacherName).FirstOrDefault()?.StudentsNames;
    }
  }


And let's use it:

  class Program
  {
    static void Main()
    {
      var teacher = new Teacher("Mary", 45, ImmutableHashSet<string>.Empty);
      ClassRoomLogic.AddTeacher(teacher);
      var student = new Student("John", 12, ImmutableHashSet<string>.Empty.Add("Mary"));
      ClassRoomLogic.AddStudent(student);

      var studentsOfMary = ClassRoomLogic.GetAllStudentNamesOfTeacher("Mary");
      Console.WriteLine(studentsOfMary);
    }
  }


This is sample code which I hope can give you some ideas to solve your issue, this can be enhanced a lot but the basic idea is all here - the State/Store is separated from the logic and locking etc is part of the State not the logic. It would be easy to modify this to keep or serialize store changes, use an STM etc

Hope this helps.

(also see With for an easy way to mutate immutable objects)

Related