Concepts
What you will learn: We will use C# fundamentals to model rules such as paying for an order or validating its data, and choose types for values and behavior. You will see how a design can prevent invalid states, and when a value is copied versus when references share the same object.
Before you start: A variable names a value we use; a function is a group of steps with inputs and a result; a class describes a kind of object with data and behavior. No prior knowledge is needed. Start with the first example; an object is an instance made from a class.
Select a term with a dotted underline for its meaning, then continue reading from the same place.
#The four pillars of OOP
Imagine a checkout that accepts a card or wallet: the caller needs to know which payment operation is available, not how funds are deducted. This small teaching example connects the four pillars to a concrete responsibility; it is not a real payment implementation.
Think of a car:
- Encapsulation: the engine is closed under the hood. You use the pedal; you don't touch the fuel line.
- Abstraction: you know "press the pedal and the car moves" without knowing how fuel injection works.
- Inheritance: a truck is a car with everything a car has, plus a cargo bed.
- Polymorphism: every car has
Start(), but an electric car starts differently from a petrol one.
public abstract class PaymentMethod // (1) abstraction
{
private decimal _balance; // (2) encapsulation
protected PaymentMethod(decimal openingBalance)
{
ArgumentOutOfRangeException.ThrowIfNegative(openingBalance);
_balance = openingBalance;
}
public decimal Balance => _balance;
protected bool Debit(decimal amount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
if (_balance < amount) return false;
_balance -= amount;
return true;
}
public abstract Task<bool> PayAsync(decimal amount); // what, not how
}
public sealed class Wallet : PaymentMethod // (3) inheritance
{
public Wallet(decimal openingBalance) : base(openingBalance) { }
public override Task<bool> PayAsync(decimal amount) // (4) polymorphism
{
return Task.FromResult(Debit(amount));
}
}
PaymentMethod method = new Wallet(150m);
await method.PayAsync(100m); // true; balance is now 50
await method.PayAsync(100m); // false; insufficient funds
the example: new Wallet(150m) creates a wallet with a balance of 150; the m suffix makes the number a decimal literal. Paying 100 succeeds and leaves 50. The next attempt to pay 100 returns false and leaves the balance unchanged, because validation happens before deduction. The calling code uses the PaymentMethod contract while the Wallet object supplies the behavior. Task.FromResult wraps an already available result; no payment-service call occurs here.
- (1) The abstract class defines what is required (
PayAsync), not how. - (2)
_balanceis private, so nothing outside can corrupt it; changes go throughDebit, which validates. - (3)
WalletspecializesPaymentMethodand can call its protectedDebitmethod. The private_balancefield is not directly accessible in the derived class, and the constructor calls the base constructor to initialize it. - (4) The variable's type is
PaymentMethod, butWallet's method runs: runtime polymorphism throughvirtualandoverride.
#Value types, reference types and boxing
- Two variables refer to the same List containing the price 100. Adding a price through one is visible through the other: there is one list, and its reference was copied.
- Copy an int holding 100 into another variable. Incrementing the second to 101 leaves the first at 100; each variable holds its own value.
- Ask whether the assignment copied a value or a reference to shared state. Storage location alone does not explain the distinction; copying a struct containing a reference does not clone the referenced object.
If you copy a Point and change the copy, does the original change? If you copy a variable referring to a Box, do both variables refer to the same object? The example answers those questions, then shows an int as object. Exact storage placement is an implementation detail; the key difference here is copy and identity behavior.
struct Point { public int X; } // value type
class Box { public int X; } // reference type
var p1 = new Point { X = 1 };
var p2 = p1; p2.X = 99; // copy → p1.X is still 1
var b1 = new Box { X = 1 };
var b2 = b1; b2.X = 99; // same object → b1.X is 99
object o = 5; // boxing: the int is copied into a new heap object
int n = (int)o; // unboxing: copied back
Value types (struct, int, decimal, DateTime, enum) | Reference types (class, string, arrays, ) | |
|---|---|---|
| Representation | The value itself | A reference to an object |
| Assignment | Copies the whole value | Copies the reference |
| Null | No, unless int? | Yes |
| Inheritance | No | Yes |
When to use a struct? Often for a small value representing one thing, such as an Point. There is no universal 16-byte cutoff: measure copying and real usage, and use a class when shared identity matters.
Boxing happens when a value type is treated as object or an interface: a new heap object is allocated. That costs and pressure, which generics such as List<int> avoid.
#class vs struct vs record
In an order system, an Order usually has its own identity even as its details change; a money amount or map point is often defined by its value. This comparison separates identity from value semantics, then the code shows record value equality and a non-destructive copy. Choose by domain meaning, not syntax alone.
public record Money(decimal Amount, string Currency); // reference type, value equality
public readonly record struct Point(int X, int Y); // value type, value equality
var a = new Money(100, "EGP");
var b = new Money(100, "EGP");
Console.WriteLine(a == b); // True: compared by values
var c = a with { Amount = 150 }; // non-destructive copy
Console.WriteLine(a); // Money { Amount = 100, Currency = EGP }
| class | struct | record (class) | record struct | |
|---|---|---|---|---|
| Kind | reference | value | reference | value |
| Default equality | By reference | By value; the default implementation may use direct comparison or depending on the case, and cost depends on type and runtime | By value (generated) | By value (generated) |
| No | No, unless readonly | Positional properties are init; other members can still be mutable | No, unless readonly record struct | |
| Best for | Entities with identity and behavior | Tiny values | , events, value objects | Small values needing equality |
#decimal vs double vs float
double stores numbers in binary. A value like 0.1 has no exact binary representation, just as 1/3 has none in decimal (0.3333…), so small errors appear.
decimal stores numbers in base 10, so 0.1 is exactly 0.1. That's what you need for money.
Console.WriteLine(0.1 + 0.2 == 0.3); // False (0.30000000000000004)
Console.WriteLine(0.1m + 0.2m == 0.3m); // True
float f = 10.5f; // 'f' suffix: a literal like 10.5 is a double by default
decimal d = 10.5m; // 'm' suffix; without it: compile error (no implicit double → decimal)
Math.Round(2.5m); // 2: banker's rounding (to even)
Math.Round(2.5m, MidpointRounding.AwayFromZero); // 3
| Type | Size | Precision | Speed | Use for |
|---|---|---|---|---|
| float | 4 bytes | ~6–9 digits | Very fast | Graphics, sensors, ML |
| double | 8 bytes | ~15–17 digits | Fast (hardware) | Science, statistics |
| decimal | 16 bytes | 28–29 decimal digits | Slower (software) | Money and accounting |
Money, with rounding in exactly one place, so results are consistent across the system.#Interface vs abstract class
| interface | abstract class | |
|---|---|---|
| Multiple inheritance | ✅ a class can implement many | ❌ one base class |
| Instance fields | ❌ | ✅ |
| Constructor | ❌ | ✅ |
| Implementation | Default interface methods (C# 8+), rarely used | ✅ normal |
| Meaning | "Can do" (a capability) | "Is a kind of", with shared code |
Rule of thumb: start with an interface, which is more flexible and easier to test and inject; use an abstract class when types really share code and state.
public interface IRefundable { Task RefundAsync(decimal amount); } // capability
// shared skeleton + state
public abstract class ReportBase
{
protected readonly ILogger Log;
protected ReportBase(ILogger log) => Log = log;
public async Task<byte[]> ExportAsync() => Render(await QueryAsync());
protected abstract Task<IReadOnlyList<Row>> QueryAsync();
protected virtual byte[] Render(IReadOnlyList<Row> rows) => ExcelWriter.Write(rows);
}
#string, StringBuilder and immutability
string in is : every "change" creates a new string. That makes it thread-safe and safe as a dictionary key, and allows interning.
string csv = "";
foreach (var o in orders)
csv += $"{o.Id},{o.Total}\n";var sb = new StringBuilder();
foreach (var o in orders)
sb.Append(o.Id).Append(',').Append(o.Total).AppendLine();
string csv = sb.ToString();
// or: string.Join("\n", orders.Select(o => $"{o.Id},{o.Total}"))A few concatenations on one line are fine; the compiler optimizes them. Inside a loop, use StringBuilder or string.Join.
#Delegates, lambdas and events
After an order is saved, another service may need to know that OrderPlaced happened so it can start an email. A defines a callable shape, a is a concise way to write one, and an event lets listeners subscribe without raising it themselves. The example shows that flow.
Func<decimal, decimal> addTax = price => price * 1.14m; // takes decimal, returns decimal
Action<string> log = msg => Console.WriteLine(msg); // returns nothing
Predicate<Order> isPaid = o => o.Status == "Paid"; // returns bool
public class OrderService
{
public event EventHandler<OrderPlacedArgs>? OrderPlaced;
public void Place(Order o)
{
// ... save
OrderPlaced?.Invoke(this, new OrderPlacedArgs(o.Id)); // only the owner raises it
}
}
service.OrderPlaced += (s, e) => email.Send(e.OrderId); // subscribe
// unsubscribe (-=) from long-lived publishers, or you leak memory
- A delegate is a variable that holds a method: a type-safe function pointer.
Func<…, TResult>returns a value; its last type parameter is the return type.Action<…>returns nothing.Predicate<T>returns bool, equivalent toFunc<T, bool>.- An event is a protected delegate: outsiders can only
+=and-=, not invoke it.
#Exceptions, using and IDisposable
A payment may be declined for insufficient funds or fail temporarily because the gateway is unavailable, and an opened file must still be closed if an error occurs. The code separates handling a known outcome, logging and rethrowing a failure, and cleaning up resources. Exceptions are for exceptional failures, not every expected result.
try
{
await payment.ChargeAsync(order, ct);
}
catch (PaymentDeclinedException ex) when (ex.Code == "insufficient_funds") // (1) filter
{
return Result.Fail("payment.declined");
}
catch (HttpRequestException ex)
{
log.LogWarning(ex, "Gateway unreachable for {OrderId}", order.Id);
throw; // (2) keep stack trace
}
finally
{
metrics.RecordAttempt(); // (3) always runs
}
await using var file = File.OpenWrite("report.csv"); // (4) disposed at end of scope
- (1) An exception filter (
when) catches only when the condition holds. - (2)
throw;preserves the original stack ;throw ex;resets it, losing where the error happened. - (3)
finallyruns in every case. - (4)
usingguaranteesDisposeeven when an exception is thrown;await usinghandlesIAsyncDisposable.
Exception and ignore it. In an , use a global exception handler.#Generics, constraints and variance
- You need a container for recent results: sometimes numbers, sometimes names. Use a type parameter T rather than duplicate the container logic.
- A List
accepts and returns int values; adding a string is rejected at compile time. Generics reuse structure while retaining a specific, checked type. - If the implementation needs a member promised by an interface, declare that constraint on T. Understand type parameters and constraints first; variance later concerns conversions between certain constructed generic types.
If you have a list of order IDs, you want the same list behavior whether an ID is numeric or text, without duplicating code or turning every value into object. Generics preserve types at compile time; constraints state which operations are valid for the generic type. Variance explains when converting between constructed generic types is safe.
public interface IRepository<T> where T : class, IEntity // constraints
{
Task<T?> GetAsync(Guid id, CancellationToken ct);
}
public static T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b;
IEnumerable<string> names = new List<string>();
IEnumerable<object> objects = names; // covariance (out T): safe for reading
Action<object> printAny = o => Console.WriteLine(o);
Action<string> printText = printAny; // contravariance (in T): safe for input
- Generics give one implementation for many types with compile-time type safety, no casts and no .
- Constraints (
where):class,struct,new(),notnull, an interface or a base class. - Covariance (
out T): a more derived type can be used where a base type is expected, for output only (IEnumerable<out T>). - Contravariance (
in T): a more general type can be used where a derived one is expected, for input only (Action<in T>). Variance applies only to interfaces and , not classes.
Interview questions
No results
Check your reasoning
Why does this choice fit? When would it fail? What changes under higher load or a dependency failure?
2.1What are the four pillars of OOP?Junior
Encapsulation hides internal state and exposes controlled behavior. Abstraction exposes what something does, not how. Inheritance lets a class reuse and extend a base class. Polymorphism lets the same call behave differently depending on the runtime type, through virtual methods or interfaces. In practice I prefer composition and interfaces over deep inheritance hierarchies.
2.2What is the difference between a class and a struct?Junior
A class is a reference type: assignment copies a reference, so two variables can refer to the same object. A struct is a value type: assignment copies its value, including any reference fields, rather than deep-copying their objects. Storage depends on context and runtime optimization, not a universal stack-versus-heap rule. A class can inherit one base class; a struct cannot inherit another struct or class, but can implement interfaces. I consider structs for small, preferably values and measure copying and costs rather than treating a size guideline as a language limit.
2.3What is a record? How is it different from a class?Mid
A record generates value equality, a useful ToString, and a shallow non-destructive copy with with. Positional record classes have init-only properties; positional record structs are mutable unless declared readonly. Records can also contain mutable members, so they aren't automatically . I use records for , messages and value objects, and classes for entities with identity and behavior. See the official record reference.
2.4Interface vs abstract class: when would you use each?Junior
An interface defines a capability; a class can implement many, and an interface cannot declare instance fields. An abstract class is a partial base type: it can have instance state, constructors and shared implementation, but a class inherits only one. I default to interfaces for flexibility and testability, and use an abstract class when several types share code, like a base report that defines the export flow.
2.5What are the access modifiers in C#?Junior
public is visible everywhere; private only inside the type, the default for members; protected inside the type and derived types; internal within the same assembly, the default for top-level types; protected internal means same assembly or any derived type; private protected means derived types in the same assembly; and file, since C# 11, limits a type to its source file. In modular code, internal is how a module hides its implementation.
2.6What is the difference between const, readonly and static readonly?Junior
A const is a compile-time constant baked into every caller, so changing it in a library requires recompiling consumers, and it supports constant-compatible types such as numbers, booleans, characters, strings and enums. A readonly field is assigned at runtime in its declaration or constructor and never changes afterwards; each instance can have its own value. static readonly is one runtime value per type, which I use for things like TimeSpans or shared instances.
2.7What does the static keyword mean? What is a static class?Junior
A static member belongs to the type rather than an instance, so there's a single copy. A static class can't be instantiated or inherited and contains only static members, like Math or a class of extension methods. I avoid mutable static state because it's shared across threads and hard to test; shared services are registered as singletons in instead.
2.8What is the difference between ref, out and in?Mid
void Increment(ref int x) => x++; // initialized; read + write
bool TryParse(string s, out int value) { ... } // method must assign it
double Distance(in Point a, in Point b) { ... } // by reference, read-onlyAll three pass by reference. ref requires the variable to be initialized and lets the method read and modify it. out doesn't need initialization, but the method must assign it, which is the TryParse pattern. in passes by reference as read-only, mainly to avoid copying large structs. In modern code I often return a tuple or a result type instead of using out parameters.
2.9decimal vs double vs float: which one for money and why?Junior
For money I always use decimal. It's a base-10 type with 28 to 29 significant digits, so values like 0.1 are exact. float and double are binary floating point: fast and with a huge range, but 0.1 + 0.2 isn't exactly 0.3, which is unacceptable for currency. decimal is slower and uses 16 bytes, so scientific and graphics code uses double. The literal suffixes are m for decimal and f for float; an unsuffixed literal like 10.5 is a double.
2.10How does Math.Round work by default?Mid
By default Math.Round uses banker's rounding: midpoints go to the nearest even number, so 2.5 becomes 2 and 3.5 becomes 4, which avoids systematic bias when summing many values. If the business expects 2.5 to become 3, I pass MidpointRounding.AwayFromZero explicitly, and keep all rounding in one place such as a Money type.
2.11What is boxing and unboxing?Junior
wraps a value type in a heap object when it's treated as object or as an interface; copies it back out. Each box is an , so in hot loops or non-generic collections like ArrayList it adds pressure. Generics avoid it because List of int stores the ints directly.
2.12What are generics? What are constraints?Junior
Generics let me write one type-safe implementation for many types, without casts or , like List of T or a generic repository. Constraints with the where keyword restrict T to a class or struct, types with a parameterless constructor, non-nullable types, or types implementing an interface, which lets me call members such as CompareTo on T safely.
2.13What are covariance and contravariance?Senior
Covariance, marked out T, lets a generic interface or that only produces T be treated as producing a base type: an of string is an IEnumerable of object. Contravariance, marked in T, lets something that only consumes T accept a more derived type: an Action of object works as an Action of string. It applies only to interfaces and delegates, and only where it's type-safe, which is why List of T is .
2.14What are Func, Action and Predicate?Junior
They're built-in generic types. Func takes up to sixteen inputs and returns a value, with the last type parameter as the return type. Action takes inputs and returns nothing. Predicate of T takes a T and returns a bool, the same as Func of T and bool. are usually assigned to them, and methods accept them.
2.15What is the difference between a delegate and an event?Mid
An event is a with restricted access: outside the declaring class you can only subscribe and unsubscribe, not invoke it or replace the subscriber list. That enforces the observer pattern, where only the publisher raises the event. A common pitfall is forgetting to unsubscribe from a long-lived publisher, which keeps subscribers alive and leaks memory.
2.16What are extension methods?Junior
public static class StringExtensions
{
public static string Truncate(this string value, int max) =>
value.Length <= max ? value : value[..max] + "…";
}
var title = product.Name.Truncate(40);An extension method is a static method in a static class whose first parameter is marked with "this", so it can be called as if it were an instance method of that type, without modifying or inheriting from it. is built from extension methods. C# 14 adds extension members: an extension block can also declare extension properties and static members.
2.17What does the yield keyword do?Mid
yield return turns a method into an iterator: the compiler generates a state machine that produces one value at a time, only when the caller asks for the next element. Nothing runs until enumeration starts, so in a pipeline like Take(3) only three values are computed. It's ideal for streaming large or infinite sequences without building a list. yield break ends the sequence.
2.18What is the difference between throw and throw ex?Junior
throw; rethrows the current exception and preserves its original stack . throw ex; resets the stack trace to the current line, so you lose where the error actually occurred. I use throw; to rethrow, or wrap it in a new exception with the original as the inner exception when I need to add context.
2.19How do you handle exceptions properly?Junior
I catch only specific exceptions I can actually handle, use filters when a condition matters, and release resources with using or finally. I never swallow exceptions silently, and I don't use them for expected outcomes like not-found or validation failures; those return results. Rethrows use throw;. In an , a global exception handler logs details and returns a ProblemDetails response without leaking internals.
2.20What is the difference between == and Equals()? Why override GetHashCode?Mid
For reference types, == compares references by default unless the type overloads it, as string does. Equals can be overridden for value equality. If you override Equals you must override GetHashCode consistently, because Dictionary and HashSet find the bucket by hash first; equal objects with different hash codes would never be found. Records generate both correctly.
2.21What is the difference between String and StringBuilder?Junior
string is , so each concatenation allocates a new string; in a loop that means repeated copying. StringBuilder keeps a resizable buffer and appends in place, so I use it, or string.Join, when building text in a loop. For a few concatenations, interpolation is fine.
2.22What does the using statement do?Junior
using guarantees Dispose is called on an IDisposable at the end of the block, even if an exception occurs; the compiler turns it into try/finally. The using declaration form disposes at the end of the enclosing scope, and await using calls DisposeAsync. I use it for connections, streams, files and anything holding unmanaged resources.
2.23What are nullable value types and nullable reference types?Junior
int? is a nullable value type, short for Nullable of int. Nullable reference types, enabled by default in new projects, make the compiler track nullability: string should never be null and string? may be, and you get warnings when dereferencing something that might be null. I treat those warnings as errors and handle nulls with ?., ?? and ??=, plus C# 14's null-conditional assignment.
2.24What is reflection and when should you use it?Mid
lets code inspect types and invoke members at runtime; containers, serializers, ORMs and test frameworks rely on it. It's slower, loses compile-time safety and conflicts with trimming and Native . I use it mainly at startup, for example to scan an assembly and register handlers, cache the results, and prefer source generators on hot paths.
2.25What are expression trees?Senior
An represents code as a data structure. When a is assigned to an Expression of Func, the compiler builds a tree describing it, which libraries can inspect and translate; that's how turns a Where on into a WHERE clause. A plain Func is compiled code that can only be executed.
2.26What is dynamic in C#?Mid
dynamic defers member binding to runtime. It can simplify COM interop or loosely shaped data, but you lose IntelliSense and compile-time checks, errors surface at runtime and calls are slower. In application code I avoid it, using strong types or JsonElement and JsonNode for unknown .
2.27Why is immutability useful?Mid
objects can't change after creation, so they can be shared across threads without locks or , and they're easier to reason about and test because there are no hidden side effects. In C# I use records, init-only and required properties, readonly fields, and immutable or frozen collections.
2.28What is the difference between overloading and overriding?Junior
Overloading means several methods with the same name but different parameters, resolved at compile time. Overriding means a derived class replaces a virtual or abstract method, resolved at runtime from the actual object. Hiding with the new keyword is different: a call through a base-typed variable still runs the base method, which usually signals a bug.
2.29What do sealed, virtual and abstract mean?Junior
virtual means a derived class may override the member; abstract means it must, and the member has no body; sealed prevents further inheritance or overriding. I seal classes by default unless they're designed for inheritance: it documents intent and lets the devirtualize calls.
2.30What are required and init properties?Mid
An init accessor allows assignment during initialization, while required makes the compiler require callers to assign the member. Neither guarantees a valid runtime value: a required property may be null or empty, and an init-only list can still be mutated. These are construction aids, not deep . See the official required reference.
Test yourself
Quiz answers and completed-topic marks are saved in this browser; they do not sync across devices.
What does 0.1 + 0.2 == 0.3 return with double?
Which parameter modifier requires the method to assign a value?
Two records new Money(100, "EGP") are compared with ==. Result?
What is the difference between throw; and throw ex;?
throw; to rethrow.You override Equals on a class used as a Dictionary key. What else must you do?
Which accessibility makes a type visible only inside its assembly?
Where is a struct stored when it is a field of a class?
Covariance (out T) allows...