Search code examples
c#.netoopserialization

Make sure an object with a particular key is statically created once


I'd like to implement the following in C#:

  • A class with a property of Key which will be used to uniquely identify an object
  • A set of pre-defined static objects
using System.Text.Json;

public sealed class Test
{
    private static readonly HashSet<string> _existingKeys = [];

    public static readonly Test One = new("one");
    public static readonly Test Two = new("two");

    public Test(string key)
    {
        if (!_existingKeys.Add(key))
        {
            throw new ArgumentException($"Key '{key}' is already in use.");
        }

        Key = key;
    }

    public string Key { get; }
}

// Example usage
class Program
{
    static void Main()
    {
        try
        {
            var three = new Test("one");
        }
        catch (ArgumentException ex)
        {
            Console.WriteLine(ex.Message); // Output: Key 'one' is already in use.
        }

        var one = JsonSerializer.Serialize(Test.One);
        try
        {
            var deserialized = JsonSerializer.Deserialize<Test>(one);
        }
        catch (ArgumentException ex)
        {
            Console.WriteLine(ex.Message); // Output: Key 'one' is already in use.
        }
    }
}

The issue I'm facing is deserialization because it attempts to instantiate an object that may already exist in memory. Is there a workaround to this? I'd like to avoid manually maintaining the key map because some classes will have a lot of pre-defined objects and it's easy to miss an object. Additionally, there are use cases where the objects need to be defined outside the definition class in another assembly.

Note that I only care about uniqueness for user-defined objects.

public static readonly Test One = new("one");

Things like deserialization / reflection are outside the scope.


Solution

  • This looks like a variation of the Flyweight pattern, so you may want to edit the class so that it instead looks like this:

    [JsonConverter(typeof(TestJsonConverter))]
    public sealed class Test
    {
        private static readonly Dictionary<string, Test> _existingObjects = new();
    
        public static readonly Test One = Create("one");
        public static readonly Test Two = Create("two");
    
        private Test(string key)
        {
            Key = key;
        }
    
        public static Test Create(string key)
        {
            if (_existingObjects.TryGetValue(key, out var existing))
                return existing;
    
            var newObject = new Test(key);
            _existingObjects[key] = newObject;
            return newObject;
        }
    
        public string Key { get; }
    }
    

    Notice that the constructor is now private, so that all client code instead has to use the Create function. The static fields One and Two also do this.

    Instead of maintaining a set of known IDs, it instead maintains a hashmap of known objects.

    Creating a new object by key now never fails, but instead just looks in the hashmap after known objects.

    JSON serialization is the tricky part, but as the comment by shingo suggests you can use a custom converter:

    internal class TestJsonConverter : JsonConverter<Test>
    {
        public override Test? Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options)
        {
            var key = reader.GetString();
            if (key is null)
                return null;
            return Test.Create(key);
        }
    
        public override void Write(
            Utf8JsonWriter writer,
            Test value,
            JsonSerializerOptions options)
        {
            writer.WriteStringValue(value.Key);
        }
    }
    

    This one is just a proof of concept, and you may want to modify it according to your particular requirements.

    These tests now pass:

    [Fact]
    public void CreateOneByKey()
    {
        var actual = Test.Create("one");
        Assert.Equal(Test.One, actual);
    }
    
    [Fact]
    public void RoundTripOne()
    {
        var one = JsonSerializer.Serialize(Test.One);
        var actual = JsonSerializer.Deserialize<Test>(one);
        Assert.Equal(Test.One, actual);
    }