C# – Modern Language Features
Cygnus Dynamics targets .NET 8 and C# 12. Use modern language features — they exist to produce safer, more expressive, and more performant code. This page defines which features to use, how to use them correctly, and which older patterns to retire.
Language Keywords Over Framework Types
Use C# language keywords for built-in types instead of their .NET runtime class names:
// ✅ Language keywords — idiomatic C#
string name = "Alice";
int orderId = 42;
bool isActive = true;
decimal totalAmount = 99.99m;
object payload = GetPayload();
// ❌ .NET type names — verbose and non-idiomatic
String name = "Alice";
Int32 orderId = 42;
Boolean isActive = true;
Decimal totalAmount = 99.99m;
Object payload = GetPayload();
| Keyword | Avoid |
|---|---|
string |
System.String |
int |
System.Int32 |
long |
System.Int64 |
bool |
System.Boolean |
decimal |
System.Decimal |
double |
System.Double |
float |
System.Single |
object |
System.Object |
byte |
System.Byte |
String Handling
String Interpolation
// ✅ String interpolation
string displayName = $"{user.LastName}, {user.FirstName}";
string message = $"Order {order.Id} confirmed for user {user.Email}.";
// ❌ String concatenation
string displayName = user.LastName + ", " + user.FirstName;
// ❌ string.Format — positional arguments are error-prone
string message = string.Format("Order {0} confirmed for user {1}.", order.Id, user.Email);
StringBuilder for Loop Concatenation
// ✅ StringBuilder for repeated appending
var sb = new StringBuilder();
foreach (var item in order.Items)
{
sb.AppendLine($" - {item.Name}: {item.Quantity} × {item.UnitPrice:C}");
}
string summary = sb.ToString();
// ❌ String concatenation in a loop — new string object on every iteration
string summary = string.Empty;
foreach (var item in order.Items)
summary += $" - {item.Name}: {item.Quantity} × {item.UnitPrice:C}\n";
Raw String Literals (C# 11+)
// ✅ Raw string literal — no escape sequences
var json = """
{
"orderId": 42,
"status": "confirmed",
"currency": "USD"
}
""";
var sqlQuery = """
SELECT o.Id, o.Total, u.Email
FROM Orders o
INNER JOIN Users u ON o.UserId = u.Id
WHERE o.Status = 'Confirmed'
""";
async / await
Use async/await for all I/O-bound operations. Never block on async code with .Result, .Wait(), or GetAwaiter().GetResult():
// ✅ async/await — non-blocking, correct
public async Task<Order> GetOrderAsync(int orderId, CancellationToken ct = default)
{
var order = await _repository.FindByIdAsync(orderId, ct);
if (order is null)
throw new OrderNotFoundException(orderId);
return order;
}
// ✅ Concurrent async — all calls run simultaneously
var (orders, notifications, profile) = await (
_orderService.GetRecentOrdersAsync(userId, ct),
_notificationService.GetUnreadAsync(userId, ct),
_userService.GetProfileAsync(userId, ct)
).WhenAll();
// ❌ Blocking on async — deadlocks in ASP.NET Core
public Order GetOrder(int orderId)
{
return _repository.FindByIdAsync(orderId).Result; // deadlock risk
}
ConfigureAwait(false)
In library or infrastructure code (not ASP.NET Core controllers), use .ConfigureAwait(false) to avoid capturing the synchronisation context:
// ✅ In library / infrastructure code
public async Task<Order> FindByIdAsync(int orderId, CancellationToken ct)
{
return await _context.Orders
.FirstOrDefaultAsync(o => o.Id == orderId, ct)
.ConfigureAwait(false);
}
// ✅ In ASP.NET Core controllers — ConfigureAwait not needed (no SynchronizationContext)
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id, CancellationToken ct)
{
var order = await _orderService.GetOrderAsync(id, ct);
return Ok(order);
}
Always Pass CancellationToken
All async methods in the service and repository layers must accept and propagate a CancellationToken:
// ✅ CancellationToken propagated through the call chain
public async Task<Order> CreateOrderAsync(
CreateOrderRequest request,
CancellationToken cancellationToken = default)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
var order = BuildOrder(request, user);
await _orderRepository.SaveAsync(order, cancellationToken);
await _eventBus.PublishAsync(new OrderCreatedEvent(order.Id), cancellationToken);
return order;
}
Null Handling
Nullable Reference Types
All projects must enable nullable reference types:
// ✅ Explicit nullable annotation — null is intentional
public User? FindUserByEmail(string email) =>
_repository.FindByEmail(email);
// ✅ Non-null return — throws if absent
public async Task<User> GetUserAsync(int userId, CancellationToken ct) =>
await _repository.FindByIdAsync(userId, ct)
?? throw new UserNotFoundException(userId);
Null Coalescing and Null-Conditional Operators
// ✅ Null-conditional — safe deep access
string? city = user?.Address?.City;
// ✅ Null-coalescing — provide a default
string currency = order.Currency ?? "USD";
// ✅ Null-coalescing assignment (C# 8+)
config.Timeout ??= TimeSpan.FromSeconds(30);
// ✅ Null-forgiving operator — only when you are certain and compiler cannot prove it
var order = _repository.FindById(id)!; // use sparingly
Throw Expressions
// ✅ ArgumentNullException.ThrowIfNull (C# 11 / .NET 7+) — preferred
public OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
_orderRepository = repository;
_logger = logger;
}
Pattern Matching
Use pattern matching for concise, readable type-checking and deconstruction:
// ✅ Switch expression with type patterns
public decimal CalculateShippingCost(Shipment shipment) => shipment switch
{
ExpressShipment s => s.Weight * 2.50m,
StandardShipment s when s.Weight > 10 => s.Weight * 1.20m,
StandardShipment s => s.Weight * 0.80m,
_ => throw new ArgumentException($"Unknown shipment type: {shipment.GetType().Name}"),
};
// ✅ Property pattern — check multiple properties in one expression
public bool IsEligibleForFreeShipping(Order order) =>
order is { Total: >= 50, Status: OrderStatus.Pending, Currency: "USD" };
// ✅ List patterns (C# 11+)
public string DescribeItems(List<OrderItem> items) => items switch
{
[] => "No items",
[var single] => $"One item: {single.Name}",
[var first, var second] => $"Two items: {first.Name} and {second.Name}",
[var first, .., var last] => $"Multiple items, first: {first.Name}, last: {last.Name}",
};
// ✅ Pattern matching with declaration
if (notification is EmailNotification email)
{
SendEmail(email);
}
// ❌ Old-style is + cast
if (notification is EmailNotification)
{
var email = (EmailNotification)notification; // redundant cast
SendEmail(email);
}
Records
// ✅ Positional record — ideal for DTOs and request/response objects
public record CreateOrderRequest(
int UserId,
string Currency,
IReadOnlyList<OrderItemRequest> Items);
// ✅ Record with validation in compact constructor
public record Money(decimal Amount, string Currency)
{
public decimal Amount { get; init; } = Amount >= 0
? Amount
: throw new ArgumentOutOfRangeException(nameof(Amount), "Amount cannot be negative.");
}
// ✅ Non-destructive mutation with with expression
var updatedRequest = originalRequest with { Currency = "EUR" };
var confirmedOrder = pendingOrder with { Status = OrderStatus.Confirmed };
Collection Expressions (C# 12)
Use collection expressions for cleaner, more concise collection construction:
// ✅ Collection expressions (C# 12) — consistent syntax across collection types
string[] currencies = ["USD", "EUR", "GBP", "AED"];
List<int> itemIds = [1, 2, 3, 4, 5];
ImmutableArray<string> headers = ["Content-Type", "Authorization"];
// ✅ Spread operator — merge collections
string[] allCurrencies = [..fiatCurrencies, ..cryptoCurrencies];
List<OrderItem> allItems = [..pendingItems, ..backorderedItems];
// ✅ Empty collection
string[] emptyTags = [];
List<Order> noOrders = [];
// ❌ Verbose older syntax — replaced by collection expressions
string[] currencies = new string[] { "USD", "EUR", "GBP" };
var itemIds = new List<int> { 1, 2, 3, 4, 5 };
var allCurrencies = fiatCurrencies.Concat(cryptoCurrencies).ToArray();
Primary Constructors (C# 12)
Use primary constructors for simple dependency injection scenarios:
// ✅ Primary constructor — clean, less boilerplate
public sealed class OrderService(
IOrderRepository orderRepository,
IPaymentService paymentService,
ILogger<OrderService> logger)
{
// Capture as readonly fields when you need to reference them as _fields
// or when you need validation
private readonly IOrderRepository _orderRepository =
orderRepository ?? throw new ArgumentNullException(nameof(orderRepository));
private readonly IPaymentService _paymentService =
paymentService ?? throw new ArgumentNullException(nameof(paymentService));
private readonly ILogger<OrderService> _logger =
logger ?? throw new ArgumentNullException(nameof(logger));
public async Task<Order> GetOrderAsync(int orderId, CancellationToken ct = default)
=> await _orderRepository.FindByIdAsync(orderId, ct)
?? throw new OrderNotFoundException(orderId);
}
// ✅ Primary constructor without field capture — use when params are only used to init fields
public sealed class OrderService(IOrderRepository orderRepository, ILogger<OrderService> logger)
{
public async Task<Order> GetOrderAsync(int orderId, CancellationToken ct = default)
=> await orderRepository.FindByIdAsync(orderId, ct) // uses primary ctor param directly
?? throw new OrderNotFoundException(orderId);
}
LINQ
Prefer method syntax over query syntax:
// ✅ Method syntax — preferred
var confirmedOrders = orders
.Where(o => o.Status == OrderStatus.Confirmed)
.Where(o => o.CreatedAt >= cutoffDate)
.OrderByDescending(o => o.Total)
.Select(o => new OrderSummary(o.Id, o.Total, o.Status))
.ToList();
decimal totalRevenue = orders
.Where(o => o.Status == OrderStatus.Delivered)
.Sum(o => o.Total);
// ✅ Use where filters early — reduce the set before expensive operations
var result = orders
.Where(o => o.Status == OrderStatus.Confirmed) // filter first
.OrderByDescending(o => o.Total) // sort the filtered set
.Select(o => o.ToDto());
// ❌ LINQ for mutations — use a foreach instead
orders.ToList().ForEach(o => o.Status = OrderStatus.Cancelled); // side-effectful
// ✅ Use foreach for mutations
foreach (var order in orders)
order.Cancel();
required Properties
Use required to force callers to initialise critical properties at construction time:
// ✅ required properties — compiler enforces initialisation
public class OrderConfiguration
{
public required string ApiKey { get; init; }
public required Uri BaseUrl { get; init; }
public int TimeoutSeconds { get; init; } = 30; // has a default — not required
}
// Caller must provide ApiKey and BaseUrl — compile error if omitted
var config = new OrderConfiguration
{
ApiKey = Environment.GetEnvironmentVariable("ORDERS_API_KEY")!,
BaseUrl = new Uri("https://api.cygnusdynamics.com/orders"),
};
Delegates and Lambdas
// ✅ Func and Action — no custom delegate types needed for most cases
Action<string> logMessage = msg => _logger.LogInformation(msg);
Func<Order, bool> isLargeOrder = order => order.Total > 1000m;
// ✅ Custom delegate only when the signature has specific semantic meaning
public delegate Task OrderStatusChangedHandler(
Order order,
OrderStatus previousStatus,
CancellationToken ct);
&& and || vs & and |
Always use && and || for boolean logic. The short-circuit operators stop evaluation as soon as the result is determined — preventing null reference exceptions:
// ✅ Short-circuit — second operand not evaluated if order is null
if (order != null && order.Items.Count > 0)
ProcessOrder(order);
// ❌ Non-short-circuit — evaluates BOTH sides, throws if order is null
if (order != null & order.Items.Count > 0) // NullReferenceException if order is null
{ }
Forbidden and Discouraged Patterns
| Pattern | Status | Replacement |
|---|---|---|
Thread.Sleep() |
❌ Banned in async code | await Task.Delay() |
.Result / .Wait() on Tasks |
❌ Banned in ASP.NET Core | await |
dynamic |
⚠️ Avoid unless truly needed | Generics, pattern matching |
goto |
❌ Banned | Refactor to structured control flow |
Mutable static state |
⚠️ Avoid | Dependency injection |
string concatenation in loops |
⚠️ Avoid | StringBuilder |
.NET type names (Int32, String) |
⚠️ Avoid | Language keywords |
Catching System.Exception broadly |
⚠️ Avoid | Specific exception types |
public fields |
⚠️ Avoid | public properties |
| Non-nullable without annotation | ❌ Banned | Enable <Nullable>enable</Nullable> |
IConfiguration injected into services |
⚠️ Avoid | IOptions<T> pattern |
| Unsealed concrete classes | ⚠️ Avoid | Add sealed by default |
new string[] { } syntax |
⚠️ Avoid | Collection expression ["..."] (C# 12) |
var when type is non-obvious |
⚠️ Avoid | Explicit type declaration |