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. Collections in the Domain
In my domain, entities expose collections like players, roads, or settlements. Only the aggregate should decide when these collections get mutated. External code shouldn’t be able to reach in and arbitrarily add things.
My original plan was to expose IReadOnlyList<T> with private backing lists and keep them immutable from the outside. Unfortunately, JSON serialization doesn’t play nicely with immutable collections out of the box—deserialization simply fails.
So I landed on a compromise: use normal List<T> properties, but protect them with aggregate logic. To help deserialization, I annotate constructors with [JsonConstructor]. That way, the serializer knows exactly how to rebuild my objects without me exposing public setters everywhere.
public class Game : Entity<GameId>
{
private readonly List<Player> _players;
[JsonConstructor]
public Game(GameId id, List<Player> players)
{
Id = id;
_players = players;
}
public IReadOnlyList<Player> Players => _players.AsReadOnly();
public void AddPlayer(Player player)
{
// Business rules enforce valid adds
_players.Add(player);
}
}
This keeps JSON deserialization happy while still letting the domain layer enforce its invariants.
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
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]attributes. - I use
[JsonConstructor]only to make deserialization possible, not to change my modeling choices. - 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 (with the occasional [JsonConstructor] to help the serializer out), I get the best of both worlds:
A pure domain model with strongly typed IDs and guarded collections. 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.