Back to blog
Backend Systemsbeginner

C# Fundamentals: Complete Guide from Zero to Confident

Learn C# from scratch. Covers variables, types, control flow, OOP, interfaces, generics, LINQ, async/await, and error handling — everything a beginner needs to start building real .NET applications.

LearnixoApril 13, 202613 min read
View Source
.NETC#BeginnerOOPLINQasync
Share:𝕏

What is C# and .NET?

C# (pronounced "C sharp") is a modern, strongly-typed, object-oriented language created by Microsoft. It runs on .NET — a free, open-source, cross-platform runtime that powers everything from web APIs to mobile apps to cloud services.

C# is used at Microsoft, Stack Overflow, Unity (game development), and thousands of enterprises worldwide. It's consistently ranked in the top 5 most-used programming languages.

What you'll be able to build after this guide:

  • Console applications
  • REST APIs with ASP.NET Core
  • Background services and workers
  • Data pipelines and automation scripts

Setting Up

Bash
# Install .NET SDK (free, cross-platform)
# Download from: https://dotnet.microsoft.com/download

# Verify installation
dotnet --version   # should show 8.x or 9.x

# Create a new console app
dotnet new console -n MyFirstApp
cd MyFirstApp
dotnet run

Use Visual Studio 2022 (Windows, full IDE) or VS Code + C# extension (cross-platform, lightweight).


Variables and Types

C# is strongly typed — every variable has a type, checked at compile time.

C#
// Value types — stored directly
int age = 25;
double price = 9.99;
decimal total = 1234.56m;   // m suffix for decimal
bool isActive = true;
char grade = 'A';

// String — reference type but behaves like value type
string name = "Alice";
string greeting = $"Hello, {name}!";  // string interpolation

// var — type inferred by compiler
var count = 42;          // int
var message = "hello";   // string
var items = new List<string>();

Value types vs Reference types:

  • Value types (int, bool, struct) are copied when assigned
  • Reference types (class, string, array) hold a reference to the object

Nullable Types

C#
// Non-nullable: compiler warns if you assign null
string name = "Alice";
// name = null;  // CS8600 warning

// Nullable: explicitly allow null
string? email = null;
int? age = null;

// Check before use
if (email != null)
    Console.WriteLine(email.ToUpper());

// Null-conditional operator
Console.WriteLine(email?.ToUpper());  // null if email is null

// Null-coalescing
string display = email ?? "No email";

// Null-coalescing assignment
email ??= "default@example.com";

Enable nullable reference types in .csproj (default in .NET 6+):

XML
<Nullable>enable</Nullable>

Control Flow

C#
// if / else
int score = 85;
if (score >= 90)
    Console.WriteLine("A");
else if (score >= 80)
    Console.WriteLine("B");
else
    Console.WriteLine("C");

// switch expression (modern, concise)
string grade = score switch
{
    >= 90 => "A",
    >= 80 => "B",
    >= 70 => "C",
    _     => "F"   // default
};

// for loop
for (int i = 0; i < 10; i++)
    Console.WriteLine(i);

// foreach
var names = new[] { "Alice", "Bob", "Carol" };
foreach (var n in names)
    Console.WriteLine(n);

// while / do-while
int count = 0;
while (count < 5)
    count++;

// Pattern matching in switch
object obj = 42;
switch (obj)
{
    case int n when n > 0:
        Console.WriteLine($"Positive int: {n}");
        break;
    case string s:
        Console.WriteLine($"String: {s}");
        break;
    case null:
        Console.WriteLine("Null");
        break;
}

Methods

C#
// Basic method
static int Add(int a, int b) => a + b;

// Optional parameters (must be at end)
static string Greet(string name, string greeting = "Hello")
    => $"{greeting}, {name}!";

// Named arguments (can pass in any order)
Greet(greeting: "Hi", name: "Alice");

// params — variable number of arguments
static int Sum(params int[] numbers)
    => numbers.Sum();

Sum(1, 2, 3, 4, 5);  // 15

// out parameter — returns multiple values
static bool TryParse(string input, out int result)
{
    return int.TryParse(input, out result);
}

if (TryParse("42", out int n))
    Console.WriteLine(n);

// ref — pass by reference
static void Swap(ref int a, ref int b)
{
    (a, b) = (b, a);  // tuple swap
}

Classes and Objects

C#
public class Customer
{
    // Properties (preferred over public fields)
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; private set; }  // only settable in class
    public DateTime CreatedAt { get; } = DateTime.UtcNow;  // init-only

    // Constructor
    public Customer(int id, string name, string email)
    {
        Id = id;
        Name = name;
        Email = email;
    }

    // Method
    public string GetDisplayName() => $"{Name} ({Email})";

    // Override ToString
    public override string ToString() => $"Customer[{Id}]: {Name}";
}

// Usage
var customer = new Customer(1, "Alice Smith", "alice@example.com");
Console.WriteLine(customer.GetDisplayName());

// Object initializer (requires parameterless constructor or init setters)
var customer2 = new Customer
{
    Id = 2,
    Name = "Bob",
    Email = "bob@example.com"  // only if setter is public
};

Record types (C# 9+)

Records are immutable value-object classes. Perfect for DTOs.

C#
public record CustomerDto(int Id, string Name, string Email);

var dto = new CustomerDto(1, "Alice", "alice@example.com");
var dto2 = dto with { Name = "Bob" };  // non-destructive mutation

// Records have equality by value (not reference)
Console.WriteLine(dto == new CustomerDto(1, "Alice", "alice@example.com"));  // True

Inheritance and Polymorphism

C#
public abstract class Animal
{
    public string Name { get; init; } = string.Empty;

    // Abstract: must be overridden
    public abstract string Speak();

    // Virtual: can be overridden
    public virtual string Describe() => $"I am {Name}";
}

public class Dog : Animal
{
    public override string Speak() => "Woof!";
    public override string Describe() => $"{base.Describe()}, a dog.";
}

public class Cat : Animal
{
    public override string Speak() => "Meow!";
}

// Polymorphism
Animal[] animals = { new Dog { Name = "Rex" }, new Cat { Name = "Whiskers" } };
foreach (var a in animals)
    Console.WriteLine($"{a.Name}: {a.Speak()}");

Interfaces

Interfaces define contracts — what a type can do without specifying how.

C#
public interface IEmailService
{
    Task SendAsync(string to, string subject, string body);
    Task<bool> ValidateAddressAsync(string email);
}

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id);
    Task<IEnumerable<T>> GetAllAsync();
    Task AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(int id);
}

// Implementation
public class SmtpEmailService : IEmailService
{
    public async Task SendAsync(string to, string subject, string body)
    {
        // ... SMTP implementation
        await Task.CompletedTask;
    }

    public async Task<bool> ValidateAddressAsync(string email)
        => await Task.FromResult(email.Contains('@'));
}

// Depend on the interface, not the implementation
public class OrderService
{
    private readonly IEmailService _emailService;  // interface, not SmtpEmailService

    public OrderService(IEmailService emailService)
        => _emailService = emailService;

    public async Task PlaceOrderAsync(Order order)
    {
        // ... business logic ...
        await _emailService.SendAsync(
            order.CustomerEmail,
            "Order Confirmed",
            $"Order #{order.Id} is confirmed.");
    }
}

Generics

Write code that works with any type, with compile-time type safety.

C#
// Generic class
public class Stack<T>
{
    private readonly List<T> _items = new();

    public void Push(T item) => _items.Add(item);
    public T Pop()
    {
        if (_items.Count == 0) throw new InvalidOperationException("Stack is empty");
        var item = _items[^1];
        _items.RemoveAt(_items.Count - 1);
        return item;
    }
    public int Count => _items.Count;
}

var intStack = new Stack<int>();
intStack.Push(1);
intStack.Push(2);
Console.WriteLine(intStack.Pop());  // 2

// Generic method
public static T Max<T>(T a, T b) where T : IComparable<T>
    => a.CompareTo(b) >= 0 ? a : b;

Max(3, 7);       // 7
Max("apple", "mango");  // mango

// Generic constraints
where T : class          // must be reference type
where T : struct         // must be value type
where T : new()          // must have parameterless constructor
where T : IMyInterface   // must implement interface
where T : BaseClass      // must inherit from class

Collections

C#
// List<T> — ordered, resizable
var names = new List<string> { "Alice", "Bob", "Carol" };
names.Add("Dave");
names.Remove("Bob");
names.Sort();
int idx = names.IndexOf("Carol");

// Dictionary<TKey, TValue> — key-value pairs
var scores = new Dictionary<string, int>
{
    ["Alice"] = 95,
    ["Bob"] = 87
};
scores["Carol"] = 92;
scores.TryGetValue("Dave", out int score);  // safe access

// HashSet<T> — unique values, fast lookup
var tags = new HashSet<string> { "sql", "csharp", "dotnet" };
tags.Add("sql");      // already exists, ignored
tags.Contains("sql"); // O(1)

// Queue<T> and Stack<T>
var queue = new Queue<int>();
queue.Enqueue(1); queue.Enqueue(2);
int next = queue.Dequeue();  // 1

// ImmutableList (System.Collections.Immutable)
using System.Collections.Immutable;
var immutable = ImmutableList.Create(1, 2, 3);
var updated = immutable.Add(4);  // returns new list, original unchanged

LINQ (Language Integrated Query)

LINQ lets you query any collection using a clean, SQL-like syntax.

C#
var customers = new List<Customer>
{
    new(1, "Alice", "alice@gmail.com"),
    new(2, "Bob",   "bob@yahoo.com"),
    new(3, "Carol", "carol@gmail.com"),
};

// Method syntax (most common)
var gmailUsers = customers
    .Where(c => c.Email.EndsWith("@gmail.com"))
    .OrderBy(c => c.Name)
    .Select(c => new { c.Name, c.Email })
    .ToList();

// Aggregations
int total = customers.Count();
var firstAlpha = customers.MinBy(c => c.Name);
var emails = customers.Select(c => c.Email).ToArray();

// GroupBy
var byDomain = customers
    .GroupBy(c => c.Email.Split('@')[1])
    .Select(g => new { Domain = g.Key, Count = g.Count() });

// First / FirstOrDefault / Single
var alice = customers.FirstOrDefault(c => c.Name == "Alice");
// FirstOrDefault returns null if not found
// First throws if not found
// Single throws if more than one match

// Any / All / Count
bool hasGmail = customers.Any(c => c.Email.EndsWith("@gmail.com"));
bool allHaveEmail = customers.All(c => !string.IsNullOrEmpty(c.Email));

// Join
var orders = new List<Order> { /* ... */ };
var customerOrders = customers.Join(
    orders,
    c => c.Id,
    o => o.CustomerId,
    (c, o) => new { c.Name, o.Total }
);

// Deferred execution
var query = customers.Where(c => c.Name.StartsWith("A"));
// Query not executed yet
customers.Add(new(4, "Anna", "anna@example.com"));
foreach (var c in query)  // executed now — includes Anna
    Console.WriteLine(c.Name);

Exception Handling

C#
// try / catch / finally
try
{
    var result = int.Parse("not a number");
}
catch (FormatException ex)
{
    Console.WriteLine($"Format error: {ex.Message}");
}
catch (OverflowException ex)
{
    Console.WriteLine($"Number too large: {ex.Message}");
}
catch (Exception ex) when (ex.Message.Contains("specific"))
{
    // Exception filter
}
finally
{
    // Always runs — use for cleanup
    Console.WriteLine("Done");
}

// Custom exceptions
public class InsufficientFundsException : Exception
{
    public decimal Amount { get; }
    public InsufficientFundsException(decimal amount)
        : base($"Insufficient funds: need {amount:C}")
        => Amount = amount;
}

// throw vs re-throw
catch (Exception ex)
{
    throw;            // re-throw, preserves stack trace
    // throw ex;      // BAD: resets stack trace
    // throw new ApplicationException("msg", ex);  // wrap with context
}

Async/Await

The foundation of scalable .NET applications. Async code doesn't block threads while waiting.

C#
// async method returns Task or Task<T>
public async Task<string> FetchUserAsync(int userId)
{
    using var client = new HttpClient();
    // await suspends the method, freeing the thread
    string json = await client.GetStringAsync($"/api/users/{userId}");
    return json;
}

// async void — ONLY for event handlers, never in libraries
// Always use Task in other cases

// Multiple concurrent operations
var task1 = FetchUserAsync(1);
var task2 = FetchUserAsync(2);
var (r1, r2) = await (task1, task2);  // run in parallel
// Or:
var results = await Task.WhenAll(task1, task2);

// Cancellation
public async Task ProcessAsync(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        await DoWorkAsync();
        await Task.Delay(1000, ct);  // throws if cancelled
    }
}

// ConfigureAwait(false) — in library code, don't capture sync context
string data = await client.GetStringAsync(url).ConfigureAwait(false);

// ValueTask — for hot paths that are often synchronous
public ValueTask<int> GetCachedCount()
{
    if (_cache.TryGet("count", out int n)) return ValueTask.FromResult(n);
    return new ValueTask<int>(FetchCountFromDbAsync());
}

Delegates, Func, Action, Events

C#
// Func<TIn, TOut> — method that returns a value
Func<int, int, int> add = (a, b) => a + b;
Console.WriteLine(add(3, 4));  // 7

// Action<T> — method that returns void
Action<string> log = msg => Console.WriteLine($"[LOG] {msg}");
log("Application started");

// Predicate<T> — Func<T, bool>
Predicate<string> isLong = s => s.Length > 10;

// Events
public class OrderProcessor
{
    public event EventHandler<OrderEventArgs>? OrderPlaced;

    public void PlaceOrder(Order order)
    {
        // ... process ...
        OnOrderPlaced(new OrderEventArgs(order));
    }

    protected virtual void OnOrderPlaced(OrderEventArgs e)
        => OrderPlaced?.Invoke(this, e);
}

// Lambda expressions
var numbers = new[] { 1, 2, 3, 4, 5 };
var evens = numbers.Where(n => n % 2 == 0);

// Expression trees (used in EF Core and ORMs)
Expression<Func<Customer, bool>> filter = c => c.Name.StartsWith("A");
// EF Core translates this to SQL: WHERE name LIKE 'A%'

Records and Pattern Matching (Modern C#)

C#
// Pattern matching (C# 8+)
object shape = new Circle(5.0);

// is expression with pattern
if (shape is Circle c) Console.WriteLine($"Area: {Math.PI * c.Radius * c.Radius}");

// switch expression with patterns
string Describe(object obj) => obj switch
{
    Circle c          => $"Circle with radius {c.Radius}",
    Rectangle r       => $"Rectangle {r.Width}x{r.Height}",
    string s when s.Length > 0 => $"Non-empty string: {s}",
    null              => "null",
    _                 => "Unknown"
};

// Positional pattern
record Point(double X, double Y);
string Quadrant(Point p) => p switch
{
    ( > 0,  > 0) => "Q1",
    ( < 0,  > 0) => "Q2",
    ( < 0,  < 0) => "Q3",
    _            => "Q4 or axis"
};

// List patterns (C# 11)
int[] arr = { 1, 2, 3 };
if (arr is [1, .., 3]) Console.WriteLine("Starts with 1, ends with 3");

Dependency Injection

The core of .NET application architecture. Register services once, inject them everywhere.

C#
// Program.cs (minimal hosting)
var builder = WebApplication.CreateBuilder(args);

// Register services
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
builder.Services.AddScoped<OrderService>();
builder.Services.AddSingleton<ILogger, ConsoleLogger>();

var app = builder.Build();

// Lifetimes:
// Transient  — new instance per injection
// Scoped     — one instance per HTTP request
// Singleton  — one instance for entire app lifetime

// Constructor injection
public class OrderController
{
    private readonly OrderService _orderService;

    public OrderController(OrderService orderService)
        => _orderService = orderService;
}

File I/O

C#
// Write a file
await File.WriteAllTextAsync("output.txt", "Hello, world!");

// Read a file
string content = await File.ReadAllTextAsync("input.txt");
string[] lines = await File.ReadAllLinesAsync("data.txt");

// Stream-based (for large files)
await using var reader = new StreamReader("large.txt");
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
    Console.WriteLine(line);
}

// JSON serialization
using System.Text.Json;

var customer = new Customer(1, "Alice", "alice@example.com");
string json = JsonSerializer.Serialize(customer);
var restored = JsonSerializer.Deserialize<Customer>(json);

// With options
var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true,
};
string prettyJson = JsonSerializer.Serialize(customer, options);

Real-World Example: Order Processing Service

C#
// Domain model
public record Order(int Id, int CustomerId, decimal Total, DateTime PlacedAt);
public record OrderItem(int OrderId, int ProductId, int Qty, decimal UnitPrice);

// Repository interface
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(int id, CancellationToken ct = default);
    Task<IReadOnlyList<Order>> GetByCustomerAsync(int customerId, CancellationToken ct = default);
    Task<int> CreateAsync(Order order, IEnumerable<OrderItem> items, CancellationToken ct = default);
}

// Service
public class OrderService
{
    private readonly IOrderRepository _orders;
    private readonly IEmailService _email;
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        IOrderRepository orders,
        IEmailService email,
        ILogger<OrderService> logger)
    {
        _orders = orders;
        _email = email;
        _logger = logger;
    }

    public async Task<int> PlaceOrderAsync(
        int customerId,
        IEnumerable<OrderItem> items,
        CancellationToken ct = default)
    {
        var total = items.Sum(i => i.Qty * i.UnitPrice);
        var order = new Order(0, customerId, total, DateTime.UtcNow);

        int orderId = await _orders.CreateAsync(order, items, ct);

        _logger.LogInformation(
            "Order {OrderId} placed for customer {CustomerId}, total {Total}",
            orderId, customerId, total);

        // Fire and forget — don't block order placement on email
        _ = _email.SendAsync(
            $"customer-{customerId}@example.com",
            $"Order #{orderId} Confirmed",
            $"Your order of {total:C} has been placed.");

        return orderId;
    }
}

What to Learn Next

  • ASP.NET Core Web API: Build production REST APIs
  • Entity Framework Core: Database access done right
  • Clean Architecture in .NET: How to structure real applications
  • .NET Interview Questions (Junior): 100 questions for your first interview

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.