I don't claim that this is production-ready, but you can hack it a bit by introducing an extension method such as ThenByCustom which just saves the keySelector and replaces it with x => x. The actual keySelector is used only when comparing values that have the same primary key in a custom comparer.
This is the idea:
public static class Extensions {
public static IOrderedEnumerable<TSource> ThenByCustom<TSource, TKey>(
this IOrderedEnumerable<TSource> source,
Func<TSource, TKey> keySelector) {
var customComparer = new KeyComparer<TSource, TKey>(keySelector);
return source.ThenBy(x => x, customComparer);
}
private class KeyComparer<TSource, TKey> : IComparer<TSource> {
private readonly Func<TSource, TKey> _keySelector;
private readonly IComparer<TKey> _keyComparer;
public KeyComparer(Func<TSource, TKey> keySelector) {
_keySelector = keySelector;
_keyComparer = Comparer<TKey>.Default;
}
public int Compare(TSource x, TSource y) {
return _keyComparer.Compare(_keySelector(x), _keySelector(y));
}
}
}
Your (slightly modified) example then:
static void Main() {
var data = new[]
{
new { Name = "Alice", Age = 30 },
new { Name = "Charlie", Age = 25 },
new { Name = "Bob", Age = 25 },
};
var sorted = data
.OrderBy(x => Tracer("First criterion", x.Age))
.ThenByCustom(x => Tracer("Second criterion", x.Name));
foreach (var item in sorted) {
Console.WriteLine($"{item.Name} - {item.Age}");
}
}
static T Tracer<T>(string label, T value) {
Console.WriteLine($"Computing {label} key: {value}");
return value;
}
outputs
Computing First criterion key: 30
Computing First criterion key: 25
Computing First criterion key: 25
Computing Second criterion key: Charlie
Computing Second criterion key: Bob
Bob - 25
Charlie - 25
Alice - 30
if you make the age different, you'd see no Tracer calls for the second criterion.