Everyday Work with Numbers: Math, Rounding Modes, Random.Shared, Guid v4/v7 and BigInteger
Math.Round rounds to even by default (2.5 → 2) while decimal formats round away from zero, so for money pass the mode explicitly and round before formatting. Using Bookstore's campaign discount we show MidpointRounding modes, the double-for-money bug and the .NET 11 decimal conversion change, thread safety with Random.Shared, how Guid v7 sorts, and BigInteger, all with real output.
Version note: The code in this post was built and run with .NET 10; the Random.NextInteger and decimal conversion examples were run with the .NET 11 SDK.
Guess First
Three short lines. We write lines like these every day in the pricing service of Bookstore, the online bookstore used as the shared example across this site's series:
Console.WriteLine(Math.Round(2.5));
Console.WriteLine(Math.Round(3.5));
Console.WriteLine(0.1 + 0.2 == 0.3);
2
4
False
The rule we learned at school rounds 2.5 up to 3. Math.Round, however, rounds to even by default: it takes a value exactly halfway to the nearest even number, so 2.5 goes to 2 and 3.5 to 4. The third line comes from double not being able to hold 0.1 exactly. Both turn into cent differences in money calculations.
The first three posts of the series were about text. In this post we move to numbers: the rounding modes of Math.Round and how formatting differs from rounding, decimal helpers, computing a campaign discount to the cent, generic helpers with INumber<T>, Random and Random.Shared, time-ordered Guid v7 for orders, overflow and BigInteger. The basics of numeric types and the decimal/double difference are in the C# Fundamentals post "Numbers and Arithmetic in C#"; here we go into the details of the APIs used every day.
dotnet run --project samples/Bookstore.Bcl.Samples -- numbers-rounding --culture en-US
Math.Round and MidpointRounding
The MidpointRounding enum only decides where a value exactly halfway goes. There have been five modes since .NET Core 3.0. I rounded the same values with each mode:
decimal[] values = [2.5m, 3.5m, -2.5m, 2.4m, 2.6m];
MidpointRounding[] modes =
[
MidpointRounding.ToEven, MidpointRounding.AwayFromZero, MidpointRounding.ToZero,
MidpointRounding.ToNegativeInfinity, MidpointRounding.ToPositiveInfinity,
];
Console.WriteLine($"{"value",-8}" + string.Concat(modes.Select(m => $"{m,20}")));
foreach (decimal value in values)
Console.WriteLine($"{value.ToString(Inv),-8}" + string.Concat(modes.Select(m => $"{Math.Round(value, m).ToString(Inv),20}")));
Console.WriteLine($"Math.Round(2.345m, 2) = {Math.Round(2.345m, 2).ToString(Inv)}");
Console.WriteLine($"Math.Round(2.345m, 2, AwayFromZero) = {Math.Round(2.345m, 2, MidpointRounding.AwayFromZero).ToString(Inv)}");
Console.WriteLine($"Math.Round(2.345, 2, AwayFromZero) (double) = {Math.Round(2.345, 2, MidpointRounding.AwayFromZero).ToString(Inv)}");
Console.WriteLine($"2.345 as G17 = {2.345.ToString("G17", Inv)}");
value ToEven AwayFromZero ToZero ToNegativeInfinity ToPositiveInfinity
2.5 2 3 2 2 3
3.5 4 4 3 3 4
-2.5 -2 -3 -2 -3 -2
2.4 2 2 2 2 3
2.6 3 3 2 2 3
Math.Round(2.345m, 2) = 2.34
Math.Round(2.345m, 2, AwayFromZero) = 2.35
Math.Round(2.345, 2, AwayFromZero) (double) = 2.35
2.345 as G17 = 2.3450000000000002
ToEven, banker's rounding, is the default because it doesn't create a systematic bias when many rounded values are summed. AwayFromZero is the rounding we learned at school: up for positives, down for negatives. Look at the 2.4 and 2.6 rows: despite their names, ToZero, ToNegativeInfinity and ToPositiveInfinity move every value in that direction, not just the midpoint; they are really Truncate, Floor and Ceiling applied at a given digit. The documentation also keeps these three modes apart as "directed rounding".
The last two lines show another trap of rounding with double. As a double, 2.345 is actually 2.3450000000000002; not "exactly halfway" but slightly above it. Here the result happened to come out right, but for any decimal number that can't be held exactly in binary, the midpoint rule is unreliable with double. That's the main reason to use decimal for money and rates.
Formatting Is Not Rounding
In the previous post we saw that format strings round the midpoint away from zero. Rounding the same value with a format and with Math.Round:
Console.WriteLine($"{"",-14}{"F0",6}{"\"0\"",6}{"Math.Round",12}");
Row("decimal 2.5", 2.5m.ToString("F0", Inv), 2.5m.ToString("0", Inv), Math.Round(2.5m).ToString(Inv));
Row("double 2.5", 2.5.ToString("F0", Inv), 2.5.ToString("0", Inv), Math.Round(2.5).ToString(Inv));
Row("decimal 0.125", 0.125m.ToString("F2", Inv), 0.125m.ToString("0.00", Inv), Math.Round(0.125m, 2).ToString(Inv));
Row("double 0.125", 0.125.ToString("F2", Inv), 0.125.ToString("0.00", Inv), Math.Round(0.125, 2).ToString(Inv));
F0 "0" Math.Round
decimal 2.5 3 3 2
double 2.5 2 3 2
decimal 0.125 0.13 0.13 0.12
double 0.125 0.12 0.13 0.12
There are three different rules: the standard formats of decimal and all custom formats round away from zero, the standard formats of double (since .NET Core 3.0) round to even, and Math.Round rounds to even by default. If you show the line amount on an invoice with F2 and compute the total with Math.Round, the lines on screen may not add up to the total below. The fix: round the value first with an explicit mode, then format it. A format's job should be display, not rounding.
decimal, double and Money
decimal works in base ten, holds 28-29 significant digits and represents 0.1 exactly. double works in base two, is faster and suits scientific calculation, but can't hold 0.1 exactly:
double total = 0;
decimal exact = 0;
for (int i = 0; i < 10; i++)
{
total += 0.1;
exact += 0.1m;
}
Console.WriteLine($"double sum of ten 0.1 = {total.ToString("R", Inv)}, == 1.0: {total == 1.0}");
Console.WriteLine($"decimal sum of ten 0.1 = {exact.ToString(Inv)}, == 1.0m: {exact == 1.0m}");
double price = 349.90;
Console.WriteLine($"349.90 * 3 as double = {(price * 3).ToString("R", Inv)}");
Console.WriteLine($"349.90m * 3 as decimal = {(349.90m * 3).ToString(Inv)}");
Console.WriteLine($"(decimal)0.1 (double literal) = {((decimal)0.1).ToString(Inv)}");
Console.WriteLine($"(decimal)349.90 (double literal) = {((decimal)349.90).ToString(Inv)}");
double sum of ten 0.1 = 0.9999999999999999, == 1.0: False
decimal sum of ten 0.1 = 1.0, == 1.0m: True
349.90 * 3 as double = 1049.6999999999998
349.90m * 3 as decimal = 1049.70
(decimal)0.1 (double literal) = 0.1
(decimal)349.90 (double literal) = 349.9
The total of three copies of Dune is 1049.6999999999998 as a double. On screen F2 shows 1049.70, but this value is what goes to the database, the payment provider or a comparison. decimal gives 1049.70; the trailing zero is preserved too, because decimal carries its scale.
The last two lines show that in .NET 10 converting a double to decimal rounds the value to about 15 significant digits; the 0.1 literal turns into a "clean" 0.1m. This behavior changed in .NET 11. Running the same lines in the Bookstore.Bcl.Net11 project:
Console.WriteLine($"(decimal)0.1 = {((decimal)0.1).ToString(invariant)}");
Console.WriteLine($"(decimal)349.90 = {((decimal)349.90).ToString(invariant)}");
Console.WriteLine($"349.90m = {349.90m.ToString(invariant)}");
(decimal)0.1 = 0.1000000000000000055511151231
(decimal)349.90 = 349.89999999999997726263245568
349.90m = 349.90
.NET 11 converts from the exact binary value of the source double with a single rounding; Microsoft doesn't offer a compatibility switch for it. Old code that reads a price as a double and converts it to decimal starts storing the price as 349.89999999999997726263245568 instead of 349.90 after moving to .NET 11, and that value produces cent differences in rounding. The rule hasn't changed, only the cost of breaking it has gone up: read money as decimal from the start (349.90m, decimal.Parse, decimal(18,2) in the database).
The decimal helpers are worth knowing too:
decimal price = -12.75m;
Console.WriteLine($"Floor {decimal.Floor(price).ToString(Inv),8} Ceiling {decimal.Ceiling(price).ToString(Inv),8}");
Console.WriteLine($"Truncate {decimal.Truncate(price).ToString(Inv),8} Round {decimal.Round(price, 1).ToString(Inv),8}");
Console.WriteLine($"Clamp(150, 0, 100) = {Math.Clamp(150, 0, 100)}, Clamp(-0.05m, 0m, 0.5m) = {Math.Clamp(-0.05m, 0m, 0.5m).ToString(Inv)}");
Console.WriteLine($"Scale of 1.50m = {(1.50m).Scale}, 1.50m == 1.5m: {1.50m == 1.5m}, ToString: {1.50m.ToString(Inv)} vs {1.5m.ToString(Inv)}");
Floor -13 Ceiling -12
Truncate -12 Round -12.8
Clamp(150, 0, 100) = 100, Clamp(-0.05m, 0m, 0.5m) = 0
Scale of 1.50m = 2, 1.50m == 1.5m: True, ToString: 1.50 vs 1.5
For a negative number Floor and Truncate give different results: Floor goes toward negative infinity, Truncate toward zero. That difference matters when you work with negative values such as refund amounts. decimal.Round(-12.75m, 1) became -12.8 with round-to-even. Math.Clamp is a one-line answer for bounds checks such as squeezing a discount rate between 0 and 50 percent. 1.50m and 1.5m are equal but print differently; the difference shows up in a price sent as JSON, so fixing the scale at the API boundary with decimal.Round(x, 2) is a good habit.
Generic Helpers with INumber<T>
Generic math, introduced in .NET 7, lets you write the same calculation once for every numeric type. A helper that computes a percentage:
// Works for int, long, decimal, double...: any type that implements INumber<T>.
public static T PercentOf<T>(T value, T percent) where T : INumber<T> =>
value * percent / T.CreateChecked(100);
Console.WriteLine($"int : {PercentOf(250, 15)}");
Console.WriteLine($"decimal : {PercentOf(349.90m, 15m).ToString(Inv)}");
Console.WriteLine($"double : {PercentOf(349.90, 15.0).ToString(Inv)}");
Console.WriteLine($"byte : {PercentOf<byte>(200, 50)}");
int : 37
decimal : 52.485
double : 52.485
byte : 0
With int, integer division cut 37.5 to 37. The byte line is more interesting: 200 × 50 = 10,000 doesn't fit in a byte, so the intermediate result silently overflowed (10,000 mod 256 = 16) and 16 / 100 gave 0. Generic code makes it easy to forget a type's limits; if you want to catch overflow, do the multiplication in a checked block or convert to a wider type. Generic math is covered in detail in the C# Collections and Generics post "C# 11–12 and .NET 7–8: Generic Math, Frozen and Collection Expressions".
Bookstore: Computing a Campaign Discount to the Cent
There are three books in the cart and the campaign gives a 15 percent discount. Do we round the discount on each line or once on the subtotal?
(string Title, decimal Price)[] cart = [("Dune", 349.90m), ("Emma", 129.90m), ("Middlemarch", 249.90m)];
const decimal rate = 0.15m;
decimal perLine = 0;
foreach (var (title, price) in cart)
{
decimal raw = price * rate;
decimal rounded = Math.Round(raw, 2, MidpointRounding.AwayFromZero);
perLine += rounded;
Console.WriteLine($"{title,-12} {price.ToString("F2", Inv),8} x 15% = {raw.ToString(Inv),-10} -> {rounded.ToString("F2", Inv)}");
}
decimal subtotal = cart.Sum(line => line.Price);
decimal onTotal = Math.Round(subtotal * rate, 2, MidpointRounding.AwayFromZero);
Console.WriteLine($"Sum of rounded lines = {perLine.ToString("F2", Inv)}");
Console.WriteLine($"Rounded on subtotal = {onTotal.ToString("F2", Inv)} (subtotal {subtotal.ToString("F2", Inv)})");
Console.WriteLine($"ToEven on 52.485 = {Math.Round(349.90m * rate, 2).ToString(Inv)}");
Dune 349.90 x 15% = 52.4850 -> 52.49
Emma 129.90 x 15% = 19.4850 -> 19.49
Middlemarch 249.90 x 15% = 37.4850 -> 37.49
Sum of rounded lines = 109.47
Rounded on subtotal = 109.46 (subtotal 729.70)
ToEven on 52.485 = 52.48
Since all prices end in ,90, every line's discount lands exactly on half a kuruş; rounding per line goes up three times and the total discount becomes 109.47 TL. Rounding once on the subtotal gives 109.46 TL. A one-kuruş difference looks small, but when the invoice lines don't add up to the invoice total, the accounting integration rejects the order. The last line rounds the same line with the default ToEven: 52.48. So choosing the mode is a business rule too.
The right approach is to choose one rule and apply it everywhere. Which rule applies depends on what the accounting and e-invoice integration expects; in Bookstore we chose AwayFromZero, which rounds half a kuruş up. If you compute the discount on the subtotal and distribute it across lines, you put the rounding difference on the last line or on the line with the largest remainder, so the line totals always equal the subtotal discount. That distribution algorithm and the Money value object are covered in detail in the "Money and Rounding" post of the Solution Patterns series.
Random, Random.Shared and RandomNumberGenerator
.NET has three sources of random numbers, and each has its place:
var seeded1 = new Random(2026);
var seeded2 = new Random(2026);
Console.WriteLine($"new Random(2026) twice : {seeded1.Next(1000)} {seeded1.Next(1000)} {seeded1.Next(1000)} | {seeded2.Next(1000)} {seeded2.Next(1000)} {seeded2.Next(1000)}");
var unseeded = Enumerable.Range(0, 3).Select(_ => new Random().Next(1000)).ToArray();
Console.WriteLine($"new Random() x3 in a loop: {string.Join(" ", unseeded)}");
string[] featured = ["Dune", "Emma", "Middlemarch", "The Hobbit", "Ulysses", "Ivanhoe"];
var picker = new Random(7);
Console.WriteLine($"GetItems(3), seed 7 : {string.Join(", ", picker.GetItems(featured, 3))}");
string[] shuffled = [.. featured];
picker.Shuffle(shuffled);
Console.WriteLine($"Shuffle, seed 7 : {string.Join(", ", shuffled)}");
string coupon = RandomNumberGenerator.GetString("ABCDEFGHJKLMNPQRSTUVWXYZ23456789", 10);
Console.WriteLine($"Coupon code length {coupon.Length}, only allowed chars: {coupon.All(c => "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".Contains(c))}");
new Random(2026) twice : 159 283 917 | 159 283 917
new Random() x3 in a loop: 628 423 548
GetItems(3), seed 7 : Middlemarch, Ivanhoe, The Hobbit
Shuffle, seed 7 : Dune, Middlemarch, Ulysses, The Hobbit, Ivanhoe, Emma
Coupon code length 10, only allowed chars: True
A seeded Random produces the same sequence every time; that's what you want in tests and reproducible simulations. Unseeded Random instances created in a loop give different values on .NET Core. On .NET Framework, though, the seed came from the system clock, so instances created in the same millisecond produced the same sequence; that was the cause of the "the same book is always featured" bug in old code. GetItems, added in .NET 8, makes a random selection from an array that may repeat items, and Shuffle shuffles an array in place; enough for a feature like "today's recommended books".
For a coupon code, a password reset token or any value that must not be guessable, use RandomNumberGenerator, not Random. Random's algorithm is fast but predictable; someone who sees a few outputs can compute the next ones. RandomNumberGenerator.GetString from .NET 8 produces a cryptographically secure string from the given character set (in the example we removed the easily confused 0/O and 1/I from the set). We printed the code's length and a character check rather than the code itself, since it differs on every run. Cryptographic randomness is covered in the first two posts of the Cryptography for Developers series.
The third topic is thread safety. The methods of a Random instance are not thread-safe. I measured what happens when one instance is used from many threads:
Console.WriteLine($"one new Random() from many threads: {DistinctValues(new Random().Next):N0} distinct of 1,000,000");
Console.WriteLine($"Random.Shared from many threads : {DistinctValues(Random.Shared.Next):N0} distinct of 1,000,000");
var seeded = new Random(42);
int zeros = 0;
Parallel.For(0, 2_000_000, _ =>
{
if (seeded.Next() == 0)
Interlocked.Increment(ref zeros);
});
Console.WriteLine($"one new Random(42) from many threads: Next() returned 0 {zeros:N0} times");
static int DistinctValues(Func<int> next)
{
var seen = new System.Collections.Concurrent.ConcurrentDictionary<int, byte>();
Parallel.For(0, 1_000_000, _ => seen.TryAdd(next(), 0));
return seen.Count;
}
one new Random() from many threads: 471,555 distinct of 1,000,000
Random.Shared from many threads : 999,779 distinct of 1,000,000
one new Random(42) from many threads: Next() returned 0 5 times
A single shared new Random() produced only 471 thousand distinct values out of a million calls; threads read the same internal state and got the same number. Once the seeded instance's internal state is corrupted, Next() starts returning zero: 5 times in this run, and 1,968,622 times out of two million calls in the previous run. The result differs on every run, which is why tests don't catch the bug. Random.Shared, added in .NET 6, is thread-safe; use it when you need random numbers across the application, and if you need a seed, give each thread its own instance.
.NET 11: Random.NextInteger<T> and NextBinaryFloat<T>
.NET 11 added two generic math based methods to Random: NextInteger<T> works for every integer type that implements IBinaryInteger<T>, and NextBinaryFloat<T> for floating-point types such as float, double and Half:
var seeded = new Random(2026);
long orderSuffix = seeded.NextInteger<long>(1_000_000L, 10_000_000L);
byte shelf = seeded.NextInteger<byte>((byte)10);
UInt128 big = seeded.NextInteger<UInt128>();
float weight = seeded.NextBinaryFloat<float>();
NextInteger<long> : 2272414
NextInteger<byte> : 6
NextInteger<UInt128>: 229278156830368596334367352717911969374
NextBinaryFloat<float>: 0.80737674
Before this you needed NextInt64 for long, (byte)Next(10) for byte, and hand-written code combining two NextInt64 calls for UInt128. The new methods can also be used inside a generic helper.
Guid v4 and v7: Time-Ordered Ids for Orders
Guid.NewGuid() produces a version 4 UUID from RFC 9562: 122 bits are random. Guid.CreateVersion7(), added in .NET 9, produces version 7: the first 48 bits are a Unix timestamp in milliseconds and the rest is random. The result is an id that sorts by creation order:
Guid v4 = Guid.NewGuid();
Guid v7 = Guid.CreateVersion7();
Console.WriteLine($"NewGuid {v4} Version={v4.Version}");
Console.WriteLine($"CreateVersion7 {v7} Version={v7.Version}");
DateTimeOffset placed = new(2026, 11, 14, 9, 5, 7, TimeSpan.Zero);
Guid[] ids = [.. Enumerable.Range(0, 5).Select(i => Guid.CreateVersion7(placed.AddMilliseconds(i)))];
foreach (Guid id in ids)
Console.WriteLine($"{id} -> {ExtractTimestamp(id):yyyy-MM-dd HH:mm:ss.fff}");
bool sortedAsGuid = ids.SequenceEqual(ids.Order());
bool sortedAsString = ids.Select(g => g.ToString()).SequenceEqual(ids.Select(g => g.ToString()).Order(StringComparer.Ordinal));
Console.WriteLine($"Order() keeps creation order: {sortedAsGuid}, string order keeps it: {sortedAsString}");
Guid[] sameMillisecond = [.. Enumerable.Range(0, 5).Select(_ => Guid.CreateVersion7(placed))];
Console.WriteLine($"Five ids in the same millisecond keep creation order: {sameMillisecond.SequenceEqual(sameMillisecond.Order())}");
// The first 48 bits of a version 7 UUID are the Unix time in milliseconds (big-endian).
public static DateTimeOffset ExtractTimestamp(Guid id)
{
Span<byte> bytes = stackalloc byte[16];
id.TryWriteBytes(bytes, bigEndian: true, out _);
long milliseconds = 0;
for (int i = 0; i < 6; i++)
milliseconds = (milliseconds << 8) | bytes[i];
return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds);
}
NewGuid 401511c2-b385-43d5-9e5a-01a56b63028c Version=4
CreateVersion7 01a1dc54-398a-74a4-8e15-8561f89beae0 Version=7
01a1d94d-a5b8-7477-9300-2f434a0d9bbd -> 2026-11-14 09:05:07.000
01a1d94d-a5b9-7323-82bf-47ca811071e0 -> 2026-11-14 09:05:07.001
01a1d94d-a5ba-7944-bf3b-fae87bda80ea -> 2026-11-14 09:05:07.002
01a1d94d-a5bb-75e9-aeb6-93031cee5d19 -> 2026-11-14 09:05:07.003
01a1d94d-a5bc-7a8b-845e-6b097aee50a4 -> 2026-11-14 09:05:07.004
Order() keeps creation order: True, string order keeps it: True
Five ids in the same millisecond keep creation order: False
The version number shows up as the first character of the third group of the string (4 and 7); .NET 9 also exposes it as the Version property. The CreateVersion7(DateTimeOffset) overload lets you pass a fixed time in tests. When the milliseconds differ, both Guid's own comparison and string ordering preserve creation order. Ids created within the same millisecond, however, are not ordered: .NET's implementation fills the bits after the timestamp with random data rather than a counter. In a system that creates thousands of orders per second, v7 is an "approximately ordered" id, and exact ordering needs a separate sequence number.
The real promise of v7 is fewer random inserts into a database index. But that promise depends on how the database sorts a Guid: SQL Server compares uniqueidentifier starting from the last 6 bytes, so v7 doesn't give a better clustered index than v4 on SQL Server. The measurements are in the Solution Patterns post "Exposing Ids: int Identity, GUID v7 and the Public Id Split", and a comparison of id strategies in the System Design post "Unique ID Generation". In Bookstore, the order number shown to users (BS-2026-0001234) comes from a separate sequence, while v7 is the technical identity used in messages between services and for log correlation.
Overflow, checked and BigInteger
Integer arithmetic in C# is unchecked by default: overflow doesn't throw, the number silently wraps around.
int unitsSold = int.MaxValue;
int next = unitsSold + 1;
Console.WriteLine($"int.MaxValue + 1 (unchecked) = {next}");
try
{
int boom = checked(unitsSold + 1);
Console.WriteLine(boom);
}
catch (OverflowException ex)
{
Console.WriteLine($"checked: {ex.GetType().Name}: {ex.Message}");
}
Console.WriteLine($"Math.BigMul(int.MaxValue, int.MaxValue) = {Math.BigMul(int.MaxValue, int.MaxValue)}");
Console.WriteLine($"Int128.MaxValue = {Int128.MaxValue}");
BigInteger permutations = 1;
for (int i = 2; i <= 30; i++)
permutations *= i;
Console.WriteLine($"30! shelf orderings = {permutations:N0}");
Console.WriteLine($"BigInteger.Pow(2, 100) = {BigInteger.Pow(2, 100)}");
Console.WriteLine($"Parse 978044117271978044117271 + 1 = {BigInteger.Parse("978044117271978044117271", Inv) + 1}");
int.MaxValue + 1 (unchecked) = -2147483648
checked: OverflowException: Arithmetic operation resulted in an overflow.
Math.BigMul(int.MaxValue, int.MaxValue) = 4611686014132420609
Int128.MaxValue = 170141183460469231731687303715884105727
30! shelf orderings = 265,252,859,812,191,058,636,308,480,000,000
BigInteger.Pow(2, 100) = 1267650600228229401496703205376
Parse 978044117271978044117271 + 1 = 978044117271978044117272
For counters that can grow, such as units sold, a checked block or the <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> project setting turns overflow into a visible error instead of a silent negative number. Math.BigMul returns the product of two ints as a long without overflow. Int128 from .NET 7 is a fixed-width 128-bit integer, while BigInteger is an integer of unlimited size; use it for values like the number of ways 30 books can be ordered on a shelf (30!). BigInteger allocates memory on every operation and is much slower than long; think of it for combinatorics and cryptographic math, not money. decimal's own arithmetic already throws OverflowException on overflow.
In Production
Don't scatter the rounding rule across the code. Bookstore should have one helper that rounds amounts (for example Money.Round) and one MidpointRounding value; when an accounting or tax rule changes, you change one place. The same rule must hold in the database: SQL Server's ROUND function rounds away from zero, so if the application rounds with ToEven, reports and invoices won't match.
When moving to .NET 11, search for code that converts double to decimal. The change produces no compile error or warning; it only changes the last digits of the result. Finding (decimal) casts and Convert.ToDecimal(double) calls and switching their source to decimal belongs on the to-do list before the migration.
Randomness makes testing harder. To make code that picks randomly testable, take the Random through the constructor and pass a seeded instance in tests; the same approach is done for time with TimeProvider, covered in the sixth post of the series. Guid.CreateVersion7 depends on time too, so use the overload that takes a DateTimeOffset in tests.
Common Pitfalls
The Math.Round(2.5) == 2 surprise. The default mode is ToEven; for money, pass the mode explicitly on every call.
Assuming formatting and Math.Round apply the same rule. decimal formats round away from zero, Math.Round rounds to even; round first, then display.
Doing money math with double. The total of three books becomes 1049.6999999999998; on .NET 11 a price converted from double becomes 349.89999999999997726263245568.
Sharing a single Random instance across threads. Values repeat or turn into zeros; use Random.Shared. For values that need security, use RandomNumberGenerator, not any kind of Random.
Thinking v7 gives an ordered index in every database. SQL Server's uniqueidentifier ordering breaks that, and v7s created in the same millisecond aren't ordered among themselves either.
A Small Experiment
★☆☆ Add the value -2.345m to the numbers-rounding example and round it to two digits with all five modes. Find the case where ToNegativeInfinity and decimal.Floor give the same result.
★★☆ Add a distribution step to the campaign example: compute the discount on the subtotal, distribute it proportionally across the lines, put the rounding difference on the line with the largest remainder, and verify that the sum of the line discounts equals the subtotal discount.
★★★ Write an OrderCodeGenerator that produces an unguessable 10-character code with a BS- prefix and no easily confused letters. Generate a million codes, measure the number of collisions and compare it with the birthday problem estimate.
That's it for numbers. In the next post we move to bytes: Encoding.UTF8 and the BOM, Windows-1254 encoded fields coming from the old accounting system, Base64 and Base64Url, hex conversion, and building the URL for a "Şeker Portakalı" search correctly without string concatenation.