Imagine the following domain object for an Order:
public class Order
{
private HashSet<OrderLine> Lines { get; init; } = [];
private Order() { }
public required Guid Id { get; init; }
public required DateTimeOffset Created { get; init; }
public IReadOnlySet<OrderLine> GetLines()
{
return Lines;
}
public void AddOrderLine(OrderLine orderLine)
{
Lines.Add(orderLine);
}
public static Result<Order> Create(Guid id)
{
if(id.Equals(Guid.Empty))
{
return Result.Failure<Order>(
new Error(ErrorType.NullOrEmpty, $"{nameof(id)} was null or empty"));
}
return Result.Success(new Order { Id = id, Created = TimeProvider.System.GetUtcNow() });
}
}
Notice that we expose the order lines as an IReadOnlySet<OrderLines>
via the GetLines
method. If we were to expose the HashSet<OrderLine>
directly, it would be possible to modify its content outside of our domain object.
public record OrderLine
{
private OrderLine() { }
public required Guid Id { get; init; }
public required string Name { get; init; }
public required decimal Price { get; init; }
public static Result<OrderLine> Create(Guid id, string name, decimal price)
{
if(id.Equals(Guid.Empty))
{
return Result.Failure<OrderLine>(
new Error(ErrorType.NullOrEmpty, $"{nameof(id)} was null or empty"));
}
if(string.IsNullOrWhiteSpace(name))
{
return Result.Failure<OrderLine>(
new Error(ErrorType.NullOrEmpty, $"{nameof(name)} was null or empty"));
}
if(price < 1)
{
return Result.Failure<OrderLine>(
new Error(ErrorType.MinimumValue, $"{nameof(price)} needs to be greater than 0"));
}
return Result.Success(new OrderLine { Id = id, Name = name, Price = price });
}
}
Our domain objects needs to be:
- "Immutable"
I wrote immutable in quotes because they are "immutable-ish":
A domain object should be immutable to ensure consistency, reliability, and simplicity. When objects are immutable, their state cannot be changed after creation, preventing unintended side effects or bugs that arise from unexpected modifications.
Our definition of immutability in this post is that it should not be possible to change the properties directly. You must use the appropriate methods, like AddOrderLine
in our Order
example above.
- Only possible to create via the
Create
method - that's why we have a private default constructor.
// This is not possible — it's not possible to call new since the constructor is private
var order = new Order(...);
// This is how we can create an order
var order = Order.Create(Guid.NewGuid());
- Possible to persist
This post will focus on the last bullet point: possible to persist.
What I mean by this is that it should be possible to store our domain object, as-is, in a database or any other type of storage. We will NOT allow any kind of attributes or other "third-party stuff" to leak into our domain object.
For this post, we will assume that we're storing our domain object in a key-value store as JSON.
Serialization
I created a new class, OrderSerializer
, to better encapsulate all the serialization/deserialization logic.
public static class OrderSerializer
{
private static readonly JsonSerializerOptions JsonSerializerOptions;
static OrderSerializer()
{
JsonSerializerOptions = new JsonSerializerOptions
{
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
public static string Serialize(Order order)
{
return JsonSerializer.Serialize(order, JsonSerializerOptions);
}
public static Result<Order> Deserialize(string orderJson)
{
try
{
var result = JsonSerializer.Deserialize<Order>(orderJson, JsonSerializerOptions);
return result is not null
? Result.Success(result)
: Result.Failure<Order>(
new Error(ErrorType.Deserialization, "Order was null after deserialization"));
}
catch(Exception e)
{
return Result.Failure<Order>(new Error(ErrorType.Deserialization, e.Message));
}
}
}
Let's write a test to ensure that we can serialize the order correctly.
[Fact]
public void CanSerializeAnOrder()
{
var order = Order.Create(Guid.NewGuid()).Data;
var orderLine = OrderLine.Create(Guid.NewGuid(), "iPhone 16 Pro", 14990).Data;
order.AddOrderLine(orderLine);
var serialized = OrderSerializer.Serialize(order);
serialized.ShouldNotBeNull();
var jsonDocument = JsonDocument.Parse(serialized);
jsonDocument.RootElement.TryGetProperty("id", out var idProperty).ShouldBeTrue();
idProperty.GetGuid().ShouldBe(order.Id);
jsonDocument.RootElement.TryGetProperty("created", out var createdProperty).ShouldBeTrue();
createdProperty.GetDateTimeOffset().ShouldBe(order.Created);
jsonDocument.RootElement.TryGetProperty("lines", out var linesProperty).ShouldBeTrue();
var lines = linesProperty.EnumerateArray().ToList();
lines.Count.ShouldBe(1);
}
...it turns out that the serialization isn't as straightforward as we hoped. The test fails with the following error:
jsonDocument.RootElement.TryGetProperty("lines", out var linesProperty)
should be
True
but was
False
Let's have a look at the produced json:
{
"id":"85799020-2642-4eb3-9449-03e66aa5e1d1",
"created":"2024-12-13T10:02:41.024379+00:00"
}
Where are our order lines?
Since our Lines
property is private
, it won't be included in the serialized output by default. Let's fix that.
By adding the [JsonInclude]
attribute on our Lines
property like this...
[JsonInclude]
private HashSet<OrderLine> Lines { get; } = [];
...the following json is produced:
{
"lines" : [ {
"id" : "04cecd46-b119-4ca1-8549-b6e8f84279b0",
"name" : "iPhone 16 Pro",
"price" : 14990
} ],
"id" : "cf16b155-45e9-40b7-a0dc-856437d30f3f",
"created" : "2024-12-13T10:05:31.009223+00:00"
}
Now, for some people, this is a good enough solution. But remember when I said We will NOT allow any kind of attributes or other "third-party stuff" to leak into our domain object?
Let’s see if we can fix this without adding the attribute.
EF Core has great support for customizing how properties has should be handled when persisting them in the database. They have a fluent api so that you don't need to add attributes to your models.
Turns out that System.Text.Json has something similar called JsonTypeInfo.
Include private properties
First, let's update our JsonSerializerOptions
to look like this:
JsonSerializerOptions = new JsonSerializerOptions
{
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { OrderJsonTypeInfoModifier }
}
};
We've added a new Modifier
called OrderJsonTypeInfoModifier
, which looks like this:
private static void OrderJsonTypeInfoModifier(JsonTypeInfo jsonTypeInfo)
{
if (jsonTypeInfo.Type != typeof(Order))
{
return;
}
foreach (var property in jsonTypeInfo.Type.GetProperties(BindingFlags.Instance | BindingFlags.NonPublic))
{
var jsonPropertyInfo = jsonTypeInfo.CreateJsonPropertyInfo(property.PropertyType, property.Name);
jsonPropertyInfo.Get = property.GetValue;
jsonPropertyInfo.Set = property.SetValue;
jsonTypeInfo.Properties.Add(jsonPropertyInfo);
}
}
With reflection, we're getting all non-public properties and adding them to the Properties
collection on the JsonTypeInfo
. This means that our private property Lines
will now be included when we serialize our Order
.
When running the test again, it still fails. The following json is produced:
{
"id" : "e5643ca0-aa82-4f49-bbdd-50ae3d569e6e",
"created" : "2024-12-13T10:13:35.631702+00:00",
"Lines" : [ {
"id" : "d8498372-5ad7-47f4-bc29-0d920d8ce5d1",
"name" : "iPhone 16 Pro",
"price" : 14990
} ]
}
The problem is that the Lines
property name isn't camel-cased. The only thing left to fix is the name of the property, we don’t want it to start with an uppercase.
// Naive camel-case method
internal static class StringExtensions
{
internal static string ToCamelCase(this string str)
{
if(string.IsNullOrWhiteSpace(str))
{
return str;
}
if(str.Length == 1)
{
return str.ToLower();
}
return char.ToLower(str[0]) + str[1..];
}
}
private static void OrderJsonTypeInfoModifier(JsonTypeInfo jsonTypeInfo)
{
if(jsonTypeInfo.Type != typeof(Order))
{
return;
}
foreach(var property in jsonTypeInfo.Type.GetProperties(BindingFlags.Instance | BindingFlags.NonPublic))
{
// We're now using the ToCamelCase extension when setting the name
var jsonPropertyInfo =
jsonTypeInfo.CreateJsonPropertyInfo(property.PropertyType, property.Name.ToCamelCase());
jsonPropertyInfo.Get = property.GetValue;
jsonPropertyInfo.Set = property.SetValue;
jsonTypeInfo.Properties.Add(jsonPropertyInfo);
}
}
Now, when we run the test again, it passes, and the following JSON is produced:
{
"id" : "ae135b77-c706-46c5-b480-84ecb1b94cfe",
"created" : "2024-12-13T10:19:38.121274+00:00",
"lines" : [ {
"id" : "b76be9b8-991e-4ac0-a2c8-cd5581863782",
"name" : "iPhone 16 Pro",
"price" : 14990
} ]
}
We can now correctly serialize an Order
.
Deserialization
Let's write a test and see if we can deserialize an Order
that has been serialized by our OrderSerializer
.
[Fact]
public void CanDeserializeASerializedOrder()
{
var order = Order.Create(Guid.NewGuid()).Data;
var orderLine = OrderLine.Create(Guid.NewGuid(), "iPhone 16 Pro", 14990).Data;
order.AddOrderLine(orderLine);
var serialized = OrderSerializer.Serialize(order);
var result = OrderSerializer.Deserialize(serialized);
result.Succeeded.ShouldBeTrue(result.Error?.ErrorMessage);
result.Data.Id.ShouldBe(order.Id);
result.Data.Created.ShouldBe(order.Created);
var orderLines = result.Data.GetLines();
orderLines.Count.ShouldBe(1);
orderLines.ShouldContain(orderLine);
}
The test fails with the following error:
Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'JOS.ImmutableSerialization.Order'
We do have a parameterless constructor. But it’s private, because we don’t want people to be able to create new instances without using the Create
method.
Let’s start by adding the [JsonConstructor]
attribute to our constructor and see if it helps.
It solves the problem for the Order
object, but now we get a similar error for our OrderLine
object:
Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'JOS.ImmutableSerialization.OrderLine'.
If we add the [JsonConstructor]
attribute to the private OrderLine
constructor, the test passes.
But is it possible to make the test pass without having to add any attributes to our domain object?
Indeed, it is.
When using JsonTypeInfo
, it's possible to specify how an instance should be created. The serializer needs to be able to create an instance before populating the properties.
A private constructor is not considered, by default.
Let's update our OrderJsonTypeInfoModifier
a bit:
private static void OrderJsonTypeInfoModifier(JsonTypeInfo jsonTypeInfo)
{
if(jsonTypeInfo.Type != typeof(Order))
{
return;
}
foreach(var property in jsonTypeInfo.Type.GetProperties(BindingFlags.Instance | BindingFlags.NonPublic))
{
var jsonPropertyInfo =
jsonTypeInfo.CreateJsonPropertyInfo(property.PropertyType, property.Name.ToCamelCase());
jsonPropertyInfo.Get = property.GetValue;
jsonPropertyInfo.Set = property.SetValue;
jsonTypeInfo.Properties.Add(jsonPropertyInfo);
}
CreateFactoryMethodForObject(jsonTypeInfo);
}
private static void CreateFactoryMethodForObject(JsonTypeInfo typeInfo)
{
var constructor = typeInfo.Type.GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic, []);
typeInfo.CreateObject = () => constructor!.Invoke(null);
}
The JsonTypeInfo
has a property called CreateObject
. By using reflection we can retrieve our private constructor and then populate the CreateObject
property with a Func
that invokes the constructor.
We need to do this for both the Order
and OrderLine
object. Our complete OrderSerializer
now looks like this:
public static class OrderSerializer
{
private static readonly JsonSerializerOptions JsonSerializerOptions;
static OrderSerializer()
{
JsonSerializerOptions = new JsonSerializerOptions
{
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { OrderJsonTypeInfoModifier, OrderLineJsonTypeInfoModifier }
}
};
}
public static string Serialize(Order order)
{
return JsonSerializer.Serialize(order, JsonSerializerOptions);
}
public static Result<Order> Deserialize(string orderJson)
{
try
{
var result = JsonSerializer.Deserialize<Order>(orderJson, JsonSerializerOptions);
return result is not null
? Result.Success(result)
: Result.Failure<Order>(
new Error(ErrorType.Deserialization, "Order was null after deserialization"));
}
catch(Exception e)
{
return Result.Failure<Order>(new Error(ErrorType.Deserialization, e.Message));
}
}
private static void OrderJsonTypeInfoModifier(JsonTypeInfo jsonTypeInfo)
{
if(jsonTypeInfo.Type != typeof(Order))
{
return;
}
foreach(var property in jsonTypeInfo.Type.GetProperties(BindingFlags.Instance | BindingFlags.NonPublic))
{
var jsonPropertyInfo =
jsonTypeInfo.CreateJsonPropertyInfo(property.PropertyType, property.Name.ToCamelCase());
jsonPropertyInfo.Get = property.GetValue;
jsonPropertyInfo.Set = property.SetValue;
jsonTypeInfo.Properties.Add(jsonPropertyInfo);
}
CreateFactoryMethodForObject(jsonTypeInfo);
}
private static void OrderLineJsonTypeInfoModifier(JsonTypeInfo jsonTypeInfo)
{
if(jsonTypeInfo.Type != typeof(OrderLine))
{
return;
}
CreateFactoryMethodForObject(jsonTypeInfo);
}
private static void CreateFactoryMethodForObject(JsonTypeInfo typeInfo)
{
var constructor = typeInfo.Type.GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic, []);
typeInfo.CreateObject = () => constructor!.Invoke(null);
}
}
And that’s it—serialization and deserialization of our immutable objects now work perfectly fine.