An audit row is useful only when a reader can quickly determine what changed, whether the change is plausible, and whether deeper investigation is necessary. A reflection-based difference generator can undermine that goal when it mistakes a collection for an ordinary object and serializes an entire object graph into a text field. The result is not merely verbose. It can tell the wrong story. If an update request omits a navigation collection, a naïve comparison may present that omission as if every child item had been removed.

This guide addresses a narrow and practical design problem in C# and ASP.NET Core applications: identify collection-shaped values through IEnumerable rather than the nongeneric ICollection, then produce a bounded summary that reports a count or a change state instead of expanding every item. The approach separates three responsibilities. Classification decides whether a value is a sequence. Comparison determines whether it is unchanged, has a different count, has changed content at the same count, or was omitted by the input contract. Presentation turns that result into a short, stable sentence. Detailed investigation remains available through structured before-and-after snapshots rather than through reverse parsing of display text.

Why nongeneric ICollection misses common generic collections

System.Collections.ICollection is the nongeneric collection interface. It is not interchangeable with System.Collections.Generic.ICollection. The Microsoft Learn type page for HashSet lists ICollection, IEnumerable, and nongeneric IEnumerable among its implemented interfaces, but it does not list nongeneric ICollection: https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.hashset-1?view=net-10.0 . Consequently, a runtime HashSet can return false for value is ICollection even though it is plainly a collection.

This defect can hide in a test suite because some other containers implement a broader interface set. A test that uses only arrays or one familiar list type may make the classifier appear correct. An ORM navigation property backed by HashSet then reaches a different branch in production. A generic audit component should not infer collection semantics from one concrete container. It should start with the behavior it needs: the ability to enumerate values.

Microsoft Learn defines nongeneric IEnumerable as exposing an enumerator that supports simple iteration over a collection: https://learn.microsoft.com/en-us/dotnet/api/system.collections.ienumerable?view=net-10.0 . That makes it an effective broad classifier, with one essential exclusion: string also implements IEnumerable. Treating text as a sequence would compare characters and produce meaningless collection counts.

using System.Collections;

static bool IsSequence(object? value) =>
    value is IEnumerable && value is not string;

Some components need a narrower capability. If a component specifically requires the semantics of ICollection, it can inspect closed generic interfaces. Microsoft documents that Type.GetInterfaces() returns every interface implemented or inherited by the current type: https://learn.microsoft.com/en-us/dotnet/api/system.type.getinterfaces?view=net-10.0 . Check definitions with Any; do not depend on interface order.

static bool ImplementsClosedGeneric(Type type, Type openGeneric) =>
    type.GetInterfaces().Any(i =>
        i.IsGenericType &&
        i.GetGenericTypeDefinition() == openGeneric);

Keep classification free of side effects. A classifier should not enumerate the value, trigger deferred loading, serialize an object graph, or change tracking state. That single responsibility makes it easy to test many container types and prevents diagnostic code from changing the behavior it is supposed to observe.

A readable audit summary needs bounded information

The central presentation rule is simple: do not put the complete collection into the human-readable summary. The first questions on a list page are usually whether the field changed, how the count changed, and whether content changed while the count remained stable. A separate detail view can compute item-level changes from structured snapshots when a reader asks for them.

A compact policy can use the following outputs:

Situation Suggested summary
Both sequences are equal Omit the field
Counts differ Items: 24 => 22
Counts match but content differs Items: content changed (24)
The input explicitly marks the sequence as omitted Omit the field and retain a diagnostic state
A scalar changes Status: Pending => Approved

Bounded output does not mean discarding evidence. It means assigning different jobs to different representations. A summary supports scanning, filtering, notifications, and search results. Structured before-and-after snapshots support investigation. Trying to reconstruct structure from old summary text is fragile because a value may contain markup, punctuation, delimiters, or formatting that resembles field boundaries. A snapshot preserves hierarchy and types, so a difference viewer can handle those values without guessing how display text was assembled.

The backend should enforce limits before producing the text. If a product decision allows a small sample of items, configure a maximum item count, a maximum length per rendered item, and a maximum total summary length. Once the limit is reached, report that more items exist. Do not create an enormous string and expect CSS to hide the excess. That still spends allocation time, transport bandwidth, storage, and rendering work before the browser truncates anything.

A bounded design also improves operational review. Repeated scalar changes remain visible instead of being buried below an object graph. Search indexes receive meaningful field names and state transitions rather than thousands of incidental words. Reviewers can compare neighboring rows consistently because a given change category uses the same compact format every time.

Distinguish an omitted sequence from an explicit clear

An empty collection does not carry enough information to tell whether the caller omitted the field or intentionally requested removal of every item. If both actions bind to the same default empty container, the difference generator lacks a crucial part of the command. Treating every empty input as a clear creates false removal reports. Ignoring every empty input loses real clear operations. The durable solution is to make intent part of the update contract instead of asking reflection to guess it.

A command can include a mode such as Omitted, Replace, or Clear. Another option is a patch-shaped contract that preserves whether a property appeared in the request. Either design gives the audit layer explicit semantics. The resulting summary is then a record of a command and its applied result, not an inference based only on container contents.

A legacy endpoint may not be ready for that change. A conservative transition rule can temporarily ignore an empty incoming navigation sequence when the stored sequence is nonempty and the endpoint normally omits navigation data. That choice should be visible through a counter and covered by tests. It is a migration policy, not a universal rule. The team should then move the endpoint toward an explicit command contract so a real clear operation remains representable.

The following sample makes omission an explicit argument. A production model should prefer an enum because it documents more states and avoids boolean ambiguity.

static string? SummarizeSequence(
    IEnumerable before,
    IEnumerable after,
    bool incomingWasOmitted)
{
    var left = before.Cast()
        .Select(x => x?.ToString() ?? "").ToArray();
    var right = after.Cast()
        .Select(x => x?.ToString() ?? "").ToArray();

    if (incomingWasOmitted && right.Length == 0 && left.Length > 0)
        return null;
    if (left.SequenceEqual(right))
        return null;
    if (left.Length != right.Length)
        return $"Items: {left.Length} => {right.Length}";
    return $"Items: content changed ({left.Length})";
}

This sample materializes sequences to keep the reproduction deterministic. Application code must consider whether a sequence is single-use, backed by a query, or expensive to enumerate. The best input for an audit comparer is usually an already-loaded, bounded snapshot DTO, not an arbitrary query object. This keeps data access timing and comparison cost explicit.

Production equality should also be semantic. ToString() is useful for a tiny demonstration, but it is not a durable item identity. Compare stable identifiers and selected auditable fields. Sort by a stable key if source ordering is not meaningful. Otherwise the same set of items can appear changed merely because enumeration order differs.

Implement the collection summary in three steps

  1. Build a classification matrix. List the runtime shapes that the application can pass to the comparer: HashSet, List, arrays, custom containers, query results, and strings. Record the expected sequence classification for each. Include at least one type that demonstrates the split between generic ICollection and nongeneric ICollection.
  2. Build a semantic comparison layer. Handle omitted input, exact equality, count changes, and same-count content changes before formatting scalar values. Compare stable identity and selected fields. Do not serialize an entire graph here, and do not parse earlier display text to recover structure.
  3. Build bounded presentation and regression checks. Configure field, item, and text limits. Keep snapshots available to a detail view. Assert runtime classification, exact summary markers, omission behavior, and maximum output size in automated checks.

A shadow period can reduce migration risk. Run the old and new algorithms against the same synthetic or approved test inputs, but record only bounded diagnostics for the new algorithm. Compare categories and frequencies before changing the user interface. Historical audit rows normally should remain unchanged because they are themselves evidence of what the system recorded at that time. Improve historical readability through a snapshot difference viewer rather than rewriting old rows.

The three-step workflow also creates clear ownership. Domain code defines command intent. The comparer defines semantic change. The presenter defines the reading format. When those concerns are mixed into one reflection loop, every fix risks changing data access, equality, and output at once.

Reflection, nullable values, and stable formatting

Collection detection is one part of a reflection-based comparer. Nullable scalar properties need deliberate handling as well. Resolve Nullable before selecting a formatter; otherwise a generic path builder may treat the wrapper as if it were a business object and generate misleading member paths.

var effectiveType = Nullable.GetUnderlyingType(property.PropertyType)
                    ?? property.PropertyType;

if (effectiveType == typeof(DateTime))
{
    // Apply an invariant, sortable, reproducible format.
}

Dates, enums, identifiers, and decimal values should each use an explicit formatter. Avoid relying on the current culture or an arbitrary ToString() implementation for persisted audit text. A stable format makes tests deterministic and prevents the same value from looking different when a process runs under another locale.

Do not depend on the order returned by reflection when selecting behavior. Use capability checks such as Any against the generic interface definition. Sort output fields by a defined policy. If collection order has no domain meaning, sort item snapshots by a stable key before comparing them. Deterministic ordering prevents false content changes and produces reproducible logs.

Broad classification through IEnumerable does not require unlimited enumeration. If a type exposes Count through IReadOnlyCollection or ICollection, use that capability. If only enumeration is available, count with a ceiling and stop once the threshold is reached. A summary can honestly say “at least 100 items” without visiting a million elements. This is especially important when audit code runs in the request path.

Another boundary is nested collections. A generic reflection walker can recursively discover sequences inside elements and accidentally rebuild the same object-graph explosion the bounded summary was meant to prevent. Define a maximum depth and treat a collection as a leaf at the summary layer. Item-level expansion belongs in a dedicated diff operation with its own paging and limits.

Verification on .NET 10.0.400

The reproduction for this article used an isolated directory, a net10.0 console project, and only the standard library. It did not connect to any external service or write application data. Synthetic values exercised three decisions: a HashSet fails the nongeneric ICollection test while passing IEnumerable and closed generic ICollection checks; same-count content changes and count changes produce bounded summaries; omitted input and an explicit clear produce different results.

Use these numbered steps to reproduce the checks:

  1. Create an empty local directory and a console project targeting net10.0, with no third-party packages.
  2. Add the classifier and summary functions from this article to Program.cs, then construct synthetic HashSet, array, and string cases.
  3. Run dotnet --version and require 10.0.400; run dotnet run --configuration Release. Treat an exception, a missing PASS marker, or a nonzero exit code as failure.

The real execution returned:

10.0.400
CHECK1 PASS hashset ICollection=False IEnumerable=True ICollection=True stringIsSequence=False
CHECK2 PASS sameCount="Items: content changed (2)" changedCount="Items: 3 => 2"
CHECK3 PASS omitted="ignored: omitted sequence" explicitClear="Items: 3 => 0"
ALL PASS

The first check proves why nongeneric ICollection is an insufficient classifier for this scenario. The second proves that output reports a bounded count or state rather than rendering complete items. The third turns the input-contract policy into executable behavior, preventing a later refactor from silently treating omission as a clear.

These checks are intentionally small. They do not replace application integration tests. A complete suite should also cover custom containers, null, a scalar string, deterministic item ordering, an item-count ceiling, nested data, nullable scalar formatting, and the endpoint contract that communicates omission versus clear. The small executable remains valuable because it pins the runtime interface facts independently of ORM behavior or a database.

Release checklist and conclusion

Before release, confirm that sequence classification excludes strings; generic interface detection does not rely on interface order; summary generation enforces field, item, depth, and text limits; omitted and explicit-clear commands remain distinguishable; detailed views compare structured snapshots rather than parsing display strings; dates and nullable values use stable formatting; tests include HashSet instead of only lists; and historical rows are not rewritten merely to improve presentation.

Also inspect the consuming interface. A compact backend value can still become confusing if the list page hides field names or merges several changes without separators. Keep one stable unit per changed field, provide a clear route to the detailed snapshot diff, and show when the detail has been truncated or bounded. Readability is an end-to-end property, but it begins with truthful, constrained data.

The right audit strategy is not more aggressive serialization. It is a better decision about what belongs in a summary. Use IEnumerable with a string exclusion for broad sequence detection, supplement it with closed generic reflection when a specific capability is required, and separate bounded human text from structured evidence. Finally, encode omission, clear, count change, and content change as reproducible checks. The resulting audit rows become trustworthy navigation cues instead of another dataset that must be debugged before it can be read.