Front-endWhat is .NET Blazor, and how to "cook" it
In this article, I’ll summarize the conclusions I reached after intensive work with Blazor WebAssembly (WASM) and outline the types of applications this framework is well-suited for.

Hi, my name is Vlad, and in this article, I’ll prove that you don’t know C# syntax well enough :)
Let’s walk through a few language nuances - not necessarily used every day, but very real and important in practice.
This is, of course, incorrect. Why does this reveals weak understanding?
People often confuse the equality model (value-based equality) with the runtime type category (reference vs. value). By default, a record is a reference type - in other words, a class. It lives on the heap, has object identity, is managed by the GC, and so on.
public record Person(string Name);
var a = new Person("Mykola");
var b = new Person("Mykola");
Console.WriteLine(a == b); // True (value-based equality)
Console.WriteLine(ReferenceEquals(a, b)); // False (different objects)How it works:
public readonly record struct Point(int X, int Y);
var p1 = new Point(1, 2);
var p2 = new Point(1, 2);
Console.WriteLine(p1.Equals(p2)); // TrueA record struct is a value type, but with record-like behavior. It typically provides:
Yes - but also no.
A record is still a class. It simply gives you convenient syntax to declare a primary constructor without a body, and the compiler generates init properties and a deconstructor for you. But the type is still a class, and nothing prevents you from adding mutable state.
This is perfectly valid C#:
public record User(string Login)
{
public int Age { get; set; } // mutable!
}
var u = new User("neo") { Age = 30 };
u.Age = 31; // totally validWhat you cannot do is mutate an init property after initialization. In that case, you need to create a new instance using "with".
“with” can update both positional members and properties declared in the record body:
var u2 = u with { Login = "trinity", Age = 25 };They can - but only when they are nested inside another class.
Top-level classes can only be public or internal. Other access modifiers are not allowed at the top level because they only make sense when a type is a member of another type.
Nested types, on the other hand, can use any access modifier because they become members of the containing class:
public class Outer
{
private class Secret { }
protected class ProtectedInner { }
internal class InternalInner { }
private protected class PPInner { }
}Variance (co/contra-variance) is supported by default for delegate type parameters in terms of signature compatibility - the ability to use a derived or base type in place of the original type in a compatible position. However, it is not automatically applied to assigning delegate instances unless the generic delegate itself is declared variant.
So, let’s break down what in and out have to do with it.
Assume the following class hierarchy:
public class Animal { public string Name { get; set; } }
public class Cat : Animal { }
public class Dog : Animal { }Now define four delegates:
Two delegates represent a factory method signature that returns Animal:
public delegate TAnimal OutAnimalFactory<out TAnimal>();
public delegate TAnimal AnimalFactory<TAnimal>();Two delegates represent a method that only accepts Animal as an argument:
public delegate void InAnimalProcessor<in TAnimal>(TAnimal arg);
public delegate void AnimalProcessor<TAnimal>(TAnimal arg);Key rules:
So what do in and out actually give you in a delegate signature?
OutAnimalFactory<Cat> makeCat = () => new Cat();
OutAnimalFactory<Animal> okFactory = makeCat; // ✅ compiling
InAnimalProcessor<Animal> processAny = a => Console.WriteLine($"Got a {a.GetType().Name}");
InAnimalProcessor<Cat> okProcessor = processAny; // ✅ compiling
AnimalFactory<Cat> invMakeCat = () => new Cat();
AnimalFactory<Animal> badFactory = invMakeCat; // ❌will not work without out
AnimalProcessor<Animal> invProcessor = a => Console.WriteLine($"Got a {a.GetType().Name}");
AnimalProcessor<Cat> badProcessor = invProcessor; // ❌ will not work without inIn many cases, Func<> and Action<> are indeed enough (and they are already variant: Func<out TResult>, Action<in T>). But there are scenarios where a custom delegate is either required or significantly better.
You typically need a custom delegate when you want:
Example using out and "Try" semantics:
public delegate bool TryParseRef(ReadOnlySpan<char> s, out int value);Or custom delegates in UI programming:
public delegate void ClosingHandler(object sender, ref bool cancel);
public delegate void KeyPressedHandler(object sender, int keyCode, ref bool handled);A more accurate way to phrase it:
Func<> and Action<> are the default choice. Custom delegates still matter in desktop development and in very targeted API design scenarios where signature, semantics, or tooling support are important.
Modern C# has supported this for a while.
Interfaces can declare methods with bodies. Semantically, these behave like "virtual" members: an implementing type can inherit the default implementation (unless it provides its own).
public interface ILogger
{
void Log(string msg); // abstract (no body)
void Info(string msg) => Log("[INFO] " + msg); // has a body
}A class must implement Log, while Info can be inherited as-is.
C# 11 introduced static abstract members in interfaces - the foundation for "generic math" and compile-time constraints over operators and static APIs.
public interface IAddable<T>
{
static abstract T operator +(T a, T b);
}
public static T Sum<T>(T a, T b) where T : IAddable<T> => a + b;So, in modern C#, an interface is not always a "pure abstraction". It can also serve as a mechanism for backward-compatibility with code (via default implementations) and for expressing powerful generic constraints (via static abstract members).
Why would you use IQueryable against an in-memory collection in real scenarios?
You store filters as Expression<Func<T, bool>> so that:
Example:
Expression<Func<User, bool>> IsAdult() => u => u.Age >= 18;
IQueryable<User> Apply(IQueryable<User> src)
=> src.Where(IsAdult());
//Now you can use the same Apply(...) method in both environments:
// Production: EF
var dbQuery = Apply(dbContext.Users);
// Tests / offline: in-memory
var memQuery = Apply(usersList.AsQueryable());In this setup, in-memory IQueryable is primarily an API unification tool - the same query composition method works both for EF-backed queries and for in-memory collections.
Did you assume that Count() in LINQ always performs a full iteration over the collection?
In reality, it doesn’t - not in every case. Let’s take a look at the source code of Count() to see why.
the source code of Count() method
As you can see, Count() tries to avoid enumerating the source until it has no other option.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
public static int Count<TSource>(this IEnumerable<TSource> source)
{
if (source == null)
{
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
}
if (source is ICollection<TSource> collectionoft) //<< here
{
return collectionoft.Count;
}
if (source is IIListProvider<TSource> listProv) //<< here
{
return listProv.GetCount(onlyIfCheap: false);
}
if (source is ICollection collection) //<< here
{
return collection.Count;
}
int count = 0;
using (IEnumerator<TSource> e = source.GetEnumerator())
{
checked
{
while (e.MoveNext())
{
count++;
}
}
}
return count;
}var names = new[] { "A", "B", "C" };
var ages = new[] { 10, 20 };
var zipped = names.Zip(ages); // ("A", 10), ("B", 20) - stops at the shorter sequenceZip combines two sequences element-by-element. Enumeration ends as soon as either sequence runs out of elements.
var data = Enumerable.Range(1, 10);
foreach (var chunk in data.Chunk(3))
{
Console.WriteLine(string.Join(",", chunk));
}Chunk splits a sequence into arrays of a fixed size (the last chunk can be smaller).
var words = new[] { "cat", "car", "dog" };
var lookup = words.ToLookup(w => w[0]);
Console.WriteLine(string.Join(",", lookup['c'])); // cat,carToLookup materializes a lookup structure where each key maps to a collection of values. Conceptually, it’s like a Dictionary<TKey, List<TValue>>, but with a dedicated API and behavior.
var zeros = Enumerable.Repeat(0, 5); // 0,0,0,0,0
Repeat produces a sequence with the same value repeated a specified number of times.Flags can be combined using bit masks (powers of two).
[Flags]
public enum FileAccess
{
None = 0,
Read = 1,
Write = 2,
Exec = 4,
ReadWrite = Read | Write
}
You can represent combinations of enum values:
var perm = FileAccess.Read | FileAccess.Exec;
bool canWrite = (perm & FileAccess.Write) != 0; // falseThese are different collection families designed for different scenarios.
Frozen collections are optimized for read-heavy workloads. You create them once, and after that they do not change.
var frozen = new Dictionary<int, string>
{
[1] = "a"
}
.ToFrozenDictionary();//Immutable collections are persistent: any "mutation" returns a new collection instance.
var imm = ImmutableList<int>.Empty.Add(1).Add(2);
var imm2 = imm.Add(3); // imm is unchangedConcurrent collections are designed for multi-threaded access with mutations. They provide thread-safe, typically atomic operations, but they do not give you full "transactional" semantics across multiple operations.
BlockingCollection<T> supports producer-consumer patterns with blocking and optional capacity limits. In modern codebases, it’s often replaced by Channel<T>, depending on the use case.
Consider the following code. It is perfectly valid C#:
var data = new[] { 2, 5, 1, 0 };
if (data is [_, > 0, ..] or [.., <= 0, _])
{
Console.WriteLine("condition met");
}How to read it:
Another example:
bool EndsWithSingleIntList(List<List<int>> lol) => lol is [.., [_]];GC is not an archive. If an object has been collected, its memory has been freed or reused, and you no longer have a reference - you cannot bring it back.
What actually exists in practice:
public static class Program
{
static WeakReference<object> weak;
public static void method()
{
weak = new WeakReference<object>(new object());
}
public static void Main()
{
method();
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine(weak.TryGetTarget(out _)); //false
}
}
A WeakReference lets you reference an object without preventing it from being collected. If the object survives, you can still retrieve it. If it’s gone - it’s gone.
It is technically possible to resurrect an object from a finalizer, but it is almost always a bad idea. It breaks predictability and makes GC behavior harder to reason about.
Thanks for reading all the way to the end - and I hope you won’t be asking these questions in interviews :)

Senior Software Engineer & Microsoft MVP with 15+ years building scalable backend systems. Founder of Ukraine's largest .NET community. Writes about clean architecture, design patterns, EF Core, Azure, and engineering career growth.
FollowGet practical .NET tips and architecture insights delivered to your inbox when new articles are published.