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

📚 Interview question

Code review: an export method builds a cache key with customer.Name.ToLower() + "_" + date.ToString(), checks status.ToUpper() == "ACTIVE", validates emails with IndexOf("@") > 0, appends CSV rows with result += line, truncates names with Substring(0, 50), calls name.Trim() without assigning it, and logs with $"Exported {count} rows". What is wrong and how would you rewrite it?

A string-handling code review that combines the Phase 2 string topics: immutability, comparison, culture, formatting, concatenation, truncation, null handling and logging, with a rewritten version.

Senior CodeReview Advanced code-review csharp culture performance string-comparison strings

🎯 Key points

  • name.Trim() without assignment does nothing: strings are immutable
  • ToLower()/ToUpper() for comparison and keys is culture-sensitive (Turkish I) and allocates; use OrdinalIgnoreCase or ToLowerInvariant for keys
  • date.ToString() and default number formatting use the current culture, so keys and CSV change by server; use invariant culture and ISO formats
  • IndexOf("@") with a string argument is culture-sensitive; use IndexOf('@') or MailAddress.TryCreate for validation
  • result += line in a loop is O(n²); use StringBuilder, string.Join or stream rows to the output
  • Substring(0, 50) throws for short names and can split surrogate pairs; check length and truncate by text elements
  • Interpolated log messages lose structured properties; use templates or [LoggerMessage]
  • Null and empty checks: string.IsNullOrWhiteSpace plus nullable annotations

⏱ 30-second answer

Almost every line has a string bug. name.Trim() does nothing because strings are immutable. ToLower()/ToUpper() comparisons are culture-sensitive, so they break under Turkish and allocate; I'd use OrdinalIgnoreCase. date.ToString() makes culture-dependent keys and CSV, so I'd use the invariant culture and ISO formats. IndexOf("@") is a culture-sensitive string search and is weak validation anyway; I'd use MailAddress.TryCreate. += in a loop is O(n²), so I'd stream rows instead. Substring(0, 50) throws on short names and can split emoji. The interpolated log message kills structured logging. My rewrite fixes each of these and adds explicit null handling.

🗣 Full answer (senior)

How I would answer this in an interview

I would go through each line, explain the category of bug (culture, correctness, performance, security/privacy, observability), and then show a rewrite. The theme: strings that are identifiers or machine data need ordinal/invariant handling; strings for humans need care with Unicode; and building large output needs streaming, not concatenation.

1. The problems, line by line

customer.Name.ToLower() + "_" + date.ToString() (cache key)

  • ToLower() uses the current culture — Turkish I lowercases to ı, so the same customer gets different keys on different servers.
  • date.ToString() uses current-culture date and time formats — 30/10/2026 8:38:00 pm on one server, 10/30/2026 8:38:00 PM on another — so cache hit rates collapse and distributed caches (Redis) get duplicate keys.
  • Names are not unique or stable keys; two customers can share a name and names change. Using personal data in cache keys also spreads it into infrastructure logs.
  • Three allocations per key.

status.ToUpper() == "ACTIVE"

  • Culture-sensitive upper-casing plus an allocation on every check.
  • Status should be an enum, not a magic string. If it must be a string: string.Equals(status, "Active", StringComparison.OrdinalIgnoreCase).

email.IndexOf("@") > 0 (validation)

  • IndexOf(string) is culture-sensitive by default; under ICU, ignorable characters can change results.
  • Logically weak: accepts a@, a@@b, a@b@c, and rejects nothing meaningful. Use MailAddress.TryCreate(email, out _) for format sanity, a length limit, and ultimately email confirmation for real validation.

result += line (CSV rows)

  • O(n²) copying and massive garbage; large exports land on the LOH and trigger Gen2 GCs.
  • CSV values are not escaped: commas, quotes and newlines in names break rows; values starting with =, +, -, @ enable CSV/formula injection when opened in Excel.
  • Numbers and dates are probably formatted with the current culture.

name.Substring(0, 50)

  • Throws ArgumentOutOfRangeException when name is shorter than 50 — the common case.
  • Can split a surrogate pair (emoji) or a combining accent, creating invalid text.
  • Throws NullReferenceException for null names.

name.Trim(); without assignment

  • Does nothing: strings are immutable and the result is discarded. CA1806 flags it.

$"Exported {count} rows" in logging

  • Destroys structured logging: the message template is lost, count is not a queryable property, every message is unique, and formatting happens even when the level is off.

2. The rewrite

public sealed partial class CustomerExporter(ILogger<CustomerExporter> logger, IDistributedCache cache)
{
    private const int MaxNameLength = 50;

    public async Task ExportAsync(IAsyncEnumerable<Customer> customers, Stream output, DateOnly date, CancellationToken ct)
    {
        await using var writer = new StreamWriter(output, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), bufferSize: 64 * 1024);
        await writer.WriteLineAsync("CustomerId,Name,Email,Status");

        int count = 0;
        await foreach (var c in customers.WithCancellation(ct))
        {
            if (c.Status != CustomerStatus.Active) continue;                     // enum, not string
            if (!MailAddress.TryCreate(c.Email, out _)) continue;              // or reject/report

            string name = Truncate(c.Name.Trim(), MaxNameLength);              // result assigned
            await writer.WriteLineAsync(string.Create(CultureInfo.InvariantCulture,
                $"{c.Id},{Csv(name)},{Csv(c.Email)},{c.Status}"));
            count++;
        }

        LogExported(count, date);
        await cache.SetStringAsync(CacheKey(date), count.ToString(CultureInfo.InvariantCulture), ct);
    }

    private static string CacheKey(DateOnly date) =>
        string.Create(CultureInfo.InvariantCulture, $"export:active-customers:{date:yyyy-MM-dd}");

    private static string Truncate(string value, int maxTextElements)
    {
        var info = new StringInfo(value);
        return info.LengthInTextElements <= maxTextElements ? value : info.SubstringByTextElements(0, maxTextElements);
    }

    private static string Csv(string value)
    {
        if (value.Length > 0 && "=+-@\t\r".Contains(value[0])) value = "'" + value;   // formula injection guard
        return value.AsSpan().IndexOfAny(",\"\r\n") >= 0 ? $"\"{value.Replace("\"", "\"\"")}\"" : value;
    }

    [LoggerMessage(Level = LogLevel.Information, Message = "Exported {Count} active customers for {ExportDate}")]
    private partial void LogExported(int count, DateOnly exportDate);
}

Key changes: enum status; ordinal/invariant handling everywhere; cache key based on a stable, non-personal identifier and ISO date; streaming output with an explicit encoding (BOM because this CSV goes to Excel users); proper CSV escaping and formula-injection guard; safe truncation by text elements; assigned Trim(); source-generated structured logging. In a real system I would use a CSV library (CsvHelper) rather than hand-rolling escaping.

3. How I'd present it in a review

I'd group comments by severity: correctness/security first (Substring crash, CSV injection, culture-dependent keys), then performance (concatenation, allocations), then maintainability (magic strings, logging). And I'd suggest turning on analysers CA1304/CA1305/CA1307/CA1309/CA1310/CA1311, CA1806 and CA2254 (template should be a static expression) so the build catches most of this next time.

🔍 What the interviewer is checking

This review checks breadth across all string topics and the ability to prioritise and generalise findings.

Green flags

  • Finds most issues: ignored Trim, culture-sensitive case and formatting, IndexOf(string), O(n²) concatenation, Substring bounds, CSV escaping, log templates, PII, null return.
  • Proposes a clean rewrite with enums, invariant culture, streaming and structured logging.
  • Suggests analysers and shared helpers to prevent recurrence.

Red flags

  • Only spots the += loop.
  • Suggests ToLower() everywhere as the fix.
  • Ignores privacy and output safety.

📚 Learn in depth

1. Why culture leaks into identifiers

ToLower(), ToUpper(), ToString() on numbers and dates, IndexOf(string), StartsWith(string) and string.Compare all consult CultureInfo.CurrentCulture when you don't pass a culture or comparison. In ASP.NET Core with request localisation, the current culture can even differ per request, so a cache key produced in one request may not match the key produced in another. Identifiers and keys must be built with ordinal operations and the invariant culture — or better, from values that are already culture-free (IDs, enums, ISO dates).

2. Case mapping rules

  • ToLowerInvariant() / ToUpperInvariant() use invariant simple case mapping: stable, but they still allocate.
  • For comparisons, case-insensitive comparers (StringComparer.OrdinalIgnoreCase) avoid allocation and are faster.
  • Microsoft recommends normalising to upper-case invariant when you must store a case-folded form, because a few characters round-trip better through upper case.
  • For keys, consider not case-folding at all: use IDs.

3. Email validation reality

The RFC 5322 grammar is enormous; regexes that try to implement it are either wrong or vulnerable to ReDoS. Practical layers:

  1. Trim and length-limit (254 characters).
  2. Basic structure: MailAddress.TryCreate (.NET 5+), or [EmailAddress] / FluentValidation's EmailAddress() (which in recent versions only checks for an @ with text either side, by design).
  3. Normalise the domain to lower case (IdnMapping for internationalised domains); the local part is technically case-sensitive.
  4. Confirm ownership by sending a verification link — the only real validation.

4. CSV is a format, not string concatenation

RFC 4180 rules: fields containing a comma, quote, CR or LF must be enclosed in double quotes, and quotes inside are doubled. Additionally, spreadsheet applications interpret fields starting with =, +, -, @ (and tab/CR) as formulas, which allows CSV injection (data exfiltration via =HYPERLINK(...), or DDE attacks in older Excel). OWASP recommends prefixing such values with a single quote and quoting the field. Libraries like CsvHelper handle quoting, culture (CsvConfiguration(CultureInfo.InvariantCulture)) and streaming.

5. Streaming vs building

For exports, the cost model of building a string is:

  • += → O(n²) time, n intermediate strings, final string on the LOH (> 85 KB).
  • StringBuilder → O(n) time but still the whole export in memory, plus one final large string copy.
  • StreamWriter over the response stream → O(n) time, constant memory (buffer size), and the client starts downloading immediately.

In ASP.NET Core, return the export by writing to Response.Body (or Results.Stream with a callback); for very large exports, generate to Azure Blob Storage and return a link.

6. Safe truncation

Substring(0, n) fails three ways: short strings (exception), null (NRE), and splitting surrogates or combining sequences (invalid or garbled text). Options:

  • value[..Math.Min(value.Length, n)] fixes the exception but not Unicode splitting.
  • Truncate by text elements (StringInfo.SubstringByTextElements) for user-visible limits.
  • Truncate by encoded bytes for byte-limited storage, backing off to a valid boundary (Rune.DecodeLastFromUtf8 / Utf8.FromUtf16 with OperationStatus).
  • Decide whether truncation is acceptable at all — often it is better to reject over-long input at validation time.

7. Structured logging

ILogger providers treat the message template as an event type and the placeholders as properties. Application Insights stores them as customDimensions; Seq and Elastic index them. [LoggerMessage] source generation:

  • checks IsEnabled before doing any work,
  • passes strongly typed values (no boxing, no object[]),
  • caches the parsed template,
  • produces an EventId for alerting.

Analyser CA2254 warns when the template is not a constant (e.g. interpolated), and CA1848 suggests LoggerMessage for performance.

Try it yourself

  1. Run the original key code under tr-TR and en-US cultures and compare keys.
  2. Feed names like O'Brien, Jr., =HYPERLINK("http://x"), and an emoji nickname through the original and rewritten exporter, then open the CSV in Excel.
  3. Measure memory with dotnet-counters while exporting 1 million rows with +=, StringBuilder and streaming.
  4. Enable the analysers listed above and count the warnings in the original code.

💻 Code

using System.Globalization;
using System.Net.Mail;
using System.Text;
using Microsoft.Extensions.Logging;

namespace Strings;

public enum CustomerStatus { Unknown = 0, Active = 1, Suspended = 2 }
public sealed record Customer(int Id, string Name, string Email, CustomerStatus Status);
public sealed record Order(decimal Total, DateOnly Date);

public static partial class ExportLog
{
    [LoggerMessage(Level = LogLevel.Information, Message = "Exported {Count} rows for customer {CustomerId}")]
    public static partial void Exported(ILogger logger, int count, int customerId);
}

public sealed class OrderExporter(ILogger<OrderExporter> logger)
{
    public static string CacheKey(Customer c, DateOnly date) =>
        string.Create(CultureInfo.InvariantCulture, $"export:{c.Id}:{date:yyyy-MM-dd}");   // no PII, invariant

    public async Task<bool> ExportAsync(Customer customer, IReadOnlyList<Order> orders, Stream output, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(customer);
        if (customer.Status != CustomerStatus.Active) return false;                // enum, not ToUpper()
        if (!MailAddress.TryCreate(customer.Email, out _)) return false;          // real validation

        string name = Truncate(customer.Name.Trim(), 50);                         // assigned, safe truncation

        await using var writer = new StreamWriter(output, new UTF8Encoding(true), leaveOpen: true); // BOM for Excel
        foreach (var o in orders)
        {
            await writer.WriteLineAsync(string.Create(CultureInfo.InvariantCulture,
                $"{Csv(name)},{o.Total:F2},{o.Date:yyyy-MM-dd}"));
        }

        ExportLog.Exported(logger, orders.Count, customer.Id);                    // structured, no PII
        return true;
    }

    private static string Truncate(string value, int max)
    {
        if (value.Length <= max) return value;
        var info = new StringInfo(value);
        return info.LengthInTextElements <= max ? value : info.SubstringByTextElements(0, max);
    }

    private static string Csv(string field)
    {
        if (field.Length > 0 && "=+-@".Contains(field[0])) field = "'" + field;    // formula injection guard
        return field.AsSpan().IndexOfAny(",\"\r\n") >= 0
            ? $"\"{field.Replace("\"", "\"\"", StringComparison.Ordinal)}\""
            : field;
    }
}

⚠ Common mistakes

1. Building cache keys from culture-sensitive strings

What happens: keys use ToLower(), ToString() of dates or numbers, and display names. Why it is wrong: keys differ between servers and requests; cache hit rates drop and distributed caches fill with duplicates. Do instead: build keys from IDs, enums and ISO dates with the invariant culture.

2. Using magic strings for state

What happens: status.ToUpper() == "ACTIVE" scattered through the codebase. Why it is wrong: typos, casing issues and culture problems; no compile-time checking. Do instead: use enums (with string conversion at the boundary) or constants plus ordinal comparison.

3. Treating IndexOf("@") as email validation

What happens: weak checks accept invalid addresses and culture-sensitive search adds surprises. Why it is wrong: bad data reaches downstream systems; bounces and failed notifications follow. Do instead: use MailAddress.TryCreate plus length limits, and verify ownership by email confirmation.

4. Concatenating CSV rows

What happens: result += line; and no escaping. Why it is wrong: O(n²) performance, broken rows when values contain commas or quotes, and CSV injection in Excel. Do instead: stream rows to a writer with a CSV library or correct escaping and a formula-injection guard.

5. Substring with fixed lengths

What happens: name.Substring(0, 50) on variable-length input. Why it is wrong: throws for short strings and can split Unicode sequences. Do instead: guard lengths and truncate by text elements or bytes, or reject over-long input.

6. Ignoring results of string methods

What happens: name.Trim();, email.ToLowerInvariant(); on their own lines. Why it is wrong: strings are immutable; nothing changes. Do instead: assign results and enable CA1806.

7. Interpolating log messages

What happens: _logger.LogInformation($"Exported {count} rows"). Why it is wrong: loses structured properties, prevents grouping, and formats even when disabled. Do instead: message templates or [LoggerMessage].

8. Putting personal data in keys and logs

What happens: customer names or emails in cache keys, file names and log messages. Why it is wrong: spreads personal information into systems without access controls or retention policies — a Privacy Act (APP 11) and breach-impact risk. Do instead: use opaque IDs, and log only what is needed.

9. Formatting numbers and dates in exports with the current culture

What happens: amounts appear as 1234,50 on a server with a European culture. Why it is wrong: consumers misread values — potentially by a factor of 100. Do instead: use invariant culture (or the consumer's documented format) for exported data.

10. Fixing each issue individually without tooling

What happens: review comments fix this method but the same patterns recur elsewhere. Why it is wrong: the class of bug isn't prevented. Do instead: enable globalisation and reliability analysers as errors, and add tests with tricky data (Turkish culture, emoji, commas, formulas).

🏛 Senior insight: trade-offs

A senior code review turns individual fixes into guardrails.

Category Guardrail
Culture and comparison CA1304/1305/1307/1309/1310/1311 as errors
Ignored results CA1806
Logging CA2254, [LoggerMessage], no PII in templates
Nullability NRT warnings as errors
CSV/Excel output shared, tested CSV writer with escaping and formula guard
Large exports streaming pattern in the team's export template
  • Prioritise findings by impact: crashes and data corruption first, privacy second, performance third, style last.
  • Explain why in review comments and link to a guideline; the goal is to teach the pattern, not just fix this PR.
  • String bugs are cheap to prevent with analysers and expensive to find in production; that trade-off is the senior argument for enabling them.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate flags ToUpper() comparisons. Why: Checks depth on the comparison findings in the review.
    How to answer

    Ordinal comparison compares the raw UTF-16 code units, so it's fast and identical on every machine. Culture-sensitive comparison applies a language's rules through ICU (accents, ignorable characters, ß versus ss, Turkish dotted I), so results can change with the culture or ICU version. The trap is the defaults: ==, Equals and Contains are ordinal, but StartsWith(string), IndexOf(string), Compare and CompareTo are culture-sensitive. I use Ordinal/OrdinalIgnoreCase for identifiers, keys, paths, headers and anything security-related, and CurrentCulture only for sorting or searching text a user sees. For collections I pass a StringComparer.

  2. When: The candidate flags the interpolated log message. Why: Checks understanding of the logging and interpolation finding.
    How to answer

    $"Hello {name}" is compiled, not parsed at run time. With a string target, C# 10+ uses DefaultInterpolatedStringHandler, which formats each hole straight into a pooled buffer without boxing, then allocates the final string once. Holes take alignment and format ({total,10:C}), and formatting uses the current culture, so for machine-readable text I use string.Create(CultureInfo.InvariantCulture, $"..."). Assigned to FormattableString, it keeps the format and arguments separate, which is how EF Core's FromSql($"...") turns holes into SQL parameters. Raw $$""" strings help with JSON. Two traps: interpolating into raw SQL causes SQL injection, and interpolating into log templates destroys structured logging.

🛠 Real-world problems: how you handle them

Customer export crashed, broke Excel columns and logged personal data
Situation
A B2B portal let account managers export customer order history to CSV; about 5,000 exports a week.
Signals
Intermittent ArgumentOutOfRangeException for customers with short names, complaints that Excel showed shifted columns, and a privacy review finding customer names in Application Insights traces.
Investigation
Reproduced with a short name and a name containing a comma, reviewed logs in Application Insights for PII, and ran the analysers on the export project to find culture and comparison issues.
Resolution
Rewrote the exporter to stream rows with a tested CSV writer (escaping and formula guard), used invariant formatting and ISO dates, replaced string status checks with an enum, and switched logs to structured templates with customer ids.
Prevention
Analyser rules as errors, a shared CSV export component with unit tests for edge cases, and a logging guideline reviewed in PRs.

How to tell it (STAR)

**S:** Our portal's CSV export [at company] crashed for some customers, broke columns in Excel and was logging customer names. **T:** I took ownership of fixing all three. **A:** I reproduced each issue, grouped them by root cause, rewrote the exporter with streaming, proper CSV escaping, invariant formatting and structured logging with ids, and turned on the analysers that would have caught them. **R:** The errors stopped, the column complaints ended and the privacy finding was closed [timeframe].

More in Strings

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me