| name | dotnet-patterns |
| description | C# development patterns for the DataNormalizer project. Covers records with init-only properties, pattern matching (type/property/list/positional), nullable reference types, async/await with ConfigureAwait(false), LINQ method syntax, dependency injection, generic constraints with IEquatable<T>, and Result<T> error handling. |
C# Development Patterns
Records and Init-Only Properties
Use records for immutable data transfer objects and value objects. Prefer init over set for properties that should only be set during initialization.
public sealed record AnalyzedProperty(string Name, string TypeName, PropertyKind Kind, bool IsNullable);
public sealed record TypeGraphNode
{
public required string TypeName { get; init; }
public required string FullyQualifiedName { get; init; }
public required IReadOnlyList<AnalyzedProperty> Properties { get; init; }
public bool HasCircularReference { get; init; }
public IReadOnlySet<string> CycleEdgeProperties { get; init; } = new HashSet<string>();
}
public readonly record struct IndexEntry(int Index, bool IsNew);
DataNormalizer note: Generator pipeline models MUST be records. The incremental generator caches pipeline results and uses structural equality to avoid re-emitting unchanged output. Non-equatable models break caching and cause unnecessary recompilation.
Pattern Matching
Type Patterns
if (symbol is INamedTypeSymbol namedType)
{
AnalyzeType(namedType);
}
if (expression is not InvocationExpressionSyntax)
return;
Property Patterns
if (type is { TypeKind: TypeKind.Enum })
return PropertyKind.Simple;
if (type is { IsValueType: true, OriginalDefinition.SpecialType: SpecialType.System_Nullable_T })
{
var innerType = ((INamedTypeSymbol)type).TypeArguments[0];
}
Switch Expressions
public static PropertyKind Classify(ITypeSymbol type) => type switch
{
{ TypeKind: TypeKind.Enum } => PropertyKind.Simple,
{ SpecialType: not SpecialType.None } => PropertyKind.Simple,
IArrayTypeSymbol array => ClassifyCollection(array.ElementType),
INamedTypeSymbol named when IsCollectionType(named) => ClassifyCollection(GetElementType(named)),
INamedTypeSymbol named => PropertyKind.Normalized,
_ => PropertyKind.Simple,
};
List Patterns
if (invocation.ArgumentList.Arguments is [var singleArg])
{
ProcessSingleArgument(singleArg);
}
Positional Patterns
public static string Describe(IndexEntry entry) => entry switch
{
(0, true) => "First item added",
(_, true) => $"New item at index {entry.Index}",
(_, false) => $"Existing item at index {entry.Index}",
};
Nullable Reference Types
Method Signatures
public INamedTypeSymbol? FindType(string name) { }
public void Process(INamedTypeSymbol type)
{
ArgumentNullException.ThrowIfNull(type);
}
Null-Conditional and Null-Coalescing
var name = type.ContainingNamespace?.ToDisplayString();
cache ??= BuildCache();
var symbol = semanticModel.GetDeclaredSymbol(node)
?? throw new InvalidOperationException($"Cannot resolve symbol for {node}");
Nullable Flow Analysis
public void Process(string? input)
{
if (input is null)
return;
var length = input.Length;
}
Async/Await Best Practices
ConfigureAwait in Libraries (MANDATORY)
DataNormalizer is a library — always use ConfigureAwait(false) in async methods. This prevents deadlocks when consumers call from synchronization contexts (WPF, WinForms, ASP.NET classic).
public async Task<NormalizedPersonResult> NormalizeAsync(Person source, CancellationToken cancellationToken = default)
{
var data = await LoadDataAsync(cancellationToken).ConfigureAwait(false);
return Process(data);
}
public async Task<NormalizedPersonResult> NormalizeAsync(Person source)
{
var data = await LoadDataAsync();
return Process(data);
}
CancellationToken
Always accept and forward CancellationToken in async methods.
public async Task<Result<T>> ProcessAsync(T input, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await ComputeAsync(input, cancellationToken).ConfigureAwait(false);
return Result.Ok(result);
}
Parallel Async
public async Task<(Result<A> a, Result<B> b)> ProcessBothAsync(CancellationToken ct = default)
{
var taskA = ProcessAAsync(ct);
var taskB = ProcessBAsync(ct);
await Task.WhenAll(taskA, taskB).ConfigureAwait(false);
return (await taskA, await taskB);
}
Never Block on Async
Never call .Result, .Wait(), or .GetAwaiter().GetResult() on tasks.
var result = ProcessAsync(data).Result;
ProcessAsync(data).Wait();
var result = ProcessAsync(data).GetAwaiter().GetResult();
var result = await ProcessAsync(data, cancellationToken).ConfigureAwait(false);
ValueTask
Use ValueTask<T> when the result is frequently available synchronously.
public ValueTask<int> GetCachedCountAsync()
{
if (cache.TryGetValue("count", out var count))
return ValueTask.FromResult(count);
return ComputeCountAsync();
}
IAsyncEnumerable
public async IAsyncEnumerable<TypeGraphNode> AnalyzeTypesAsync(
IEnumerable<INamedTypeSymbol> types,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
foreach (var type in types)
{
cancellationToken.ThrowIfCancellationRequested();
var node = await AnalyzeTypeAsync(type, cancellationToken).ConfigureAwait(false);
yield return node;
}
}
await foreach (var node in AnalyzeTypesAsync(types, ct).ConfigureAwait(false))
{
ProcessNode(node);
}
LINQ (Method Syntax Preferred)
Prefer method syntax over query syntax for consistency.
var normalizedTypes = typeGraph
.Where(static x => x.Kind == PropertyKind.Normalized)
.Select(static x => x.TypeName)
.Distinct()
.OrderBy(static x => x)
.ToList();
var normalizedTypes = (from node in typeGraph
where node.Kind == PropertyKind.Normalized
select node.TypeName).Distinct().OrderBy(x => x).ToList();
Common LINQ Patterns
bool hasCircular = nodes.Any(static x => x.HasCircularReference);
var rootNode = nodes.FirstOrDefault(x => x.TypeName == rootTypeName);
var byKind = properties
.GroupBy(static x => x.Kind)
.ToDictionary(static x => x.Key, static x => x.ToList());
var namedTypes = members.OfType<INamedTypeSymbol>();
var allProperties = nodes
.SelectMany(static x => x.Properties)
.Distinct()
.ToList();
var totalCount = groups.Aggregate(0, (sum, x) => sum + x.Count);
var report = typeGraph
.Where(static x => x.Properties.Any())
.GroupBy(static x => x.Kind)
.Select(static x => new { Kind = x.Key, Types = x.OrderBy(static t => t.TypeName).ToList() })
.OrderBy(static x => x.Kind)
.ToList();
Dependency Injection
Constructor Injection with Primary Constructors
public sealed class NormalizerService(
INormalizationEngine engine,
IOptions<NormalizerOptions> options,
ILogger<NormalizerService> logger)
{
public object Normalize<T>(T source)
{
logger.LogDebug("Normalizing {Type}", typeof(T).Name);
return engine.Process(source, options.Value);
}
}
Interface Segregation
public interface INormalizationEngine
{
object Normalize<T>(T source);
}
public interface IDenormalizationEngine
{
T Denormalize<T>(object container);
}
Registration Patterns
services.AddSingleton<INormalizationEngine, NormalizationEngine>();
services.AddScoped<INormalizationContext, NormalizationContext>();
services.AddTransient<ITypeAnalyzer, TypeAnalyzer>();
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddDataNormalizer(
this IServiceCollection services,
Action<NormalizerOptions>? configure = null)
{
if (configure is not null)
services.Configure(configure);
services.AddSingleton<INormalizationEngine, NormalizationEngine>();
return services;
}
}
Generic Patterns
Constraints for Compile-Time Safety
public (int Index, bool IsNew) GetOrAddIndex<TDto>(string typeKey, TDto dto)
where TDto : IEquatable<TDto>
{
}
public T? FindById<T>(Guid id) where T : class
=> store.TryGetValue(id, out var obj) && obj is T typed
? typed
: null;
public T CreateAndRegister<T>() where T : class, IEquatable<T>, new()
{
var instance = new T();
registry.Add(instance);
return instance;
}
Covariance and Contravariance
public interface IReadOnlyRepository<out T> where T : class
{
T? GetById(Guid id);
IReadOnlyList<T> GetAll();
}
public interface IComparer<in T>
{
int Compare(T x, T y);
}
Result Pattern for Error Handling
Use Result<T> instead of exceptions for expected failure paths. Exceptions are for exceptional, unexpected situations only.
public readonly record struct Result<T>
{
public T? Value { get; }
public string? Error { get; }
public bool IsSuccess { get; }
private Result(T value) { Value = value; IsSuccess = true; }
private Result(string error) { Error = error; IsSuccess = false; }
public static Result<T> Ok(T value) => new(value);
public static Result<T> Fail(string error) => new(error);
public TOut Match<TOut>(Func<T, TOut> onSuccess, Func<string, TOut> onError)
=> IsSuccess ? onSuccess(Value!) : onError(Error!);
}
public Result<NormalizationModel> Parse(ClassDeclarationSyntax configClass, SemanticModel model)
{
if (!configClass.Modifiers.Any(SyntaxKind.PartialKeyword))
return Result<NormalizationModel>.Fail("Configuration class must be partial");
var result = AnalyzeConfiguration(configClass, model);
return Result<NormalizationModel>.Ok(result);
}
Disposable Pattern
using var stream = File.OpenRead(path);
var data = await JsonSerializer.DeserializeAsync<T>(stream, cancellationToken: ct)
.ConfigureAwait(false);
await using var connection = await factory.CreateConnectionAsync(ct).ConfigureAwait(false);