C# 12 and .NET 8: Modern Cross-Platform Development Fundamentals
A complete guide inspired by Mark J. Price's bestselling book. Covers C# 12 features (primary constructors, type aliasing, collection expressions), .NET 8 AOT, OOP, pattern matching, Blazor, ASP.NET Core, EF Core, LINQ, and deployment.
About This Guide
"C# 12 and .NET 8: Modern Cross-Platform Development Fundamentals" by Mark J. Price (Packt, 8th Edition) is an Amazon bestseller and one of the most comprehensive beginner-to-intermediate .NET books available.
Mark J. Price is a seasoned Software Engineer and former Microsoft Certified Trainer (MCT) with 20+ years of experience, who helped prepare Microsoft's own learning materials for .NET certification.
This guide walks through the book's key topics with practical code for every concept.
Part 1: C# Language Fundamentals
Variables and Types
// Value types ā stored on stack
int age = 28;
double price = 19.99;
decimal total = 1234.56m; // m suffix ā exact for money
bool isActive = true;
char grade = 'A';
// Reference types ā stored on heap
string name = "Mark";
object anything = 42;
// Implicit typing ā compiler infers the type
var count = 10; // int
var text = "Hello"; // string
var items = new List<string>(); // List<string>
// Nullable reference types (C# 8+)
string? maybeNull = null; // explicitly nullable
string notNull = "Hi"; // compiler warns if null assignedString Interpolation and Raw String Literals (C# 11+)
string name = "Alice";
int age = 30;
// String interpolation
string message = $"Hello {name}, you are {age} years old.";
// Verbatim string ā backslashes treated literally
string path = @"C:\Users\Alice\Documents\file.txt";
// Raw string literals (C# 11) ā no escaping needed
string json = """
{
"name": "Alice",
"age": 30,
"path": "C:\Users\Alice"
}
""";
// Interpolated raw string
string html = $"""
<h1>Hello, {name}!</h1>
<p>You are {age} years old.</p>
""";Indexes and Ranges (C# 8+)
int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// Index from end ā ^ operator
int last = numbers[^1]; // 10
int secondLast = numbers[^2]; // 9
// Range ā .. operator
int[] first3 = numbers[..3]; // 1, 2, 3
int[] last3 = numbers[^3..]; // 8, 9, 10
int[] middle = numbers[2..5]; // 3, 4, 5
int[] all = numbers[..]; // whole array
string name = "Alice Smith";
string first = name[..5]; // "Alice"
string last2 = name[6..]; // "Smith"Part 2: Object-Oriented Programming
Classes and Encapsulation
public class BankAccount
{
// Private field ā encapsulated
private decimal _balance;
// Public properties ā controlled access
public string AccountNumber { get; }
public string Owner { get; }
public decimal Balance => _balance; // read-only
// Constructor
public BankAccount(string accountNumber, string owner, decimal initialBalance = 0)
{
AccountNumber = accountNumber;
Owner = owner;
_balance = initialBalance;
}
// Methods enforce business rules
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Deposit must be positive.", nameof(amount));
_balance += amount;
}
public void Withdraw(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Withdrawal must be positive.", nameof(amount));
if (amount > _balance)
throw new InvalidOperationException("Insufficient funds.");
_balance -= amount;
}
}Inheritance and Polymorphism
// Base class
public abstract class Shape
{
public string Color { get; set; } = "White";
public abstract double Area(); // must be implemented
public virtual string Describe() // can be overridden
=> $"A {Color} shape with area {Area():F2}";
}
// Derived classes
public class Circle : Shape
{
public double Radius { get; }
public Circle(double radius) => Radius = radius;
public override double Area() => Math.PI * Radius * Radius;
}
public class Rectangle : Shape
{
public double Width { get; }
public double Height { get; }
public Rectangle(double width, double height)
{
Width = width;
Height = height;
}
public override double Area() => Width * Height;
public override string Describe() => $"Rectangle {Width}Ć{Height}, area {Area():F2}";
}
// Polymorphism
Shape[] shapes = { new Circle(5), new Rectangle(4, 6) };
foreach (var shape in shapes)
Console.WriteLine(shape.Describe()); // calls the correct overrideInterfaces
public interface IDrawable
{
void Draw();
string GetDescription() => $"Drawable: {GetType().Name}"; // default implementation
}
public interface IPrintable
{
void Print();
}
// A class can implement multiple interfaces
public class Circle : Shape, IDrawable, IPrintable
{
public void Draw() => Console.WriteLine($"Drawing circle r={Radius}");
public void Print() => Console.WriteLine($"Printing circle r={Radius}");
}Part 3: Modern C# Features
Records and Record Structs (C# 9ā10)
// Record ā reference type with value equality, immutable by default
public record Person(string FirstName, string LastName, int Age);
var alice = new Person("Alice", "Smith", 30);
var alice2 = new Person("Alice", "Smith", 30);
Console.WriteLine(alice == alice2); // true ā value equality
Console.WriteLine(alice); // Person { FirstName = Alice, LastName = Smith, Age = 30 }
// Non-destructive mutation with 'with'
var olderAlice = alice with { Age = 31 };
// Record struct (C# 10) ā value type record
public record struct Point(double X, double Y);
// Positional record with additional members
public record Order(Guid Id, string CustomerId, decimal Total)
{
public bool IsHighValue => Total > 1000;
}Primary Constructors (C# 12)
// C# 12 ā primary constructor parameters available throughout the class
public class OrderService(IOrderRepository orders, IEmailService email)
{
// Parameters are in scope for the whole class ā no need to store in fields explicitly
public async Task<Order?> GetOrderAsync(Guid id, CancellationToken ct)
=> await orders.GetByIdAsync(id, ct);
public async Task ConfirmOrderAsync(Guid id, CancellationToken ct)
{
var order = await orders.GetByIdAsync(id, ct)
?? throw new NotFoundException("Order", id);
await email.SendConfirmationAsync(order.CustomerId, order.Id, ct);
}
}
// Also works on structs and records
public class Point(double x, double y)
{
public double X { get; } = x;
public double Y { get; } = y;
public double Distance => Math.Sqrt(X * X + Y * Y);
}Type Aliasing (C# 12)
// C# 12 ā alias any type, not just simple types
using OrderId = System.Guid;
using CustomerId = System.Guid;
using OrderLines = System.Collections.Generic.List<OrderLine>;
using HttpHeaders = System.Collections.Generic.Dictionary<string, string>;
// Makes intent clearer ā OrderId and CustomerId are both Guid but semantically different
public async Task<Order> GetOrderAsync(OrderId orderId, CustomerId customerId)
{ /* ... */ }Pattern Matching
// Switch expression (C# 8+)
string GetDiscount(object customer) => customer switch
{
PremiumCustomer p when p.YearsAsCustomer > 5 => "20%",
PremiumCustomer => "10%",
StandardCustomer { LoyaltyPoints: > 1000 } => "5%",
StandardCustomer => "0%",
null => throw new ArgumentNullException(nameof(customer)),
_ => "0%"
};
// Property pattern
bool IsEligibleForShipping(Order order) => order is
{
Status: OrderStatus.Submitted,
Total: >= 50,
Items: { Count: > 0 }
};
// List pattern (C# 11)
string DescribeList(int[] nums) => nums switch
{
[] => "Empty",
[var single]=> $"Single: {single}",
[var first, var second] => $"Two items: {first}, {second}",
[var first, .., var last] => $"Starts {first}, ends {last}"
};Switch Expressions
// Replaces verbose switch statements
decimal GetTaxRate(string country) => country switch
{
"UK" => 0.20m,
"DE" => 0.19m,
"US" => 0.08m,
"AU" => 0.10m,
_ => throw new NotSupportedException($"Tax rate for {country} not configured")
};
// With when guards
string ClassifyAge(int age) => age switch
{
< 0 => throw new ArgumentException("Age cannot be negative"),
< 13 => "Child",
< 18 => "Teenager",
< 65 => "Adult",
>= 65 => "Senior"
};
// Returns a value ā can be used inline
var tax = GetTaxRate(order.Country) * order.Total;Collection Expressions (C# 12)
// Unified syntax for all collection types
int[] array = [1, 2, 3];
List<int> list = [1, 2, 3];
Span<int> span = [1, 2, 3];
ImmutableArray<string> immutable = ["a", "b", "c"];
// Spread operator
int[] first = [1, 2, 3];
int[] second = [4, 5, 6];
int[] all = [..first, ..second, 7, 8]; // [1,2,3,4,5,6,7,8]Part 4: LINQ
var orders = new List<Order> { /* ... */ };
// Filtering
var pending = orders.Where(o => o.Status == OrderStatus.Pending);
// Projection
var summaries = orders.Select(o => new
{
o.Id,
o.Total,
LineCount = o.Lines.Count
});
// Ordering
var sorted = orders.OrderByDescending(o => o.Total)
.ThenBy(o => o.CreatedAt);
// Grouping
var byStatus = orders
.GroupBy(o => o.Status)
.Select(g => new { Status = g.Key, Count = g.Count(), Total = g.Sum(o => o.Total) });
// Aggregation
decimal totalRevenue = orders.Where(o => o.Status == OrderStatus.Delivered).Sum(o => o.Total);
int avgItems = (int)orders.Average(o => o.Lines.Count);
Order? largest = orders.MaxBy(o => o.Total);
// Any / All / Count
bool hasOverdue = orders.Any(o => o.DueDate < DateTime.UtcNow && o.Status != OrderStatus.Delivered);
bool allShipped = orders.All(o => o.Status >= OrderStatus.Shipped);
// Flattening
var allLines = orders.SelectMany(o => o.Lines);
// Query syntax (alternative)
var result = from o in orders
where o.Total > 100
orderby o.CreatedAt descending
select new { o.Id, o.Total };Part 5: ASP.NET Core Web API
// Program.cs ā minimal API
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
// Controller
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly AppDbContext _db;
public ProductsController(AppDbContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> GetAll(CancellationToken ct)
=> Ok(await _db.Products.ToListAsync(ct));
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id, CancellationToken ct)
{
var product = await _db.Products.FindAsync(id, ct);
return product is null ? NotFound() : Ok(product);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateProductRequest req, CancellationToken ct)
{
var product = new Product { Name = req.Name, Price = req.Price };
_db.Products.Add(product);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
}Part 6: Blazor
@* Pages/Products.razor *@
@page "/products"
@inject HttpClient Http
<h1>Products</h1>
@if (_products is null)
{
<p>Loading...</p>
}
else
{
<table class="table">
@foreach (var product in _products)
{
<tr>
<td>@product.Name</td>
<td>@product.Price.ToString("C")</td>
<td><button @onclick="() => DeleteProduct(product.Id)">Delete</button></td>
</tr>
}
</table>
}
@code {
private List<Product>? _products;
protected override async Task OnInitializedAsync()
=> _products = await Http.GetFromJsonAsync<List<Product>>("api/products");
private async Task DeleteProduct(int id)
{
await Http.DeleteAsync($"api/products/{id}");
_products?.RemoveAll(p => p.Id == id);
}
}Part 7: Entity Framework Core
// DbContext
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Product> Products => Set<Product>();
public DbSet<Category> Categories => Set<Category>();
protected override void OnModelCreating(ModelBuilder model)
{
model.Entity<Product>(e =>
{
e.HasKey(p => p.Id);
e.Property(p => p.Name).HasMaxLength(200).IsRequired();
e.Property(p => p.Price).HasPrecision(10, 2);
e.HasOne(p => p.Category).WithMany(c => c.Products).HasForeignKey(p => p.CategoryId);
});
}
}
// Queries
var products = await db.Products
.Include(p => p.Category)
.Where(p => p.Price > 10)
.OrderBy(p => p.Name)
.ToListAsync();
// Create
db.Products.Add(new Product { Name = "Widget", Price = 9.99m, CategoryId = 1 });
await db.SaveChangesAsync();
// Update
var product = await db.Products.FindAsync(id);
product!.Price = 14.99m;
await db.SaveChangesAsync();
// Delete
db.Products.Remove(product!);
await db.SaveChangesAsync();
// Migrations
// dotnet ef migrations add InitialCreate
// dotnet ef database updatePart 8: .NET 8 AOT (Ahead-of-Time Compilation)
<!-- .csproj ā enable Native AOT for an ASP.NET Core API -->
<PropertyGroup>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>// AOT-compatible minimal API
var builder = WebApplication.CreateSlimBuilder(args); // slim = AOT-optimised
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
var app = builder.Build();
app.MapGet("/products", () => new[] { new Product(1, "Widget", 9.99m) });
app.Run();
// Source-generated JSON serializer ā required for AOT (no runtime reflection)
[JsonSerializable(typeof(Product[]))]
internal partial class AppJsonSerializerContext : JsonSerializerContext { }
record Product(int Id, string Name, decimal Price);# Publish as native binary
dotnet publish -r linux-x64 -c Release
# Output: ~15MB native binary, startup < 50msPart 9: Working with Files, Streams, and Serialisers
Files
// Write
await File.WriteAllTextAsync("output.txt", "Hello, .NET 8!");
await File.WriteAllLinesAsync("lines.txt", new[] { "line1", "line2", "line3" });
// Read
string content = await File.ReadAllTextAsync("output.txt");
string[] lines = await File.ReadAllLinesAsync("lines.txt");
// Stream-based (memory efficient for large files)
await using var writer = new StreamWriter("large.txt");
for (int i = 0; i < 100_000; i++)
await writer.WriteLineAsync($"Line {i}");
await using var reader = new StreamReader("large.txt");
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
// process each line without loading all into memory
}JSON Serialisation
// System.Text.Json ā built-in, fast
var product = new Product(1, "Widget", 9.99m);
// Serialise
string json = JsonSerializer.Serialize(product, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
// Deserialise
var restored = JsonSerializer.Deserialize<Product>(json);
// Stream-based (no intermediate string allocation)
await JsonSerializer.SerializeAsync(stream, product);
var fromStream = await JsonSerializer.DeserializeAsync<Product>(stream);Part 10: NuGet Packages
# Add a package
dotnet add package Newtonsoft.Json
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 8.*
# List installed packages
dotnet list package
# Update packages
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 9.*
# Remove a package
dotnet remove package Newtonsoft.Json
# Restore all packages
dotnet restorePart 11: Deployment
# Publish for deployment
dotnet publish -c Release -o ./publish
# Self-contained (includes .NET runtime ā no runtime needed on target machine)
dotnet publish -c Release -r linux-x64 --self-contained true -o ./publish
# Docker
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM base AS final
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]Key Takeaways from the Book
| Topic | What You Learn | |---|---| | C# 12 Primary Constructors | Cleaner class definitions, fewer boilerplate fields | | C# 12 Type Aliasing | Semantic clarity for strongly-typed IDs | | Pattern Matching + Switch Expressions | Replace if-else chains with readable declarative code | | Records | Immutable DTOs and value objects with one line | | Indexes and Ranges | Slice arrays and strings without loops | | AOT Compilation | Production-ready .NET with fast startup and minimal footprint | | Blazor | Full-stack C# ā no JavaScript required | | EF Core + LINQ | Database access that feels like C# collections | | Minimal API | Lean, high-performance HTTP endpoints |
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.