Background
I'm using Contentful to drive UIs, allowing non-technical stakeholders to change what customers see. Nothing groundbreaking here. However, there is an interesting problem to solve which Contentful introduces by having a highly flexible workflow.
Contentful works by allowing pieces of 'content' to link to other content 'by reference'. In the normal case, you end up with a few content models pointing to each other, A->B->C->D, etc. But I found myself with an interesting conundrum where we have a business case that legitimately sees the definition of content models that are essentially circular.
A can reference B, B can reference C, and C can reference A or B.
This means if one were to sit there and actively click through the UI and link all these pieces of content up, I need to be able to resolve these as the user expected.
Consider these two.
public class Foo
{
public required Bar Bar { get; init; }
}
public class Bar
{
public required Foo Foo { get; init; }
}
Now try to create them.
var foo = new Foo { Bar = new Bar { Foo = new Foo() } };
Try it yourself and the compiler stops you.
Required member 'Foo.Bar' must be set in the object initializer or attribute constructor.
You can't ever finish that initialiser, because every Foo you write needs a Bar that needs a Foo. Make the properties nullable and the error goes away, since you can now build both objects first and point them at each other afterwards.
public class Foo
{
public Bar? Bar { get; set; }
}
public class Bar
{
public Foo? Foo { get; set; }
}
var foo = new Foo();
var bar = new Bar();
foo.Bar = bar;
bar.Foo = foo;
Then you try to serialize it. Newtonsoft is the kind case, it detects the loop and throws a self-referencing loop exception at you. System.Text.Json is nearly as kind, it gives up at a depth of 64 and calls it a possible object cycle. The unkind case is anything that walks the graph without a cycle check, and there your task dies after a glorious and expensive few seconds.
Cue recursion
Two things make C# developers' eyes twitch, reflection and recursion. I've come round to both in the right place. You just need a warped enough mind to think recursively, and these problems are usually presented as abstract puzzles rather than as the concrete thing in front of you.
The concrete thing in front of me had three properties. A human configures it, so it stops somewhere. It starts at one content model and fans out. It can point back at itself.
That's a tree. So walk it.
Conceptually, you take the first piece of content and resolve it, leaving references untouched.
- A is a piece of content with data and a reference to B. So now resolve B.
- B is a piece of content with data and a reference to C. So now resolve C.
- C is a piece of content with data and a reference to A. So now resolve A.
The thing that makes it work is that you are no longer dealing with classes. These are instances, and A the instance holds a reference to a different instance of A, so there is nothing circular left to resolve. Here's what that looks like.
string Walk(IEnumerable<INode> nodes)
{
foreach (var node in nodes)
{
switch (node)
{
case A a:
Walk(a.Nodes);
// Business logic...
break;
case B b:
Walk(b.Nodes);
// Business logic...
break;
case C c:
Walk(c.Nodes);
// Business logic...
break;
}
}
return "something";
}
Eventually a node has no children, the stack unwinds, and the concrete values resolve on the way back up.
To finish
Is it elegant? Yes. Is it for everyone? No.
Could I have used a while(true) and my own stack? Probably, but I found that harder to reason about than the version that matches the shape of the data.
I know plenty of developers who avoid recursion at all costs. It has a time and a place, and this was both. That's one of those things you have to hit yourself before you believe it.