A number includes a calculation policy
decimal can represent values such as 1.005m exactly, but it does not choose the application’s rounding rule. Specify precision, mode and calculation stage. Math.Round(2.345m, 2, MidpointRounding.ToEven) resolves the midpoint to 2.34; AwayFromZero chooses 2.35.
Away from a midpoint, these two modes choose the nearest value. Rounding each contribution before summing can produce a different total from rounding once at the end. Two contributions of 1.005m produce 2.02m with individual AwayFromZero rounding, but 2.01m when summed first.
Neither order is universally correct: the report contract decides. Retain original values and the policy used so reconciliation is possible; formatting to two places does not correct a sum calculated under another rule.
Interpret text with explicit culture
A data protocol should not depend on the culture of the machine processing it. Define the decimal separator and permitted components. Decimal.TryParse with CultureInfo.InvariantCulture and NumberStyles.AllowDecimalPoint accepts a decimal point but does not permit grouping commas or signs unless those styles are added.
The string "12,50" should be rejected under that contract, not silently reinterpreted. NumberStyles.Number is more permissive and allows thousands separators; selecting it without reading the contract can turn malformed input into another valid number.
Check the TryParse Boolean first and then domain limits. A syntactically valid number can be negative, excessive or have precision incompatible with the product. Do not use the out value from a failed conversion as if it were a real measurement.
Calendar date, instant and recurrence
Choose the type according to data meaning. A start date without a time can be DateOnly. An event that occurred at an instant can use DateTimeOffset with a known offset. 10:00+01:00 and 09:00+00:00 represent the same instant; Equals compares that equivalence, not identical representation.
A rule such as "09:00 local on every business day" needs more information: region/time zone, calendar and decisions for ambiguous or nonexistent local times. An offset observed today does not describe the region’s future transitions. Separating the civil schedule from the calculated occurrence avoids confusing a 24-hour duration with the next business day at the same local time.
When storing data, retain enough information to answer the consumer’s question, including origin when needed to interpret time.
Labeling a time is not converting it
DateTime.SpecifyKind creates a value with the same ticks and another Kind. It does not shift a clock time to preserve an instant in another time reference. If the source states that 10:00 had offset +02:00, assigning Kind.Utc retains 10:00 and now labels the wrong instant; the UTC equivalent would be 08:00.
Source data may include an explicit offset or require a zone and an ambiguity policy. Do not guess that information from the server’s location. Similarly, formatting a date with a Z does not prove correct conversion.
During review, track three separate things: the received clock value, the temporal information interpreting it and the operation used to obtain the intended instant.
JSON: names and presence are contracts
With fresh JsonSerializerOptions, name matching is case-sensitive. A Count property is not populated by count without additional configuration; web options have different defaults. Therefore, make the active option set explicit. required demands payload presence during deserialization: if Count is required int, {} fails even though int has a default value of zero.
A supplied property must also be compatible with its type. Unknown members are ignored by default; UnmappedMemberHandling.Disallow allows rejection when the protocol requires a closed schema. This choice affects evolution: a new field may be rejected by a strict consumer.
Define compatibility and contract version before choosing tolerance or rejection.
Presence, null and domain validity
In .NET 10, RespectNullableAnnotations = true can enforce some nullability annotations during deserialization. For a non-nullable, non-required string property initialized to "default", {} retains the initializer, but {"Name":null} violates the annotation.
The option does not make every property required. required and nullability answer different questions: did the member appear, and may its value be null? A third question remains: is the value useful for the domain? An empty string can pass the first two checks while remaining an invalid identifier.
The application needs that explicit validation. Annotation enforcement also has limits, including some generic contexts and collection elements; do not treat it as complete validation of every object in a graph.
Guided reading: an explicit input boundary
The example accepts bounded-size JSON with a string Id and numeric Amount. It requires both members, rejects unknown names and explicit null for Id, and validates content before returning. {} fails for missing members; {"Id":"A","Amount":-1} is syntactically valid but fails the domain rule; {"Id":"A","Amount":2.345} produces accepted input with no implicit rounding.
The one-million limit belongs only to this example. A null result from a JSON null root is also explicitly rejected because the annotation option does not enforce that constraint at the root. The code is not a date parser or a monetary-value policy.
Time and rounding cases must apply their own contracts after accepting the relevant data.
#nullable enable
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
public sealed class MeasureInput
{
public required string Id { get; set; }
public required decimal Amount { get; set; }
}
public static class Inputs
{
public static MeasureInput Read(string json)
{
var options = new JsonSerializerOptions
{
RespectNullableAnnotations = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
};
var value = JsonSerializer.Deserialize<MeasureInput>(json, options)
?? throw new JsonException("An object is required.");
if (string.IsNullOrWhiteSpace(value.Id) || value.Amount < 0m
|| value.Amount > 1_000_000m)
throw new ArgumentException("Invalid measurement.");
return value;
}
}Input {"Id":"A","Amount":2.345} is accepted without changing precision; Amount -1 is rejected by the explicit rule. Executing code is not required to answer.
Common pitfalls
Using machine culture for protocols; rounding at the wrong stage; confusing offset with zone; using SpecifyKind as conversion; assuming required also validates domain meaning.
Related topics: Types, conversions and numeric boundaries · References, nullability and text · Exceptions, resources and files
A value is useful when its representation, meaning and acceptance rules are explicit.
Reference: Math.Round · C# 14 / .NET 10; no preview features