In DDD most objects can be categorised as either value types or entities. Value types being objects where there is not one identifier, but simply a collection of related properties; entities being where the ID of the object is the ultimate identifier and all other properties are attributes of this entity. For me, the desired functionality in terms of equality comparisons is that entities are "Equal" when they have the same ID.. Value types are equal when they have matching "composite key" - i.e. all the properties of the object. To model this I have created a base class for enforcing value equality and a more specialised base for an entity:

public abstract class ValueEqualityObject<T> : IEquatable<T>
{
    public sealed override bool Equals(object obj)
    {
        if (obj is null)
            return false;

        if (ReferenceEquals(obj, this))
            return true;

        if (GetType() != obj.GetType())
            return false;

        return Equals((T)obj);
    }

    public sealed override int GetHashCode()
    {
        return TupleBasedHashCode();
    }

    public abstract bool Equals(T other);

    protected abstract int TupleBasedHashCode();
}

public abstract class Entity<TId> : ValueEqualityObject<Entity<TId>>
    {
        protected Entity(TId id)
        {
            Id = id;
        }

        public TId Id { get; }

        protected override int TupleBasedHashCode()
        {
            return (Id).GetHashCode();
        }

        public override bool Equals(Entity<TId> other)
        {
            return other != null 
                && other.Id.Equals(Id);
        }
    }
Now for each domain type I can choose which base to inherit from. For entities I simply define the ID, for value types I am prompted to define a TupleBasedHashCode and Equals method. The TupleBasedHashCode is a reminder to myself on a my preferred strategy for GetHashCode which is use the built-in Tuple implementation :)