LINQ Extensions¶
Apply null-aware value operations and cancellation-aware asynchronous sequence operators.
- LINQ Extensions
- Overview
- Challenges
- Solution
- Key Features
- Architecture
- Use Cases
- Basic Usage
- Fluent extensions
- Async enumerable extensions
Overview¶
The LINQ extensions provide focused operations for nullable values, fluent synchronous/asynchronous value chains, and IAsyncEnumerable<T> sequences. They live in Common.Abstractions under the BridgingIT.DevKit.Common namespace.
This page focuses on the LINQ-oriented extension families. For a broader package-level overview of the extension helpers available from Common.Abstractions, see Common Extensions.
Challenges¶
Application code often mixes optional values, conditional transformations, asynchronous work, and streamed sequences. Repeated null branches and manual await foreach loops obscure the operation being performed. Standard LINQ also does not cover every project-specific null-safe or asynchronous-sequence convention.
These helpers must be used with care: not every fluent delegate can be translated by an IQueryable<T> provider, and some operators enumerate a source completely or keep state proportional to the number of distinct items.
Solution¶
LinqFluentExtensions adds null-aware lookup, branching, transformation, side-effect, validation, matching, and fallback methods for values and tasks. AsyncEnumerableExtensions adds cancellation-aware querying and lazy transformation for IAsyncEnumerable<T>.
The extensions return the original value, a transformed value, a task, or another asynchronous sequence according to the operation. Callers choose when to materialize or otherwise consume a lazy sequence.
Key Features¶
- Null-safe
Find,WhenNotNull,WhenNull,Match, andOrElseoperations. - Conditional value transformations through
WhenandUnless. - Sync-to-async and task-to-async composition through
SelectAsync,DoAsync, and related overloads. - Explicit validation through
ThrowandThrowWhen. - Cancellation-aware query, filter, projection, partition, and deduplication for asynchronous sequences.
- Lazy async-sequence operators except for terminal operations such as
CountAsync,FirstAsync, andLastAsync.
Architecture¶
Both extension families are static classes in Common.Abstractions. LinqFluentExtensions operates on in-memory values, IEnumerable<T>, nullable structs, and Task<T>. AsyncEnumerableExtensions consumes IAsyncEnumerable<T> with WithCancellation(...) and returns either a ValueTask<T> terminal result or a lazy IAsyncEnumerable<T> pipeline.
They are in-memory operators. Calling them after AsAsyncEnumerable() moves subsequent work out of a database query provider.
Use Cases¶
- Find an optional item and handle the present and absent branches.
- Apply a transformation only when a predicate matches.
- Add logging or auditing without changing the value flowing through a chain.
- Validate a value and throw an application-specific exception.
- Filter, page, concatenate, or deduplicate streamed results.
- Stop asynchronous enumeration through a propagated cancellation token.
Basic Usage¶
Reference Common.Abstractions, import the namespace, and handle both lookup outcomes explicitly:
using BridgingIT.DevKit.Common;
var message = users
.Find(user => user.IsActive)
.Match(
some: user => $"Active user: {user.Name}",
none: () => "No active user found");
Console.WriteLine(message);
For an active user named Ada, the visible result is Active user: Ada. Find returns null when the source, predicate, or matching item is absent, and Match selects the none branch in that case.
Fluent extensions¶
The LINQ fluent extensions handle null values, conditional operations, and task/value composition. They complement standard LINQ with operations for optional values and fluent asynchronous transitions.
Overview¶
Key benefits¶
- Null-aware chaining: Run actions or select branches according to a value's null state.
- Functional composition: Chain related operations through a fluent interface.
- Async/sync composition: Continue from a
Task<T>with synchronous or asynchronous work. - Explicit side effects: Keep logging and other non-transforming operations visible through
Do. - Reference and value-type support: Use dedicated overloads for reference values and nullable structs.
Architecture¶
The extensions are organized into logical groups:
- Find operations: Null-returning lookup for reference types and nullable lookup for value types
- Null Handling: Conditional execution based on null state
- String Checks: Specialized null/empty validation for strings
- Conditional Logic: When/Unless for predicate-based operations
- Transformations: Select/Map for value transformations
- Side Effects: Do for logging and non-transforming operations (Tap)
- Error Handling: Throw/ThrowWhen for validation
- Pattern Matching: Match for both-case handling
- Fallback Values: OrElse for default factories
Common usage patterns¶
Basic null checking¶
Replace traditional null checks with fluent null handling:
// Traditional approach
var user = users.FirstOrDefault(u => u.IsActive);
if (user != null)
{
await emailService.SendAsync(user.Email);
}
// Using extensions
await users
.Find(u => u.IsActive)
.WhenNotNullAsync(async u => await emailService.SendAsync(u.Email), cancellationToken);
Conditional LINQ chains¶
Apply filters and transformations conditionally:
// Traditional approach
var query = orders.AsQueryable();
if (!string.IsNullOrEmpty(searchTerm))
query = query.Where(o => o.Description.Contains(searchTerm));
if (minPrice.HasValue)
query = query.Where(o => o.Total >= minPrice.Value);
var results = await query.ToListAsync();
// Using the single-branch When overload
var results = await orders
.When(_ => !string.IsNullOrEmpty(searchTerm),
q => q.Where(o => o.Description.Contains(searchTerm)))
.When(_ => minPrice.HasValue,
q => q.Where(o => o.Total >= minPrice.Value))
.ToListAsync();
// Or with Unless for inverted conditions
var results = await orders
.Unless(_ => string.IsNullOrEmpty(searchTerm),
q => q.Where(o => o.Description.Contains(searchTerm)))
.Unless(_ => !minPrice.HasValue,
q => q.Where(o => o.Total >= minPrice.Value))
.ToListAsync();
Validation and error handling¶
Chain validations with proper error propagation:
// Traditional approach
var product = products.FirstOrDefault(p => p.Id == id);
if (product == null)
throw new ProductNotFoundException($"Product {id} not found");
if (product.Stock == 0)
throw new OutOfStockException();
// Using extensions
var product = products
.Find(p => p.Id == id)
.Throw(() => new ProductNotFoundException($"Product {id} not found"))
.ThrowWhen(p => p.Stock == 0, p => new OutOfStockException());
Async/sync mixing¶
Seamlessly transition between async and sync operations:
// Load async, then process sync, then transform async
var result = await users
.FindAsync(async (u, ct) => await IsActiveAsync(u, ct), cancellationToken) // Async find
.Select(u => u.Profile) // Sync select
.SelectAsync(async p => await enrichService.EnrichAsync(p), cancellationToken) // Async select
.DoAsync(p =>
{
logger.LogInformation("Processed: {Name}", p.Name);
return Task.CompletedTask;
}, cancellationToken)
.DoAsync(async p => await cache.StoreAsync(p), cancellationToken); // Async side effect
Extension reference¶
Find operations¶
Find fluent alternatives to FirstOrDefault:
// Find first matching element
var user = users.Find(u => u.IsAdmin);
// Find first element that satisfies an always-true predicate
var first = orders.Find(_ => true);
// Async find with async predicate
var product = await products.FindAsync(
async (p, ct) => await IsInStockAsync(p, ct),
cancellationToken);
Null handling¶
WhenNotNull/WhenNull execute operations based on null state:
// Execute side effect if not null
await user
.WhenNotNullAsync(async u => await LogUserAccessAsync(u.Id), cancellationToken);
// Execute if null (alternative path)
await user
.WhenNullAsync(async ct => await CreateDefaultUserAsync(ct), cancellationToken);
String checks¶
String-specific checks for empty/whitespace:
// Check for empty string
email.WhenNotNullOrEmpty(e => SendEmail(e));
// Check for whitespace
searchTerm.WhenNotNullOrWhiteSpaceAsync(
async (term, ct) => await SearchAsync(term, ct),
cancellationToken);
// Alternative paths
input
.WhenNotNullOrWhiteSpace(ProcessInput)
.WhenNullOrWhiteSpace(() => UseDefaultValue());
Conditional logic¶
When applies operations based on predicates. Use the single-branch overload when you only want to transform if the condition is true:
// Single-branch When - only applies transformation when condition is true
var filtered = items
.When(items => items.Any(),
i => i.Where(x => x.IsActive));
// Both-branch When - choose between two transformations
var filtered = items
.When(items => items.Any(),
i => i.Where(x => x.IsActive), // then
i => Enumerable.Empty<Item>()); // else
// Practical example - filtering on conditions
var results = orders
.When(_ => !string.IsNullOrEmpty(searchTerm),
q => q.Where(o => o.Description.Contains(searchTerm)))
.When(_ => minPrice.HasValue,
q => q.Where(o => o.Total >= minPrice.Value))
.ToListAsync();
// Conditional async
await order
.WhenAsync(
async (o, ct) => await IsHighValueAsync(o, ct),
async (o, ct) => await ApplyPremiumBenefitAsync(o, ct),
cancellationToken);
Unless provides clearer negation when the "then" action applies to the false case:
// Unless - clearer when negating conditions
var result = users
.Unless(u => u.IsDeleted, u => ProcessUser(u));
// Practical example - skip filters on exclusion conditions
var results = orders
.Unless(_ => string.IsNullOrEmpty(searchTerm),
q => q.Where(o => o.Description.Contains(searchTerm)))
.Unless(_ => !minPrice.HasValue,
q => q.Where(o => o.Total >= minPrice.Value))
.ToListAsync();
Transformations¶
Select continues a Task<T> with a synchronous transformation. SelectAsync applies an asynchronous transformation to a value or task:
// Synchronous transformation after a task
var profile = await userService
.GetUserAsync(userId, cancellationToken)
.Select(user => user.Profile, cancellationToken);
// Async transformation
var enriched = await user
.SelectAsync(async (u, ct) => await LoadProfileAsync(u, ct), cancellationToken);
// Bridge sync to async
var result = await users
.FindAsync(async (u, ct) => await IsActiveAsync(u, ct), cancellationToken)
.Select(u => u.Profile) // Sync after async
.SelectAsync(async p => await EnrichAsync(p), cancellationToken);
Side effects¶
Do execute operations without changing the value (Tap):
// Log without changing value
var user = repository
.Find(u => u.Id == id)
.Do(u => logger.LogInformation("Found: {Name}", u.Name))
.Do(u => auditService.Log(u.Id));
// Async side effects
await order
.DoAsync(async (o, ct) => await cache.StoreAsync(o, ct), cancellationToken)
.DoAsync(async (o, ct) => await analytics.TrackAsync(o.Id, ct), cancellationToken);
Error handling¶
Throw/ThrowWhen validate and throw conditionally:
// Throw if null
var product = products
.Find(p => p.Id == id)
.Throw(() => new NotFoundException("Product not found"));
// Throw if condition true
var order = orders
.Find(o => o.Id == id)
.ThrowWhen(o => o.IsDeleted, o => new InvalidOperationException("Order deleted"));
// Async validation
await user
.ThrowWhenAsync(
async (u, ct) => await IsBlockedAsync(u, ct),
async (u, ct) => new UnauthorizedAccessException($"User {u.Id} blocked"),
cancellationToken);
Pattern matching¶
Match handle both success and failure cases:
// Sync pattern matching
var message = user.Match(
some: u => $"Hello, {u.Name}",
none: () => "User not found");
// Async pattern matching
var result = await order
.MatchAsync(
some: async (o, ct) => await ProcessOrderAsync(o, ct),
none: async ct => await LogNotFoundAsync(ct),
cancellationToken);
Fallback values¶
OrElse provide default factories:
// Simple fallback
var user = cachedUser
.OrElse(() => repository.FindById(userId));
// Async fallback
var config = await cachedConfig
.OrElseAsync(
async ct => await configService.LoadAsync(ct),
cancellationToken);
Common scenarios¶
API request processing¶
app.MapGet("/api/users/{id}", async Task<Microsoft.AspNetCore.Http.IResult>
(int id, IUserRepository repository, ILogger<Program> logger, CancellationToken ct) =>
{
return await repository
.FindAsync(u => u.Id == id, ct)
.DoAsync(async u => await logger.LogAccessAsync(u.Id, ct), ct)
.MatchAsync(
some: (user, _) => Task.FromResult<Microsoft.AspNetCore.Http.IResult>(TypedResults.Ok(user)),
none: _ => Task.FromResult<Microsoft.AspNetCore.Http.IResult>(TypedResults.NotFound()),
cancellationToken: ct);
});
Data validation pipeline¶
var validatedData = await inputData
.When(data => !string.IsNullOrEmpty(data.Email),
d => NormalizeEmail(d))
.SelectAsync(async d => await ValidateAsync(d, ct), ct)
.ThrowWhenAsync(
async (d, c) => !(await IsUniqueAsync(d, c)),
(d, _) => Task.FromResult<Exception>(
new ValidationException("Email already exists")),
ct);
Conditional query building¶
var results = await orders
.When(_ => filterCriteria.HasCategory,
q => q.Where(o => o.Category == filterCriteria.Category))
.When(_ => !filterCriteria.IncludeArchived,
q => q.Where(o => !o.IsArchived))
.When(_ => filterCriteria.MinPrice.HasValue,
q => q.Where(o => o.Total >= filterCriteria.MinPrice.Value))
.OrderBy(o => o.CreatedDate)
.ToListAsync();
Multi-step processing¶
var processed = await users
.Find(u => u.IsActive)
.Throw(() => new InvalidOperationException("No active users"))
.SelectAsync(async (u, ct) => await EnrichUserDataAsync(u, ct), ct)
.DoAsync(async (u, ct) => await LogProcessingAsync(u, ct), ct)
.UnlessAsync(
async (u, ct) => await IsBlacklistedAsync(u, ct),
async (u, ct) => await ApplyAccessRulesAsync(u, ct),
ct);
Best practices¶
1. Choose the right conditional method¶
Use When for positive conditions and Unless for negative conditions:
// Clear with When
items.When(_ => isActive, q => q.Where(i => i.Status == "active"))
// Clear with Unless
items.Unless(_ => isArchived, q => q.Where(i => i.Status != "archived"))
// Avoid double negation
items.Unless(_ => !isArchived, q => ...) // Hard to read
2. Use single-branch When for filters¶
Only use the both-branch overload when you actually need two different transformations:
// Good - single branch, simple filtering
items.When(_ => hasFilter, q => q.Where(...))
// Good - both branches needed for different transformations
items.When(_ => sortAsc,
q => q.OrderBy(x => x.Date), // then
q => q.OrderByDescending(x => x.Date)) // else
// Avoid - unnecessary both-branch when else does nothing
items.When(_ => condition,
q => q.Where(...),
q => q) // Redundant
3. Mix sync and async naturally¶
Use Select to bridge from async to sync operations:
// Natural flow: async -> sync -> async
await orders
.FindAsync(async (o, token) => await IsPendingAsync(o, token), ct) // Async
.Select(o => o.Items) // Sync
.SelectAsync(async i => await EnrichAsync(i), ct); // Async
4. Use Do for observability¶
Keep side effects explicit without changing flow:
var observed = data
.Do(d => logger.LogInformation("Processing: {Id}", d.Id))
.Do(d => metrics.Increment("processed"));
var result = Transform(observed);
5. Combine operations meaningfully¶
Chain operations that form a complete workflow:
var finalResult = await initial
.SelectAsync(async x => await ValidateAsync(x, ct), ct)
.ThrowWhenAsync(
async (x, c) => await IsInvalidAsync(x, c),
(x, _) => Task.FromResult<Exception>(
new ValidationException(x.ToString())),
ct)
.DoAsync(async (x, c) => await LogSuccessAsync(x, c), ct)
.SelectAsync(async (x, c) => await SaveAsync(x, c), ct);
Performance considerations¶
- Execution timing: Value and task extensions execute when called; any
IEnumerable<T>returned by a transformation retains that sequence's normal lazy behavior. - Async predicates:
FindAsyncchecks items sequentially and stops at the first match. - Null checks: The null-aware methods use direct null or
HasValuechecks. - Cancellation: Async overloads accept cancellation tokens, but supplied delegates must also observe the token when appropriate.
Limitations and gotchas¶
QueryProvider compatibility¶
When and Unless invoke a delegate that returns the next value in the chain. They can return an IQueryable<T>, but any expression inside that query must still be translatable by the database provider:
// Works - filter is translatable
var results = await context.Orders
.When(_ => minPrice.HasValue,
q => q.Where(o => o.Total >= minPrice.Value))
.ToListAsync();
// Won't work - filtering happens in memory
var results = await context.Orders
.ToList() // Materializes to memory
.When(_ => minPrice.HasValue,
q => q.Where(o => o.Total >= minPrice.Value))
.ToList();
Async context¶
Always maintain the async context properly:
// Correct - maintains async context
await value.SelectAsync(async v => await ProcessAsync(v), ct);
// Problematic - blocks the thread
value.SelectAsync(async v => await ProcessAsync(v), ct).Result;
Async enumerable extensions¶
The async enumerable extensions provide LINQ-like operations for IAsyncEnumerable<T> sequences with cancellation-aware iteration and transformation.
Overview¶
These extensions enable working with asynchronous sequences in a familiar LINQ style while maintaining proper async/await semantics and cancellation support. They are particularly useful when working with database queries, API streams, and other async data sources.
Key benefits¶
- Familiar API: LINQ-like methods you already know
- Async-Aware: Built for async scenarios with cancellation support
- Efficient: Lazy evaluation and streaming where appropriate
- Memory-Friendly: Process large sequences without materializing to memory
Extension reference¶
Querying operations¶
AnyAsync - Check if any elements match a condition:
// Check if any active users exist
bool hasActive = await users.AnyAsync(u => u.IsActive, cancellationToken);
ContainsAsync - Check if sequence contains a specific value:
// Check if user exists in collection
bool exists = await users.ContainsAsync(targetUser, cancellationToken);
// With custom equality comparer
bool exists = await users.ContainsAsync(targetUser, userComparer, cancellationToken);
CountAsync - Count elements matching a condition:
// Count all items
int total = await items.CountAsync(cancellationToken);
// Count matching condition
int activeCount = await items.CountAsync(i => i.IsActive, cancellationToken);
Filtering operations¶
WhereAsync - Lazily filter an asynchronous sequence with a synchronous predicate:
// Filter active items
var active = items
.WhereAsync(i => i.IsActive, cancellationToken)
.SelectAsync(i => i.Name, cancellationToken);
WhereNotNull - Filter out null values:
// Remove null entries
var valid = items.WhereNotNull(cancellationToken);
WhereNotNullOrEmpty - Filter out null/empty strings:
// Keep only non-empty strings
var populated = strings.WhereNotNullOrEmpty(cancellationToken);
WhereNotNullOrWhiteSpace - Filter out null/whitespace strings:
// Keep meaningful strings
var meaningful = strings.WhereNotNullOrWhiteSpace(cancellationToken);
Selection operations¶
SelectAsync - Lazily transform elements with a synchronous selector:
// Transform each item
var names = users
.SelectAsync(u => u.Name, cancellationToken);
This IAsyncEnumerable<T> extension does not accept an asynchronous selector. Use await foreach when each projection needs asynchronous work.
Aggregation operations¶
FirstAsync - Get first element or matching element:
// Get first element
var first = await items.FirstAsync(cancellationToken);
// Get first matching
var active = await items.FirstAsync(i => i.IsActive, cancellationToken);
FirstOrDefaultAsync - Get first matching element or default:
// Get first match or null
var active = await items.FirstOrDefaultAsync(i => i.IsActive, cancellationToken);
LastAsync - Get last element or matching element:
// Get last element
var last = await items.LastAsync(cancellationToken);
// Get last matching
var lastActive = await items.LastAsync(i => i.IsActive, cancellationToken);
LastOrDefaultAsync - Get last matching element or default:
// Get last match or null
var lastActive = await items.LastOrDefaultAsync(i => i.IsActive, cancellationToken);
Partitioning operations¶
TakeAsync - Take first N elements:
// Get first 10 items
var first10 = items.TakeAsync(10, cancellationToken);
// Take while condition is true
var batch = items
.TakeAsync(100, cancellationToken)
.WhereAsync(i => i.IsValid, cancellationToken);
SkipAsync - Skip first N elements:
// Skip first 20, get rest
var remaining = items.SkipAsync(20, cancellationToken);
// Pagination pattern
var page = items
.TakeAsync(pageSize, cancellationToken);
if (pageNumber > 1)
{
page = items
.SkipAsync((pageNumber - 1) * pageSize, cancellationToken)
.TakeAsync(pageSize, cancellationToken);
}
Deduplication operations¶
DistinctAsync - Remove duplicate elements:
// Remove duplicates
var unique = items.DistinctAsync(cancellationToken);
// With custom comparer
var unique = items.DistinctAsync(comparer, cancellationToken);
DistinctByAsync - Remove duplicates by key:
// Remove users with duplicate IDs
var uniqueUsers = users
.DistinctByAsync(u => u.Id, cancellationToken);
// With custom comparer
var unique = items
.DistinctByAsync(i => i.Category, categoryComparer, cancellationToken);
Concatenation¶
ConcatAsync - Combine two async sequences:
// Combine results from multiple sources
var combined = source1
.ConcatAsync(source2, cancellationToken)
.ConcatAsync(source3, cancellationToken);
Common scenarios¶
Streaming results¶
// Process large result set without materializing
await foreach (var order in database
.GetOrdersAsync(cancellationToken)
.WhereAsync(o => o.Total > 100, cancellationToken)
.WithCancellation(cancellationToken))
{
var enriched = await EnrichAsync(order, cancellationToken);
logger.LogInformation("Order {OrderId}", enriched.Id);
}
Pagination¶
// Implement pagination without loading entire set
public async IAsyncEnumerable<Item> GetPagedItemsAsync(
int pageNumber,
int pageSize,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var skip = (pageNumber - 1) * pageSize;
var source = database.GetItemsAsync(cancellationToken);
if (skip > 0)
{
source = source.SkipAsync(skip, cancellationToken);
}
await foreach (var item in source
.TakeAsync(pageSize, cancellationToken)
.WithCancellation(cancellationToken))
{
yield return item;
}
}
SkipAsync(0) currently produces an empty sequence. Bypass SkipAsync when the calculated skip count is zero, as the example does.
Filtering and validation¶
// Combine synchronous stream filtering with asynchronous validation
var validItems = new List<Item>();
await foreach (var item in source
.WhereNotNull(cancellationToken)
.WhereAsync(i => i.IsActive, cancellationToken)
.WithCancellation(cancellationToken))
{
if (await ValidateAsync(item, cancellationToken))
{
validItems.Add(item);
}
}
Deduplication¶
// Remove duplicates by category and keep first occurrence
var uniqueByCategory = items
.DistinctByAsync(i => i.Category, cancellationToken);
// Further filter and deduplicate
var filtered = items
.WhereAsync(i => i.IsValid, cancellationToken)
.DistinctAsync(cancellationToken);
Best practices¶
1. Use lazy evaluation¶
Leverage lazy evaluation for large sequences:
// Good - operations are lazy
var processed = source
.WhereAsync(i => i.IsActive, cancellationToken)
.SelectAsync(i => Transform(i), cancellationToken)
.TakeAsync(100, cancellationToken);
// Then consume when needed
var results = new List<Item>();
await foreach (var item in processed.WithCancellation(cancellationToken))
{
results.Add(item);
}
2. Chain efficiently¶
Order operations to filter early:
// Good - filter before transform
var results = items
.WhereAsync(i => i.IsValid, cancellationToken)
.SelectAsync(i => Transform(i), cancellationToken);
// Avoid - transform then filter
var results = items
.SelectAsync(i => Transform(i), cancellationToken)
.WhereAsync(i => i.IsValid, cancellationToken);
3. Respect cancellation¶
Always pass cancellation tokens:
// Good - cancellation is respected
await foreach (var item in items
.WhereAsync(i => i.IsActive, cancellationToken)
.WithCancellation(cancellationToken))
{
await ProcessAsync(item, cancellationToken);
}
// Avoid - no cancellation support
await foreach (var item in items.WhereAsync(i => i.IsActive))
{
await ProcessAsync(item);
}
4. Handle large sequences¶
Cap a single pass when only an initial segment is needed:
// Process at most one segment
const int batchSize = 1000;
var processed = 0;
await foreach (var item in items
.TakeAsync(batchSize, cancellationToken)
.WithCancellation(cancellationToken))
{
await ProcessAsync(item, cancellationToken);
processed++;
}
Performance considerations¶
- Lazy evaluation: Sequence-returning methods defer enumeration.
- Memory use: Lazy operators stream items, while
DistinctAsyncandDistinctByAsyncretain a set of seen values or keys. - Cancellation: Operators pass the supplied token into source enumeration; caller-provided work must observe its token separately.
- Repeated enumeration: Enumerating the same pipeline again reruns its source and operators.
Limitations¶
Database query providers¶
Some async enumerable operations may not translate to database queries:
// Runs client-side after AsAsyncEnumerable
var distinctCount = await dbContext.Orders
.AsAsyncEnumerable()
.DistinctByAsync(o => o.CustomerId, cancellationToken)
.CountAsync(cancellationToken);
// Better - use LINQ-to-Entities
var serverResults = await dbContext.Orders
.GroupBy(o => o.CustomerId)
.Select(g => g.First())
.ToListAsync(cancellationToken);