In a previous post we looked at the importance ๐ค of modeling content in Kentico Xperience and the impacts of that content modeling on our code.
We asked the question "How well does our code match the content model? Especially, when working with optional or missing data."
We saw that nullable reference types can help express that data is missing, but they aren't the only tool ๐จ at our disposal for handling or modeling missing data in our Kentico Xperience applications.
In this post we're going to look at one of those alternative tools - the Null Object Pattern.
๐ What Will We Learn?
- What are the problems with modeling our code using only nullable reference types
- Implementing the Null Object Pattern with our Call To Action
ImageViewModel
- Where do we fall short with the Null Object Pattern?
๐ A Refresher - Our Call To Action
Let's quickly look back on our example Call To Action Page Type which represents our content model in code:
public class CallToAction : TreeNode
{
public bool HasImage { ... }
public string ImagePath { ... }
public string ImageAltText { ... }
}
This Page Type has some optional content - the "Image" - which is represented by the three properties in the CallToAction
class.
If CallToAction.HasImage
is true
, then we expect ImagePath
and ImageAltText
to have values, if it is false
, then we will ignore the values and act as though we have no Image for our Call To Action.
Now, let's look again at our HomeController
where we were trying to work with our Call To Action:
public class HomeController : Controller
{
private readonly IPageRetriever pageRetriever;
private readonly IPageDataContextRetriever contextRetriever;
// ... Constructor
public ActionResult Index()
{
var home = contextRetriever.Retrieve().Page;
var cta = retriever.Retrieve<CallToAction>(
q => q.WhereEquals("NodeGUID", home.Fields.CTAPage))
.FirstOrDefault();
var viewModel = new HomeViewModel
{
Title = home.Fields.Title,
Image = cta.HasImage
? new ImageViewModel
{
Path = cta.ImagePath,
AltText = cta.AltText
}
: null
};
return View(viewModel);
}
}
We use a property, CTAPage
, on our Home
Page Type to find our Call To Action and then create our HomeViewModel
with an optional Image
:
public class HomeViewModel
{
public string Title { get; set; }
public ImageViewModel? Image { get; set; }
}
public class ImageViewModel
{
public string Path { get; set; }
public string AltText { get; set; }
}
Since HomeViewModel.Image
is nullable, our code models the true nature of this content ๐๐ฝ - it is optional and might not exist.
When we go and render our HomeViewModel
in the Razor View, we can ensure we account for this potentially missing content:
@model HomeViewModel
<h1>@Model.Title</h1>
@if (Model.Image is not null)
{
<img src="@(Model.Image.Path)" alt="@(Model.Image.AltText)" />
}
And with that, we've ensured that our code models the content accurately which means we are handling the business requirements and writing a more robust application ๐ช๐พ.
๐ณ The Problems with Null
What Does Null Really Mean?
Using null
to model missing data is a great first step, but its a slightly awkward ๐ tool sometimes because the C# that represents our content model isn't the only place we will find null
in our codebase. This leads us to the following questions:
- In our code, where is the value
null
found and what does it mean? - If I'm a new developer coming into this code base and I see a method returning a nullable value, do
null
results imply something I should expect? (Should I log/display an error or not?) - Is
null
a normal value that represents the content model (intentionally missing) or is it the result of bad data (unintentionally missing)?
When we think about the core types of C# as a language, null
is not that different from int
, bool
, and string
- they're all primitives and have no inherit meaning to the business concerns of an application.
Relying on too many primitive types to represent our content model could be considered a case of primitive obession.
The only thing that null
tells us is 'There's nothing here' but it doesn't answer the question of 'why?' there's nothing.
Null is a Special Case
As we start to use nullable reference types in our code, we'll see that each time we come across a null
value, we have to treat it as a special case. This is a good thing and helps us prevent NullReferenceException
s at runtime. At the same time, our code becomes cluttered with these 'special cases'.
An example of this can be seen using the code that populates our HomeViewModel
:
var viewModel = new HomeViewModel
{
Title = home.Fields.Title,
Image = cta.HasImage
? new ImageViewModel
{
Path = cta.ImagePath,
AltText = cta.AltText
}
: null
};
We now have a HomeViewModel
with an Image
that is potentially null
.
If we want to set a default AltText
we need to make sure we don't cause a NullReferenceException
, so we guard ๐ก against that condition:
if (viewModel.Image is object &&
string.IsNullOrWhiteSpace(viewModel.Image.AltText))
{
viewModel.Image.AltText = "An image";
}
Any time we want to perform some operations on the nullable property, we need to first ensure its not null
, and for more complex View Models it's not hard to imagine examples with lots and lots of checks like this.
We know this is a problem, but what's its cause ๐คท๐ผโโ๏ธ?
Nullable Reference Types are Unions Types
The core issue is that null
and ImageViewModel
are two completely different types and using nullable reference types is really a way of creating a new type that the C# compiler can reason about. That new type is ImageViewModel or null (sometimes written as ImageViewModel | null
). This is called a Tagged Union Type ๐ง.
Since these two types have different properties and methods (different 'shapes'), namely that null
has no properties or methods, we have to check first to see which type we're actually working with.
Talking about nullable reference types this way shows how glaring of a problem
null
was in C# before nullable reference types were added. The C# compiler treated the two typesImageViewModel
andImageViewModel | null
as the exact same type, despite the problems we've just shown this can cause! Ooof! ๐คฆ๐ปโโ๏ธ
What we'd like to do is change that ImageViewModel | null
type back into a plain old ImageViewModel
, so its not as complex to work with, but also handle scenarios where the content is missing ๐ค.
๐งฉ The Null Object Pattern
Fortunately for us, there's a design pattern that does exactly what we are looking for, and it's called the Null Object Pattern.
The Null Object Pattern lets us get rid of null
, using the custom type we've already defined, while also handling the scenarios where we have no data to populate an instance of this type ๐.
Creating a 'Null' ImageViewModel
Looking at our ImageViewModel
, we could implement the Null Object as follows:
public record ImageViewModel(string Path, string AltText)
{
public static ImageViewModel NullImage { get; } =
new ImageViewModel("", "");
public bool IsEmpy => this == NullImage;
public bool IsNotEmpty => this != NullImage;
}
First, we change from a C# class
to a C# 9.0 record
(which you can read about in the Microsoft Docs) for reasons that will soon become apparent.
We also add a public static
property that is read-only (it only has a getter) and name it NullImage
. This property is the same type as the enclosing class (ImageViewModel
), which means anywhere we need an ImageViewModel
we can use NullImage
.
NullImage
also has some default values for its properties, which means any interactions with it will behave how we would expect (it's not dangerous to operate on the way null
is ๐).
This is a key point of the Null Object Pattern - we want to represent the null
special case using our type, which gets rid of the null
, and we also want our null
case to behave the same as all other cases so we don't have to guard against those scenarios everywhere ๐
.
Notice that by making the NullImage
static
and read-only, it's effectively a Singleton. This is another powerful feature because it lets us check if any ImageViewModel
instance is the NullImage
by performing a comparison.
With C# record types, equality of objects is defined by the equality of the values in the those objects, not by the references to a spot in memory, like with C# classes ๐ค. This means any ImageViewModel
created with an empty string
for both the Path
and AltText
will be 'equal' to the NullImage
.
var house = new ImageViewModel("/path/to/image.jpg", "A House");
Console.WriteLine(house == ImageViewModel.NullImage); // False
var emptyImage = new ImageViewModel("", "");
Console.WriteLine(emptyImage == ImageViewModel.NullImage); // True
Finally we create some convenience properties, IsEmpty
and IsNotEmpty
, on the ImageViewModel
which compare the current instance to NullImage
.
If we need to modify the Title
or AltText
of objects we've created, we can use the C# record with
syntax, which lets us clone an object into a new one while also selectively updating values ๐ง:
var image = new ImageViewModel("/path/to/image.jpg", "A");
var imageUpdated = image with { AltText = "A House" };
Console.WriteLine(image.AltText); // A
Console.WriteLine(imageUpdated.AltText); // A House
There's lots of different ways to write the
ImageViewModel
and add a "Null" object - inheretance with virtual methods, private backing fields - but those details are specific to the business domain, not the Null Object Pattern itself ๐๐ฟ.
Using our 'Null' ImageViewModel
Now let's use our updated ImageViewModel
in the HomeController
:
public class HomeController : Controller
{
private readonly IPageRetriever pageRetriever;
private readonly IPageDataContextRetriever contextRetriever;
// ... Constructor
public ActionResult Index()
{
var home = contextRetriever.Retrieve().Page;
var cta = retriever.Retrieve<CallToAction>(
q => q.WhereEquals("NodeGUID", home.Fields.CTAPage))
.FirstOrDefault();
var viewModel = new HomeViewModel
{
Title = home.Fields.Title,
Image = cta.HasImage
? new ImageViewModel(cta.ImagePath, cta.AltText)
: ImageViewModel.NullImage
};
if (string.IsNullOrWhiteSpace(viewModel.Image.AltText))
{
viewModel.Image = viewModel.Image with
{
AltText = "An Image"
};
}
return View(viewModel);
}
}
We still have a conditional (as a ternary) checking if the Call To Action has an image, but we don't need to special case any interactions with the HomeViewModel.Image
because in the case of missing content, we still have an instance of the ImageViewModel
to work with ๐.
Our Razor View can be updated as well to use the IsNotEmpty
property:
@model HomeViewModel
<h1>@Model.Title</h1>
@if (Model.Image.IsNotEmpty)
{
<img src="@(Model.Image.Path)" alt="@(Model.Image.AltText)" />
}
By updating our code to model missing content in the ImageViewModel
type itself, we remove the need to guard against NullReferenceException
s and we can still conditionally render the content if it was intentionally missing from the Page Type data.
๐ Where the Null Object Pattern Falls Short
I'm a big fan of the Null Object Pattern because it encodes business logic into our types and classes, modeling our special cases but in a way that makes our code more robust and readable ๐.
However, there are still some places where this pattern isn't ideal ๐:
- We have to remember to define the "Null" version of each type and ensure they have consistent naming (ex
NullImage
) across the application. - The convenience methods
IsEmpty
andIsNotEmpty
also need to be implemented. This could be done with a base class but then we need to remember to inherit from it. - Picking default values for
string
properties is pretty easy, but what about other types? Is0
always the best value forint
properties? What aboutfalse
forbool
?- Remember, we'd like to treat our "Null" case and normal case the same in as much of our code as possible.
- Nested objects can get especially verbose since we'll want to continue our Null Object Pattern for all of those types as well.
- The
ImageViewModel
has started to become more complex because it handles both the modeling of the content and the special case of missing content.
The overarching issue is that we've pushed the 'missing content' problem into the class, giving it more responsibility and complexity ๐.
๐คจ Conclusion?
Null reference types are a great way of exposing the hidden โ
null
values that lurk within our code, bringing them out into the light of day ๐ using the C# type system.
They are our first step in battling the dreaded NullReferenceException
and modeling code to match our content!
However, null
isn't a very ergonomic type to work with and doesn't represent our content very well - all it says is "There's nothing here".
The Null Object Pattern lets us get rid of null
completely by representing the 'missing content' in our classes, treating it as a special case that behaves the same as the normal cases.
Unfortunately, the Null Object Pattern comes along with a bit of baggage that could be hard to maintain in larger applications.
Nullable reference types and the Null Object Pattern are great tools to have in our toolbox, but as we will see there are even more ways to model missing content in our Kentico Xperience applications.
In my next post on modeling missing data, we'll see how we can use a simple functional ๐จ๐ฝโ๐ฌ programming pattern called the 'Maybe' (or 'Option') to pull the representation of missing content out of our class, which simplifies it, while still avoiding a Union Type with null
๐.
As always, thanks for reading ๐!
References
- Kentico Xperience Design Patterns: Modeling Missing Data with Nullable Reference Types
- The Special Case (Null Object)
- C# Nullable Reference Types (Microsoft Docs)
- Tagged Union Types
- C# Null Object Pattern
- The Singleton Pattern
- C# 9.0 Record Types
We've put together a list over on Kentico's GitHub account of developer resources. Go check it out!
If you are looking for additional Kentico content, checkout the Kentico or Xperience tags here on DEV.
Or my Kentico Xperience blog series, like:
Top comments (0)