A code review that combines encoding and endianness: ASCII data loss, truncating by chars instead of bytes, splitting surrogate pairs, BitConverter's machine byte order, missing validation, and an allocation-free rewrite with BinaryPrimitives and UTF-8.
Several real bugs. Encoding.ASCII turns José into Jos?, so names are corrupted. The field limit is 32 bytes, but the code truncates at 32 chars; once you fix the encoding to UTF-8, a 32-char name can be up to 96 bytes and overflows the buffer. Substring can also split a surrogate pair. BitConverter writes the machine's little-endian order, but the partner specification says big-endian, so every ID and length is wrong. I'd rewrite it with BinaryPrimitives, UTF-8 truncated on grapheme boundaries to 32 bytes, input validation, and golden-byte tests from the partner's specification.
// Partner spec: [int32 customerId, big-endian][uint16 nameLength, big-endian][32 bytes name, UTF-8, zero-padded]
public byte[] BuildMessage(Customer customer)
{
var name = customer.Name.Length > 32 ? customer.Name.Substring(0, 32) : customer.Name;
var nameBytes = Encoding.ASCII.GetBytes(name);
var buffer = new byte[4 + 2 + 32];
BitConverter.GetBytes(customer.Id).CopyTo(buffer, 0);
BitConverter.GetBytes((short)nameBytes.Length).CopyTo(buffer, 4);
nameBytes.CopyTo(buffer, 6);
return buffer;
}
| # | Issue | Impact |
|---|---|---|
| 1 | BitConverter uses machine byte order (little-endian on x64/Arm64); spec says big-endian |
Every customer ID and length is wrong at the partner; ID 1 is read as 16,777,216 |
| 2 | Encoding.ASCII replaces every non-ASCII character with ? |
Customer names are corrupted (Nguyễn becomes Nguy?n); spec says UTF-8 |
| 3 | Truncates to 32 chars, but the field is 32 bytes | After switching to UTF-8, names with accents, CJK or emoji overflow and CopyTo throws ArgumentException |
| 4 | Substring(0, 32) can split a surrogate pair or grapheme |
Invalid UTF-16; encodes as � or a broken character |
| 5 | Length written as signed short |
Spec says uint16; harmless for 32 but signals carelessness |
| 6 | No null check on customer or Name; no validation of Id |
NullReferenceException deep in the method; negative IDs sent if the spec forbids them |
| 7 | Several temporary arrays per message | Minor here; matters at high message rates |
See the code example: it writes into a caller-supplied Span<byte> (or a fresh array), uses BinaryPrimitives.WriteInt32BigEndian and WriteUInt16BigEndian, encodes the name as UTF-8, truncates on grapheme boundaries to fit 32 bytes, zero-pads the rest, and validates inputs.
This exact pattern caused a reconciliation incident with a payments partner: about 3% of records were rejected for name mismatches (ASCII ?), and an earlier change to UTF-8 without fixing the byte-based truncation caused intermittent ArgumentExceptions that dropped messages. Golden-byte tests would have caught both before release.
Checks whether the candidate can spot subtle, high-impact bugs in low-level code and communicate them well.
Green flags: finds byte order, data loss, bytes versus chars, surrogate splitting; proposes BinaryPrimitives and grapheme-aware truncation; prioritises; asks for golden-byte tests; raises contract questions.
Red flags: only comments on naming or var; misses BitConverter byte order; "just switch to UTF-8" without noticing the overflow.
For Id = 1, Name = "José" on an x64 machine:
expected (spec): 00 00 00 01 | 00 05 | 4A 6F 73 C3 A9 00 00 ... 00
sent (original): 01 00 00 00 | 04 00 | 4A 6F 73 3F 00 00 ... 00
^ ID reads as 16,777,216 ^ length 1,024 ^ '?' instead of é
One hex dump makes the whole review obvious, which is why golden-byte tests are so effective.
| Rule | Unit | API |
|---|---|---|
| Field holds 32 bytes | UTF-8 bytes | Encoding.UTF8.GetByteCount / GetBytes(span) |
| Must not split a character | Grapheme clusters | StringInfo.GetNextTextElementLength |
string.Length |
UTF-16 code units | Irrelevant to this specification |
Worst cases: a CJK character is 3 bytes (so about 10 fit in 32), an emoji with a skin-tone modifier is 8 bytes.
for each grapheme cluster in name:
bytes = UTF-8 byte count of the cluster
if written + bytes > 32: stop
encode the cluster into the field, written += bytes
pad the remaining bytes with 0x00
write nameLength = written (big-endian uint16)
Alternative: System.Text.Unicode.Utf8.FromUtf16 writes only complete scalar values when the destination is too small (returns DestinationTooSmall), which avoids invalid UTF-8 but can still split a grapheme such as an emoji with a modifier. Use grapheme truncation when the text is user-visible at the other end.
BitConverter.GetBytes or Encoding.GetBytes.BinaryPrimitives is explicit about byte order and compiles to a byte-swap instruction.ArrayPool<byte>.Shared) or a PipeWriter span.nameLength the used bytes or always 32?é, emoji at the boundary, empty name.José and print the hex; compare it with the expected bytes above.namespace PartnerProtocol;
using System.Buffers.Binary;
using System.Globalization;
using System.Text;
public sealed record Customer(int Id, string Name);
public static class PartnerMessage
{
public const int NameFieldBytes = 32;
public const int Size = 4 + 2 + NameFieldBytes;
public static byte[] Build(Customer customer)
{
var buffer = new byte[Size];
Write(customer, buffer);
return buffer;
}
// Spec: [int32 id BE][uint16 nameLength BE][32 bytes UTF-8 name, zero-padded]
public static void Write(Customer customer, Span<byte> destination)
{
ArgumentNullException.ThrowIfNull(customer);
ArgumentNullException.ThrowIfNull(customer.Name);
ArgumentOutOfRangeException.ThrowIfNegative(customer.Id);
if (destination.Length < Size)
{
throw new ArgumentException($"Destination must be at least {Size} bytes.", nameof(destination));
}
BinaryPrimitives.WriteInt32BigEndian(destination, customer.Id);
Span<byte> nameField = destination.Slice(6, NameFieldBytes);
nameField.Clear(); // zero padding
int written = EncodeTruncated(customer.Name, nameField);
BinaryPrimitives.WriteUInt16BigEndian(destination.Slice(4, 2), (ushort)written);
}
// Encodes whole grapheme clusters as UTF-8 until the next one would not fit.
private static int EncodeTruncated(ReadOnlySpan<char> text, Span<byte> field)
{
int written = 0;
while (!text.IsEmpty)
{
int length = StringInfo.GetNextTextElementLength(text);
ReadOnlySpan<char> element = text[..length];
int needed = Encoding.UTF8.GetByteCount(element);
if (written + needed > field.Length)
{
break;
}
written += Encoding.UTF8.GetBytes(element, field[written..]);
text = text[length..];
}
return written;
}
}
// xUnit golden-byte test
public class PartnerMessageTests
{
[Fact]
public void Matches_partner_specification_example()
{
byte[] bytes = PartnerMessage.Build(new Customer(1, "José"));
string expected = "00000001" + "0005" + "4A6F73C3A9" + new string('0', 27 * 2);
Assert.Equal(expected, Convert.ToHexString(bytes));
}
}
Mistake: Using Encoding.ASCII for text fields.
Why it is wrong: every character above U+007F becomes ?. The data loss is silent, so it is only noticed when a customer complains that their name arrived as Jos?.
Instead: use the encoding the spec names. For UTF-8, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) makes invalid data throw instead of being replaced.
Mistake: Truncating with Substring(0, 32) for a 32-byte field.
Why it is wrong: 32 chars of UTF-8 text can be anywhere from 32 to 96+ bytes, so the field overflows and shifts every later field in the message.
Instead: encode, measure Encoding.UTF8.GetByteCount, and cut on a boundary that fits the byte budget.
Mistake: Cutting the byte array at exactly 32 bytes.
Why it is wrong: it can split a multi-byte UTF-8 sequence or a surrogate pair, producing invalid UTF-8 that the partner may reject or decode as U+FFFD.
Instead: truncate on text-element (grapheme) boundaries with StringInfo/TextElementEnumerator or Rune enumeration, then encode.
Mistake: Using BitConverter.GetBytes for integers in a big-endian protocol.
Why it is wrong: it writes in host order, which is little-endian on all mainstream .NET platforms, so every multi-byte number is reversed on the wire.
Instead: BinaryPrimitives.WriteInt32BigEndian(span, value) states the byte order in the code.
Mistake: Implementing the format without agreeing the contract with the partner. Why it is wrong: the code can be perfectly correct against an assumption the partner does not share: which encoding, how fields are padded (spaces vs NUL bytes), whether truncation is allowed or must be rejected, how the message is versioned, and what happens on a malformed message. Instead: write those decisions into the interface spec, version the message (a version byte or header), and share golden-byte test vectors both sides run.
Mistake: Allocating a new array for every field.
Why it is wrong: at high message rates the garbage adds GC pressure and latency for no benefit.
Instead: compute the fixed message size, rent one buffer (or stackalloc for small messages) and write each field into a slice of it.
A senior reviewer treats this as a contract problem first and a coding problem second. The bugs here (ASCII loss, char vs byte length, endianness) all come from code that was written against an unstated assumption.
| Concern | Risk if ignored | What to do |
|---|---|---|
| Encoding | Silent ? substitution, corrupted names |
Use the spec's encoding, throwing encoder, test with non-ASCII data |
| Field length | Overflow shifts every later field | Truncate or reject by byte count, on grapheme boundaries |
| Byte order | Numbers reversed on the wire | BinaryPrimitives BigEndian methods, never BitConverter |
| Validation | Partner receives a different value than intended | Validate and either reject or log truncation with a metric |
| Versioning | Any change breaks the partner | Version byte/header, agreed change process |
| Performance | Per-field allocations, GC pauses | One pre-sized buffer, Span<byte>, ArrayPool<byte> |
| Security | Unbounded input, injection of control bytes | Enforce max lengths before encoding, reject control characters the spec forbids |
Performance and memory: Encoding.UTF8.GetBytes(ReadOnlySpan<char>, Span<byte>) and BinaryPrimitives write straight into the destination span, so a whole message can be built with zero intermediate allocations. Grapheme-aware truncation costs more than a byte cut, so only take that path when the encoded length exceeds the budget.
Trade-off: silently truncating keeps messages flowing but changes customer data; rejecting is safer but needs an error path the partner understands. The right answer is whatever the contract says, which is why agreeing it up front matters more than any single line of code.
Endianness is the order in which the bytes of a multi-byte value are stored. Little-endian puts the least significant byte first, so 0x12345678 is stored as 78 56 34 12. Big-endian puts the most significant byte first: 12 34 56 78. x64 and Arm64 machines running .NET are little-endian, but network protocols ("network byte order") and many file formats are big-endian. In .NET I use BinaryPrimitives.ReadInt32BigEndian and friends so the byte order is explicit and portable. BitConverter follows the machine, and BinaryReader/BinaryWriter are always little-endian.
Both encode Unicode code points. UTF-8 uses 1–4 bytes per code point and is ASCII-compatible, so it is compact for English (half the memory of UTF-16) and dominates files, HTTP and JSON. UTF-16 uses 2 bytes for the Basic Multilingual Plane and 4 bytes (a surrogate pair) above U+FFFF, such as most emoji; it is more compact than UTF-8 for CJK text (2 bytes instead of 3). .NET strings are UTF-16, and string.Length counts UTF-16 code units, not characters: "👍".Length is 2, "👍🏽".Length is 4, and "é" is 1 if precomposed (U+00E9) but 2 if written as e + combining accent. Count what you mean: EnumerateRunes() for code points, StringInfo for user-visible characters, Encoding.UTF8.GetByteCount for byte limits. On high-traffic paths, stay in UTF-8 bytes (u8 literals, spans, Utf8JsonReader) so you do not transcode and allocate on every request.
How to tell it (STAR)
**Situation:** At [company], about [3%] of our daily settlement records to [partner] were rejected, and a recent fix had started throwing exceptions. **Task:** I was asked to review the message builder and stop the rejections. **Action:** I compared our hex output with the partner's specification and found three bugs: ASCII encoding was turning accented names into `?`, the 32-byte name field was truncated by characters so UTF-8 names overflowed, and IDs were little-endian. I rewrote the builder with `BinaryPrimitives` and grapheme-aware UTF-8 truncation and added golden-byte tests, then coordinated the cut-over with the partner. **Result:** Rejections dropped to [near zero], dropped records were replayed, and the golden-byte tests caught a later regression in a refactor.
Last updated 23 Oct 2026.