Contracts, your DTOs, request/response models, and on-wire messages live much longer than the code that first produced them. They cross process and organisational boundaries, and you can’t patch every consumer when you ship a new build. In .NET 8 with C# 12, you have a rich toolbox for designing contracts that are easy to read, hard to misuse, and safe to evolve: required members, primary constructors, and GeneratedRegex. Used together, they make your models self-documenting, your initialisation explicit, and your parsing fast and trimming-friendly.
Why contracts rot (and how to stop it)
Contracts tend to accumulate nullable footguns, optional fields that are actually mandatory, and “just one more string” that means three different things in downstream services. The antidote is to be explicit about what must be present, to keep creation paths narrow, and to parse/validate close to the boundary with predictable performance. C#’s required members turn “this should be set” into a compile-time guarantee for object initialisers. Primary constructors give you a single, obvious creation path without boilerplate. GeneratedRegex replaces ad-hoc parsing helpers and reflection-heavy regex caching with source-generated, AOT-friendly matchers.
Making invariants obvious with required
A contract should express its invariants in the type system. required members force callers to provide values when using object initialisers and make intent crystal clear in reviews and IDE hints. Combine required with init and narrow setters to preserve immutability after construction.
public sealed record Address
{
public required string Line1 { get; init; }
public string? Line2 { get; init; }
public required string City { get; init; }
public required string Postcode { get; init; }
public required string Country { get; init; }
}
If a consumer forgets to set Line1 or Country, they get a compile-time error rather than a runtime 400. For internal creation paths (e.g., mapping from your domain model), you can still use constructors; required does not apply to constructor parameters.
You’ll inevitably need to evolve models. Additive changes are safe: new required members would be a breaking change for existing callers, so prefer optional (string?) with validation at the boundary and a server-side default. When something becomes obsolete, mark it with [Obsolete] and keep it in the contract long enough to migrate clients cleanly.
public sealed record Customer
{
public required Guid Id { get; init; }
public required string Name { get; init; }
[Obsolete("Use PrimaryEmail; this will be removed in v3.")]
public string? Email { get; init; }
public string? PrimaryEmail { get; init; }
}
Narrow creation paths with primary constructors
Primary constructors let you put state requirements front-and-centre while still exposing an init-only surface for serializers. For DTOs, you can mix both: keep a public parameterless constructor for System.Text.Json and provide a primary-constructor-backed type for internal creation, or use a separate domain type and map at the boundary. For options/settings and small request objects, primary constructors shine.
public sealed class CreateOrderRequest(string customerId, IReadOnlyList<OrderLine> lines)
{
public required string CorrelationId { get; init; } // set by middleware
public string CustomerId { get; } =
string.IsNullOrWhiteSpace(customerId)
? throw new ArgumentException("CustomerId is required.", nameof(customerId))
: customerId;
public IReadOnlyList<OrderLine> Lines { get; } =
lines is { Count: > 0 } ? lines :
throw new ArgumentException("At least one line is required.", nameof(lines));
}
public sealed record OrderLine(string Sku, int Quantity)
{
public string Sku { get; } = Sku?.Trim() ?? throw new ArgumentNullException(nameof(Sku));
public int Quantity { get; } = Quantity > 0 ? Quantity : throw new ArgumentOutOfRangeException(nameof(Quantity));
}
The type communicates what must exist (customerId, at least one line). The required CorrelationId is left to the infrastructure to populate. If you expose this publicly, keep the parameterless constructor and guard invariants in OnDeserialized or explicit validators; otherwise, prefer explicit construction at the API boundary and map into an internal command.
Validating without reflection overhead using GeneratedRegex
Regexes show up everywhere: IDs, postcodes, SKUs. The common anti-pattern is new Regex(pattern, RegexOptions.Compiled) sprinkled around, which incurs a startup cost and can be unfriendly to trimming/AOT. GeneratedRegex moves regex compilation to build time via source generation and gives you a strongly-typed, static factory.
using System.Text.RegularExpressions;
public static partial class Parsers
{
[GeneratedRegex(@"^[A-Z]{3}\d{5}$", RegexOptions.CultureInvariant)]
private static partial Regex SkuRegex();
[GeneratedRegex(@"^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex UkPostcodeRegex();
public static bool IsValidSku(string value) => SkuRegex().IsMatch(value);
public static bool IsValidUkPostcode(string value) => UkPostcodeRegex().IsMatch(value);
}
This approach is linker-friendly and avoids runtime compilation. For input normalisation, prefer allocation-free helpers (Span<char>, Rune for Unicode) where reasonable, but keep contracts readable first; micro-optimise only on measured hot paths.
JSON serialisation that evolves gracefully
System.Text.Json in .NET 8 gives you enough control to design forward- and backward-compatible payloads. Set JsonSerializerOptions.PropertyNamingPolicy consistently (camelCase is the web default), opt into IncludeFields only if you must, and never
Rely on constructor parameter names matching JSON names unless you intend to. Requiredness in your C# model is not automatically enforced by the serializer; add a lightweight validator at the boundary.
public static class ApiJson
{
public static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.Strict
};
}
For versioning, prefer additive fields and soft defaults over breaking renames. If you need polymorphism, declare it explicitly:
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(CardPayment), "card")]
[JsonDerivedType(typeof(BankTransfer), "bank")]
public abstract record PaymentMethod;
public sealed record CardPayment(string Last4) : PaymentMethod;
public sealed record BankTransfer(string Iban) : PaymentMethod;
This keeps payloads inspectable and avoids “hidden” subtype binding that breaks under trimming.
Contracts for messages and APIs
HTTP APIs should be explicit about shape and version. The least painful approach is URI or media-type versioning with additive changes only inside a major line. Consumers opt into a new major when they’re ready.
For messaging (Service Bus, Kafka), version with the message name and evolve the schema additively. If you must break, publish a new message type (OrderCreatedV2) and run both for a migration window. Wrap “maybe present” fields in a dedicated type if semantics differ from “missing”.
public readonly record struct Money(decimal Amount, string Currency);
public readonly record struct Optional<T>(bool HasValue, T? Value);
This preserves intent over bare null.
Options and configuration as contracts
Options classes are contracts too. They often leak into multiple services, so make them robust and trimming-safe. Use primary constructors for invariants, required for must-set fields, and bind with ValidateOnStart to fail fast.
public sealed class EmailOptions(string fromAddress)
{
public string FromAddress { get; } =
MailAddress.TryCreate(fromAddress, out _) ? fromAddress
: throw new ArgumentException("Invalid email.", nameof(fromAddress));
public required string SmtpHost { get; init; }
public int Port { get; init; } = 587;
}
// Program.cs
builder.Services.AddOptions<EmailOptions>()
.Bind(builder.Configuration.GetSection("Email"))
.ValidateDataAnnotations()
.ValidateOnStart();
This pattern surfaces misconfiguration at startup, not after the first request.
Avoiding accidental breakage
Once a contract ships, assume a consumer relies on every quirk. Some practical rules help:
Keep JSON names stable even if C# property names change; lock them with [JsonPropertyName("stable")]. Avoid changing types in place (e.g., int to string); introduce a sibling property and deprecate the old one. Don’t overload meaning into a single field; introduce discriminators when new cases appear. Test compatibility with golden files, serialised samples checked into source, so refactors don’t silently change the wire.
public sealed record Product
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("name")]
public required string Name { get; init; }
[Obsolete("Use unitPrice; kept for backward compat.")]
[JsonPropertyName("price")]
public decimal? Price { get; init; }
[JsonPropertyName("unitPrice")]
public Money? UnitPrice { get; init; }
}
Putting it together at the boundary
An API endpoint should accept input, validate with fast, explicit checks, map into a domain command, and return a contract-shaped response. The contract types carry most of the weight.
app.MapPost("/v2/orders", async (CreateOrderRequest req, IOrderService svc, CancellationToken ct) =>
{
if (!req.Lines.All(l => Parsers.IsValidSku(l.Sku)))
return Results.BadRequest(new { error = "Invalid SKU format." });
var orderId = await svc.CreateAsync(req.CustomerId, req.Lines, req.CorrelationId, ct);
return Results.Created($"/v2/orders/{orderId}", new { id = orderId });
});
There’s no guesswork about what must be present; invariants are encoded in types and a couple of tight checks protect the domain.
A note on performance and AOT
All three features play nicely with trimming and NativeAOT. GeneratedRegex removes runtime regex compilation. Primary constructors and explicit property access keep reflection usage low. Avoid dynamic features in contract code paths: no dynamic, minimal Type.GetProperty, and keep JsonSerializerContext source generation in mind for hot services. If you have very hot endpoints, consider source-generated JSON serializers to remove reflection entirely.
[JsonSerializable(typeof(CreateOrderRequest))]
[JsonSerializable(typeof(OrderLine))]
public partial class ApiJsonContext : JsonSerializerContext { }
// Usage:
// var obj = JsonSerializer.Deserialize(json, ApiJsonContext.Default.CreateOrderRequest);
Great contracts make illegal states unrepresentable, make evolution boring, and make performance predictable. In .NET 8 with C# 12, required members keep initialisation honest, primary constructors keep creation paths tight, and GeneratedRegex gives you fast, analyzable parsing without runtime magic. If you adopt these as your defaults, you’ll spend far less time firefighting “breaking change” regressions, and far more time building features.