So I ended up biting the bullet on building the Mongo-specific junk to make a custom class work for this. So here is my drop-in replacement for ObjectId:
public struct DocumentId<T> : IEquatable<DocumentId<T>>
{
static DocumentId()
{
BsonSerializer.RegisterSerializer(typeof(DocumentId<T>), DocumentIdSerializer<T>.Instance);
BsonSerializer.RegisterIdGenerator(typeof(DocumentId<T>), DocumentIdGenerator<T>.Instance);
}
public static readonly DocumentId<T> Empty = new DocumentId<T>(ObjectId.Empty);
public readonly ObjectId Value;
public DocumentId(ObjectId value)
{
Value = value;
}
public static DocumentId<T> GenerateNewId()
{
return new DocumentId<T>(ObjectId.GenerateNewId());
}
public static DocumentId<T> Parse(string value)
{
return new DocumentId<T>(ObjectId.Parse(value));
}
public bool Equals(DocumentId<T> other)
{
return Value.Equals(other.Value);
}
public override bool Equals(object obj)
{
if (ReferenceEquals(null, obj)) return false;
return obj is DocumentId<T> && Equals((DocumentId<T>)obj);
}
public static bool operator ==(DocumentId<T> left, DocumentId<T> right)
{
return left.Value == right.Value;
}
public static bool operator !=(DocumentId<T> left, DocumentId<T> right)
{
return left.Value != right.Value;
}
public override int GetHashCode()
{
return Value.GetHashCode();
}
public override string ToString()
{
return Value.ToString();
}
}
public class DocumentIdSerializer<T> : StructSerializerBase<DocumentId<T>>
{
public static readonly DocumentIdSerializer<T> Instance = new DocumentIdSerializer<T>();
public override DocumentId<T> Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
{
return new DocumentId<T>(context.Reader.ReadObjectId());
}
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, DocumentId<T> value)
{
context.Writer.WriteObjectId(value.Value);
}
}
public class DocumentIdGenerator<T> : IIdGenerator
{
public static readonly DocumentIdGenerator<T> Instance = new DocumentIdGenerator<T>();
public object GenerateId(object container, object document)
{
return DocumentId<T>.GenerateNewId();
}
public bool IsEmpty(object id)
{
var docId = id as DocumentId<T>? ?? DocumentId<T>.Empty;
return docId.Equals(DocumentId<T>.Empty);
}
}
The type parameter T can be anything; it is never used. It should be the type of your object, like so:
public class Cupcake {
[BsonId]
public DocumentId<Cupcake> Id { get; set; }
// ...
}
This way, your Flavor class has an Id of type DocumentId<Flavor> and your Color class has an Id of type DocumentId<Color>, and never shall the two be interchanged. Now I can create a CupcakeRepository with the following unambiguous methods as well:
public interface ICupcakeRepository {
IEnumerable<Cupcake> Find(DocumentId<Flavor> flavorId);
IEnumerable<Cupcake> Find(DocumentId<Color> colorId);
}
This should be safe with existing data as well because the serialized representation is exactly the same, just an ObjectId("1234567890abcef123456789").
cupcakeobjects byflavorId". So you call cupcakeRepository.Find(ObjectId flavorId) because IntelliSense very helpfully suggested it and you didn't read the argument name. Hey, it compiles! Even your unit tests pass, because you mocked out the method according to what you thought it did.cupcakeRepository.Find(ObjectId colorId). I actually forgot which one I was trying to use between typing those two sentences. See, this isn't just about some other developer, this is to protect me from myself.