📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Review this C# method that builds a fixed-layout binary message for a partner system, including a customer name field limited to 32 bytes. What is wrong and how would you fix it?

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.

Senior CodeReview Advanced binary-protocol code-review computer-basics encoding endianness utf-8

🎯 Key points

  • Encoding.ASCII silently replaces every non-ASCII character with '?', so names such as 'José' or '東京' are corrupted with no error: use the encoding the spec names (usually UTF-8) and a throwing encoder where loss must be detected.
  • The spec field is 32 bytes, not 32 characters. UTF-8 uses 1–4 bytes per code point, so truncation must be done by encoded byte count, never by string.Length or Substring.
  • A surrogate pair is two UTF-16 chars that together encode one code point above U+FFFF (for example most emoji). A grapheme cluster is what a user sees as one character and can span several code points (e.g. 'e' + combining accent, family emoji). Truncating must not split either.
  • The spec mandates big-endian (network byte order) integers. BitConverter uses the machine's endianness (little-endian on x86/x64/ARM64 .NET), so use BinaryPrimitives.WriteInt32BigEndian and friends to make byte order explicit.
  • Validate inputs against the contract (lengths, ranges, required fields) and fail loudly or log the truncation instead of silently sending a different value.
  • Write into a pre-sized Span<byte> or pooled buffer (stackalloc/ArrayPool) rather than concatenating byte arrays, to avoid per-message allocations on a hot path.
  • Prove the wire format with golden-byte tests: fixed inputs must produce an exact, agreed byte sequence, including non-ASCII and boundary-length cases.

⏱ 30-second answer

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.

🗣 Full answer (senior)

The code under review

// 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;
}

Findings, most severe first

# 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

The fixed version

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.

How I'd raise it in the review

  • Lead with the two production-impacting bugs (byte order and data loss), linking to the partner specification section.
  • Ask for a golden-byte test: build a message for a known customer and assert the exact hex the partner documented.
  • Suggest a test set of names with accents, macrons, CJK and emoji, plus a 40-character name.
  • Mark the allocation point as optional.

Production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. What the partner actually receives

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.

2. Counting the right thing

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.

3. Truncation strategy

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.

4. Why Span and BinaryPrimitives

  • The fixed version writes straight into the destination buffer: no intermediate arrays from BitConverter.GetBytes or Encoding.GetBytes.
  • BinaryPrimitives is explicit about byte order and compiles to a byte-swap instruction.
  • Callers sending thousands of messages a second can pass a pooled buffer (ArrayPool<byte>.Shared) or a PipeWriter span.

5. Questions to ask the partner (or check in the spec)

  • Is the name zero-padded or space-padded? Is nameLength the used bytes or always 32?
  • Should truncated names be flagged? Silent truncation of legal names can matter for identity checks.
  • Is there a version byte for future changes? If not, propose one.

6. Testing approach

  • Golden bytes: the partner's sample message as a hex string; assert byte-for-byte equality.
  • Property-style tests: for random Unicode strings, the output field never exceeds 32 bytes, always decodes as valid UTF-8, and is a prefix of the original by graphemes.
  • Boundary tests: exactly 32 ASCII characters, 31 ASCII + é, emoji at the boundary, empty name.

7. Try it yourself

  1. Run the original method for José and print the hex; compare it with the expected bytes above.
  2. Change only the encoding to UTF-8 and pass a 32-character Vietnamese name; observe the exception.
  3. Implement the fixed version and write the golden-byte, boundary and random-string tests.

💻 Code

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));
    }
}

⚠ Common mistakes

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate spots that BitConverter writes little-endian. Why: Checks the depth of the candidate's endianness knowledge beyond this snippet.
    How to answer

    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.

  2. When: Candidate notices the 32-char versus 32-byte mismatch. Why: Probes why chars, code points and bytes are different counts.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Payments partner rejects 3% of records after a name-field change
Situation
A .NET service sent settlement records to a payments partner over a fixed-layout binary protocol, about 1.2 million records a day.
Signals
Daily reconciliation reports showed about 3% of records rejected for name mismatch; after a quick fix switched ASCII to UTF-8, an `ArgumentException` started appearing in logs and some records were never sent.
Investigation
Hex-dumped a rejected message and compared it against the partner specification: names had `3F` (`?`) where accented characters should be. Reproduced the exception with a long Vietnamese name, proving the char-based truncation overflowed the 32-byte field. Noticed that IDs only worked because the partner had a byte-swapping workaround on their side.
Resolution
Rewrote the builder with `BinaryPrimitives`, UTF-8 encoding truncated on grapheme boundaries, and validation. Coordinated with the partner to remove their workaround in the same release window. Replayed rejected and dropped records.
Prevention
Golden-byte tests from the partner's sample messages, multilingual boundary tests, and a reconciliation-rejection metric with an alert threshold.

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.

↩ Usually asked after

More in Computer Basics

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me