Clean Catan Serialization
Tue Sep 09 2025
Using Json Serialization as an Infrastructure concern to avoid polluting my Domain layer.
Written by: Wesley Witsken

Serialization in Redis with Clean Architecture for My Catan App
One of the big design goals for my Catan realtime application has been keeping my domain layer clean. I don’t want to sprinkle [JsonPropertyName]
attributes all over my entities, or make compromises in my domain just to appease the serializer. My domain should reflect the game’s rules and invariants—not how data happens to be stored in Redis.
That means my infrastructure layer has to do the heavy lifting when it comes to serialization. Let me walk through a couple of the main challenges I hit, and how I solved them.
1. Immutable Collections in the Domain
In my domain, entities expose read-only collections. For example, a Game has a list of players, but I don’t want arbitrary code to be able to mutate that list. Only the aggregate itself should decide when a new player gets added, or a road is placed.
C# makes this pattern pretty natural: I use private backing lists and expose public read-only copies (like IReadOnlyList<T>
).
private readonly List<Player> _players = [];
public IReadonlyList<Player> Players => [.. _players];
The problem is that JSON serialization doesn’t play nicely with immutable collections out of the box. If you deserialize into a read-only property, it fails.
The fix?
Infrastructure-level JsonSerializerOptions
. By configuring converters in the infrastructure, I can deserialize directly into the private backing fields without changing my domain code. The domain stays clean and focused on business rules, while infrastructure quietly handles the messy details of JSON.
public static class JsonOptions
{
public static readonly JsonSerializerOptions Default = Create();
private static JsonSerializerOptions Create()
{
var opts = new JsonSerializerOptions
{
IncludeFields = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.Strict
};
return opts;
}
}
2. Strongly Typed IDs
Another pattern I lean on heavily is strongly typed IDs. Instead of passing around raw Guids or ints, I define little wrappers like GameId
, PlayerId
, and BoardId
.
This is one of those “small investment, big payoff” choices. If a method takes a GameId, I can’t accidentally pass a PlayerId. The compiler enforces correctness, and I save myself from a whole class of errors that only show up at runtime otherwise.
Of course, this introduces another serialization problem. By default, System.Text.Json doesn’t know what to do with GameId : BaseId<Guid>
. That’s where my custom converter factory comes in:
public sealed class BaseIdJsonConverterFactory : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert)
=> typeof(BaseId).IsAssignableFrom(typeToConvert);
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
// Walk up the hierarchy to find BaseId<TValue>
var baseType = typeToConvert;
while (baseType is not null && baseType != typeof(object))
{
if (baseType.IsGenericType && baseType.GetGenericTypeDefinition() == typeof(BaseId<>))
{
var valueType = baseType.GetGenericArguments()[0];
var converterType = typeof(BaseIdJsonConverter<,>)
.MakeGenericType(typeToConvert, valueType);
return (JsonConverter)Activator.CreateInstance(converterType)!;
}
baseType = baseType.BaseType!;
}
throw new InvalidOperationException($"Type {typeToConvert} does not inherit from BaseId<T>.");
}
}
With this in place, serialization and deserialization work seamlessly: the IDs round-trip through Redis as their underlying Guid values, while my domain still gets the benefits of type safety.
Wiring it all up looks like this:
public static class JsonOptions
{
// ...
private static JsonSerializerOptions Create()
{
// ...
opts.Converters.Add(new JsonStringEnumConverter());
opts.Converters.Add(new BaseIdJsonConverterFactory());
return opts;
}
}
And inside my base Redis Repository pattern, I can use it simply, like so:
var serialized = JsonSerializer.Serialize(aggregateEntity, JsonOptions.Default);
var deserialized = JsonSerializer.Deserialize<TAgg>(json!, JsonOptions.Default);
3. Keeping the Domain Free of Infrastructure Concerns
The common theme here is: infrastructure adapts to the domain, not the other way around.
- My entities don’t carry
[JsonPropertyName]
or[JsonConstructor]
attributes. - I don’t expose public setters on lists just so JSON can populate them.
- I don’t collapse back to plain Guids because it’s “easier to serialize.”
Instead, I configure JsonSerializerOptions
and write custom converters in the infrastructure layer. That way, Redis can happily persist and hydrate my aggregates, while my domain stays true to the rules of the game.
Wrapping Up
Serialization is one of those behind-the-scenes details that can quietly infect your architecture if you’re not careful. By pushing it down into the infrastructure layer, I get the best of both worlds:
A pure domain model with immutability, strongly typed IDs, and no JSON attributes.
A flexible infrastructure layer that knows how to translate domain objects to/from Redis.
It’s been worth the upfront investment because now I can focus on building the actual game rules—without fighting my serializer every step of the way.