C#
.NET
design flaws
software development
programming languages

C .NET Design Flaws

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

C# and .NET are well-designed for enterprise development, but they carry design decisions from the early 2000s that cause friction for modern developers. These are not bugs — they are deliberate choices that turned out to be suboptimal as the language and ecosystem evolved. Understanding these flaws helps developers work around them and write better code.

Null Reference Types Were Not in the Original Design

The biggest source of runtime crashes in C# is NullReferenceException. Reference types were nullable by default from C# 1.0, with no compile-time enforcement:

csharp
// C# 1.0 through 7.x — no warning
string name = null;
Console.WriteLine(name.Length);  // NullReferenceException at runtime

C# 8.0 introduced Nullable Reference Types (NRT) as an opt-in feature, but it is not enforced at runtime — it is only a compiler analysis:

csharp
1#nullable enable
2string? name = null;     // Explicitly nullable
3string title = null;     // Warning CS8600: Converting null to non-nullable type
4
5if (name != null)
6    Console.WriteLine(name.Length);  // No warning after null check

The flaw: NRT is purely a compile-time annotation. It does not prevent null at runtime because existing code and libraries may not have adopted it.

Structs vs Classes — Confusing Semantics

C# has both value types (structs) and reference types (classes), but their behavior differences cause subtle bugs:

csharp
1struct Point {
2    public int X;
3    public int Y;
4}
5
6List<Point> points = new() { new Point { X = 1, Y = 2 } };
7
8// This does NOT modify the point in the list:
9points[0].X = 10;  // Compiler error in recent C#
10// The indexer returns a COPY of the struct
11
12// Workaround:
13var p = points[0];
14p.X = 10;
15points[0] = p;

The guideline is to make structs immutable, but the language does not enforce this (until readonly struct was added in C# 7.2).

Covariant Arrays Are Unsafe

C# arrays are covariant, meaning string[] can be assigned to object[]. This is type-unsafe:

csharp
1string[] strings = { "hello", "world" };
2object[] objects = strings;  // Legal — covariant assignment
3
4objects[0] = 42;  // Compiles fine, but throws ArrayTypeMismatchException at runtime!

This was inherited from Java for compatibility. Generic collections (List<T>) do not have this problem because they are invariant. Arrays should have been invariant from the start.

IDisposable and the using Pattern

Resource cleanup through IDisposable is verbose and error-prone. Forgetting to call Dispose() leaks resources:

csharp
1// Easy to forget Dispose:
2var conn = new SqlConnection(connectionString);
3conn.Open();
4// ... if an exception occurs here, connection is leaked
5
6// Correct but verbose:
7using (var conn = new SqlConnection(connectionString))
8{
9    conn.Open();
10    // auto-disposed at end of block
11}
12
13// C# 8.0 improved this:
14using var conn = new SqlConnection(connectionString);
15conn.Open();
16// disposed at end of enclosing scope

The flaw: there is no compile-time enforcement that IDisposable objects are disposed. You can new up a SqlConnection and forget the using with zero warnings.

Event Handler Memory Leaks

C# events hold strong references to subscribers, preventing garbage collection:

csharp
1class Publisher {
2    public event EventHandler DataChanged;
3}
4
5class Subscriber {
6    public void Subscribe(Publisher pub) {
7        pub.DataChanged += OnDataChanged;  // Strong reference
8    }
9
10    void OnDataChanged(object sender, EventArgs e) { }
11    // If Subscriber goes out of scope but Publisher lives on,
12    // Subscriber is never garbage collected
13}

The fix requires manually unsubscribing (-=), which developers frequently forget. Weak events were never built into the language.

DateTime API Issues

The DateTime type lacks timezone awareness, leading to frequent bugs:

csharp
1// Is this UTC, local, or unspecified?
2DateTime now = DateTime.Now;         // Local time
3DateTime utc = DateTime.UtcNow;     // UTC time
4
5// Subtracting them gives wrong results if you're not careful:
6TimeSpan diff = now - utc;  // Not zero! Off by timezone offset
7
8// DateTimeOffset was added later to fix this:
9DateTimeOffset proper = DateTimeOffset.Now;  // Includes offset

DateTime.Kind (Local, Utc, Unspecified) is a runtime-only property — the type system does not distinguish them.

LINQ Deferred Execution Surprises

LINQ queries use deferred execution, which confuses developers:

csharp
1var numbers = new List<int> { 1, 2, 3 };
2var query = numbers.Where(n => n > 1);
3
4numbers.Add(4);  // Modifying the source AFTER creating the query
5
6foreach (var n in query)
7    Console.Write(n + " ");  // Prints: 2 3 4 — includes 4!

The query is not a snapshot — it re-evaluates against the current state of numbers each time it is iterated.

async void Is Dangerous

async void methods cannot be awaited and swallow exceptions:

csharp
1// BAD: exceptions crash the process, cannot be caught
2async void HandleClick(object sender, EventArgs e) {
3    await Task.Delay(100);
4    throw new Exception("oops");  // Unobserved — crashes
5}
6
7// GOOD: always use async Task
8async Task HandleClickAsync() {
9    await Task.Delay(100);
10    throw new Exception("oops");  // Can be caught by caller
11}

async void exists only for compatibility with event handler delegates. It should be avoided everywhere else.

Common Pitfalls

  • String comparison: string.Equals defaults to ordinal comparison, but == uses ordinal too. For culture-aware comparison, you must explicitly pass StringComparison.CurrentCulture — easy to forget and causes bugs in internationalized apps.
  • Mutable structs: Structs that are mutable behave unexpectedly when boxed, stored in collections, or used as readonly fields. Always make structs readonly struct with init properties.
  • Enum underlying type: Enums in C# are just integers with names. You can cast any integer to an enum type, even invalid values: (Color)999 compiles and runs without error.
  • Exception hierarchy: Exception is the base class, but SystemException and ApplicationException serve no practical purpose. The distinction was a design mistake acknowledged by Microsoft.
  • Default interface implementations: Added in C# 8.0, these allow interfaces to have method bodies — blurring the line between interfaces and abstract classes and potentially causing diamond inheritance confusion.

Summary

  • Nullable reference types were retrofit in C# 8.0 but lack runtime enforcement
  • Covariant arrays are type-unsafe and throw runtime exceptions instead of compile-time errors
  • IDisposable has no compile-time enforcement — forgetting using leaks resources
  • Events hold strong references, causing memory leaks when subscribers are not unsubscribed
  • DateTime lacks proper timezone support — prefer DateTimeOffset for new code
  • async void swallows exceptions silently — always use async Task

Course illustration
Course illustration

All Rights Reserved.