Learn C# / .NET 8 by building one chat backend ⚙️

No reference dump. We build the server side of a tiny AI chat, one need at a time. Each concept starts with the problem it solves and a real-world picture, then the C# 12 code.

Our app is MiniChat: a React frontend posts a message to our ASP.NET Core service, the service calls Amazon Bedrock, and streams the AI reply back token by token. We start with how C# even runs, and end with a streaming Bedrock endpoint. Every section adds the next piece the backend needs.
Opened 0/16 · tap any section to expand
1

How your C# actually runsCLR · IL · JIT · managed memory

🤔 Goal: understand what happens between "I wrote Console.WriteLine" and "the CPU did something." C++ compiles straight to machine code for one exact chip. That's fast but fragile: wrong OS or CPU and it won't run, and you manage every byte of memory by hand.
🌍 Think of it like
Shipping a recipe in a universal shorthand instead of a finished dish. Your code compiles to a portable half-language (IL). On the machine that runs it, a translator (the JIT) converts each method into real machine code the first time it's called, tuned for that exact CPU. A janitor (the GC) walks around collecting plates you're no longer using.

You compile C# to Intermediate Language, not machine code. At runtime the .NET runtime loads it and the Just-In-Time compiler turns methods into native code on first use. You never call free(). The garbage collector reclaims objects nothing points to anymore.

the journey of one method
C# source
Program.cs
compile (Roslyn)
to IL
.dll of IL
portable
JIT on first call
→ native
CPU runs it
// C# 12 top-level program — this IS a full app
using System;
Console.WriteLine("MiniChat backend booting…");
// compiles to IL → JIT compiles WriteLine to native on first call
Why you care: the GC means no manual free, but it can pause your app briefly. On a chat server that streams tokens, allocating less garbage per request keeps latency smooth.

Now the words: the runtime is the CLR (Common Language Runtime). The portable bytecode is IL / MSIL. Code the CLR manages (memory, safety) is managed code. Skipping IL to precompile is AOT (Native AOT in .NET 8).

2

Copy vs share: value vs reference typesstruct · class · stack · heap · boxing

🤔 Goal: a ChatMessage gets passed to three methods. Do they all see the same object, or their own copies? Get this wrong and one method's edit silently changes another's data (or fails to).
🌍 Think of it like
A value type is a photocopy: hand someone a copy, they scribble on it, your original is untouched. A reference type is a shared street address: everyone holding the address walks to the same house, so one person's remodel is seen by all.

A struct is a value type: assigning or passing it copies the whole thing. A class is a reference type: you copy only the address, not the object. Click below to feel the difference.

live · one edit — does the "other" copy change?
original.Score = 10
method does
x.Score = 99
after: ?
pick one…
struct Vote { public int Score; }   // value → copied
class  Msg  { public int Score; }   // reference → shared
Boxing: put a struct into an object (or a non-generic collection) and the runtime wraps it on the heap — a hidden copy + allocation. object o = 42; boxes. In a hot request path that's wasted GC pressure. Generics avoid it.

Now the words: value types usually live on the stack (fast, auto-freed when the method returns) or inline inside their owner; reference-type objects live on the heap (GC-managed). Local reference variables sit on the stack but point to the heap.

3

Model a message cleanlyproperties · interfaces · records

🤔 Goal: represent a chat message. Public fields let anything mutate them. And when two messages have identical content, msgA == msgB should arguably be true — but with a normal class it's false unless they're literally the same object.
🌍 Think of it like
A property is a receptionist at a desk: callers ask for the value or hand one in, and the receptionist can validate or log before letting it through. A record is a banknote: two $20 bills are equal because of what they represent, not because they're the same physical note.

Use properties (get/set) instead of raw fields so you control access. Use an interface to say "any class that promises these methods can be plugged in here." Use a record when the object is defined by its data and two with the same data should count as equal.

live · compare a record vs a class by value
click…
// record: value equality + immutable by default (C# 12)
public record ChatMessage(string Role, string Text);

// interface: a contract the LLM client must satisfy
public interface IChatClient {
    Task<string> CompleteAsync(string prompt);
}

// property with validation via the receptionist
public class Conversation {
    private string _title = "";
    public string Title {
        get => _title;
        set => _title = value?.Trim() ?? "";
    }
}
Records shine for DTOs: the request/response shapes MiniChat sends over HTTP are pure data. record ChatRequest(string Message, string ConversationId) gives you equality, a nice ToString, and with-copies for free.

Now the words: a class implementing an interface is polymorphism — swap a real Bedrock client for a fake in tests. Records generate value-based Equals/GetHashCode and support non-destructive mutation: msg with { Text = "edited" }.

4

Kill the NullReferenceExceptionnullable reference types · ?? · ?.

🤔 Goal: a message's Text might be missing. In old C#, any reference could secretly be null, and you'd only find out at runtime with the billion-dollar mistake: NullReferenceException.
🌍 Think of it like
A parking space that might be empty. Old C# let you drive as if a car were always there. Nullable reference types make you mark which spaces can be empty (string?) and the compiler yells if you use one without checking.

Turn on nullable reference types and the type string means "never null," while string? means "might be null." Then two operators keep code short: ?. (do this only if not null) and ?? (use this fallback if null).

live · a possibly-missing message text
click…
string? raw = msg.Text;                 // ? = may be null
int len   = raw?.Length ?? 0;         // ?. skips if null, ?? falls back
string safe = raw ?? "(empty message)";   // never null after this
The ! escape hatch: raw!.Length tells the compiler "trust me, not null." It silences the warning but does NOT add a check — if you're wrong it still throws. Use it rarely, and only when you truly know more than the compiler.

Now the words: ?. is the null-conditional operator, ?? is the null-coalescing operator, ??= assigns only if currently null. Enable it project-wide with <Nullable>enable</Nullable>.

5

Filter and shape the conversationList · Dictionary · LINQ · deferred execution

🤔 Goal: from a list of messages, get just the user's last 5, count tokens per role, group by conversation. Hand-writing for loops with temp lists for each of these is noisy and easy to get wrong.
🌍 Think of it like
A factory conveyor belt with stations. Where is a station that lets only some items pass, Select reshapes each one, GroupBy sorts them into bins. Crucially, nothing moves until someone at the end grabs the finished items (ToList). Set up the belt now, run it later.

Store messages in a List<ChatMessage> and lookups in a Dictionary. Then LINQ reads like a sentence: filter, transform, group, fold. And it's lazy — the query is a plan; it runs when you iterate it.

live · when does the query actually run?
var userWords = messages
    .Where(m => m.Role == "user")         // keep user msgs
    .Select(m => m.Text.Length)             // reshape → lengths
    .Where(len => len > 0);              // still just a plan!

int total = userWords.Sum();               // NOW it iterates + runs

var byRole = messages
    .GroupBy(m => m.Role)
    .ToDictionary(g => g.Key, g => g.Count());  // {"user":3,"assistant":2}
Deferred execution bites: if you build a query, then change the source list, then enumerate — you get the NEW data, not a snapshot. And enumerating a query twice runs it twice. Call ToList() once to freeze the result when you'll reuse it.

Now the words: LINQ methods take lambdas and return IEnumerable<T> that runs lazily (deferred execution). Aggregate is the general fold; Sum/Count/Max are shortcuts. Dictionary<K,V> gives O(1) lookups by key.

6

Pass behavior around, not just datadelegates · Func/Action/Predicate · events · lambdas

🤔 Goal: our token stream should notify whoever's interested each time a token arrives, without the streamer knowing who's listening. And we want to pass "how to transform a message" into a method as an argument.
🌍 Think of it like
A delegate is a phone number for a method. You can store it, pass it around, and dial it later — without knowing whose phone it is. An event is a subscription list: "text me when a token arrives," and the publisher texts everyone on the list.

A delegate is a variable that holds a method. The built-ins cover almost everything: Action (takes args, returns nothing), Func (returns a value), Predicate (returns bool). A lambda x => x*2 is just an inline method you hand to one of these.

live · subscribe two listeners, then fire the event
// Func/Action: behavior as a parameter
Func<string,int> length = s => s.Length;   // returns a value
Action<string>     log    = t => Console.WriteLine(t); // returns void

// event: publisher notifies subscribers
public event Action<string>? OnToken;
OnToken?.Invoke(token);   // ?. so it's safe with zero subscribers
Unsubscribe or leak: an event holds a reference to every subscriber. Forget to -= and the subscriber can't be garbage-collected. This is the classic .NET memory leak.

Now the words: a delegate is a type-safe method pointer. Events are delegates with restricted access (only the owner can Invoke). Multiple methods on one delegate = a multicast delegate.

7

Write it once, reuse for any typegenerics & constraints

🤔 Goal: a cache that stores anything by key — conversations, users, tokens. Writing a separate StringCache, UserCache, etc. is copy-paste. Using object everywhere loses type safety and boxes value types.
🌍 Think of it like
A labeled shipping box that fits any product. The box design is one blueprint; you stamp the contents type on the side (Cache<User>) at the moment you use it. You get one implementation but full type checking for each contents.

A generic leaves a type as a blank T to be filled in at the call site. A constraint says "T must be at least this" so you can actually use it — e.g. where T : class or where T : IChatClient.

one class, three uses — no boxing, full type safety
public class Cache<TKey, TValue> where TKey : notnull {
    private readonly Dictionary<TKey,TValue> _map = new();
    public void Set(TKey k, TValue v) => _map[k] = v;
    public bool TryGet(TKey k, out TValue? v) => _map.TryGetValue(k, out v);
}

var convos  = new Cache<string, Conversation>();   // type-safe
var tokens  = new Cache<int, string>();            // no boxing of int

// constrained generic: T must implement the contract
public T First<T>(IEnumerable<T> xs) where T : IChatClient => xs.First();
Why it beats object: the compiler checks types at compile time (no runtime casts), and value types like int aren't boxed. This is why List<T> replaced the old ArrayList.

Now the words: T is a type parameter. where T : … is a constraint (class, struct, new(), an interface, or notnull). Generics are reified in .NET — List<int> and List<string> are genuinely different types at runtime.

8

Don't freeze while waiting on the LLMthe payoff: async/await, Task, streaming

🤔 Goal: calling Bedrock takes seconds. If our thread just sits there blocked, one slow LLM call ties up a whole thread, and 200 users means 200 stuck threads. The server falls over. We want to release the thread while waiting and pick up when the reply comes.
🌍 Think of it like
A restaurant order ticket. The waiter takes your order to the kitchen and does NOT stand there watching the food cook. They hand off the ticket and go serve other tables. When the food is ready, the kitchen rings a bell and the waiter comes back to that ticket. await is "go serve other tables until the bell rings." Blocking is "stand at the pass doing nothing."

Mark a method async and it can await a slow operation. At the await, the thread is released back to the pool to do other work. When the work finishes, execution resumes right after the await. A Task is the order ticket; Task<T> is a ticket that comes back with a value.

live · watch the thread stay free vs get stuck
worker thread: idle
public async Task<string> CompleteAsync(string prompt, CancellationToken ct) {
    var reply = await _bedrock.ConverseAsync(prompt, ct); // thread freed here
    return reply.Text;                                  // resumes when reply arrives
}
The deadlock that catches everyone: calling .Result or .Wait() on an async method blocks the current thread waiting for a Task — but if that Task needs the same thread to resume (classic in old UI / ASP.NET contexts), it can never complete. It deadlocks forever. Rule: async all the way down. Never block on async.
🌍 The state machine, simply
When you write await, the compiler rewrites your method into a resumable state machine — like a choose-your-own-adventure book that bookmarks the page, closes so others can read, then reopens to your exact bookmark. Your straight-line code becomes "save locals, return, resume here later."

CancellationToken: the user closed the tab mid-stream — stop calling Bedrock and stop billing. You thread a CancellationToken through every async call; when it's cancelled, the operation bails early. ConfigureAwait(false): in library code, "I don't need to resume on the original context," which avoids the deadlock above and is slightly faster.

live · stream tokens as they generate (IAsyncEnumerable)
// yield each token as it arrives — caller awaits the stream
public async IAsyncEnumerable<string> StreamAsync(
        string prompt, [EnumeratorCancellation] CancellationToken ct) {
    await foreach (var chunk in _bedrock.ConverseStreamAsync(prompt, ct))
        yield return chunk.Text;   // pushes one token to the caller
}
This is the whole point of the backend: hundreds of users each waiting on Bedrock, but the server stays responsive because every await frees its thread. Then IAsyncEnumerable streams tokens straight to the React frontend from the React tutorial's streaming section.

Now the words: Task/Task<T> represent a future result. async+await compile to a state machine. The freed thread returns to the thread pool. ValueTask avoids an allocation when the result is often already available. async void is only for event handlers — never elsewhere (see anti-patterns).

9

Clean up sockets and streamsIDisposable · using · dispose pattern

🤔 Goal: our HTTP call to Bedrock opens a network stream. The GC frees managed memory eventually, but it does NOT know or care about the OS socket, file handle, or DB connection underneath. Leave those open and you exhaust the pool.
🌍 Think of it like
Returning a library book. The GC tidies your room (memory) whenever it feels like it. But the library book (a socket, a file) belongs to someone else and must be returned on a schedule. using is the reminder that returns it the instant you leave the room, even if you leave by tripping over the rug (an exception).

Anything holding an OS resource implements IDisposable with a Dispose() method. Wrap it in using and Dispose() is called automatically when the block ends — guaranteed, even if an exception is thrown.

live · does the stream get closed if code throws mid-way?
// using declaration (C# 8+): disposed at end of scope
using var stream = await _bedrock.GetStreamAsync(ct);
await Pump(stream, ct);   // even if this throws, stream.Dispose() still runs

// async cleanup for async resources
await using var conn = new SqlConnection(cs);
HttpClient is the exception to the rule: do NOT wrap HttpClient in using per request — it exhausts sockets. Register it once via IHttpClientFactory and reuse it. Disposing what should be long-lived is its own bug.

Now the words: using compiles to try/finally that calls Dispose(). For your own type, implement IDisposable (or IAsyncDisposable for async cleanup). The full dispose pattern (with a Dispose(bool) and finalizer) is only needed when you hold unmanaged handles directly.

10

When Bedrock throws, don't crash the serverexceptions done right

🤔 Goal: Bedrock is rate-limited or the network blips. We want to handle that specific failure gracefully, not swallow every error blindly (which hides real bugs) and not let it take down the whole process.
🌍 Think of it like
A circuit breaker in your house. You want the breaker for the ONE overloaded circuit to trip, not to cut power to the whole house. Catching Exception broadly is cutting all power: you also silence the fire alarm you needed to hear.

Catch the most specific exception you can actually handle. Let the ones you can't handle bubble up. Use finally for cleanup that must always run, and when filters to catch conditionally.

live · specific catch vs catch-all
try {
    return await _bedrock.ConverseAsync(prompt, ct);
}
catch (ThrottlingException ex) when (retries < 3) {  // filter
    _log.LogWarning(ex, "Bedrock throttled, retrying");
    return await RetryAsync(prompt, ct);
}
catch (OperationCanceledException) {                    // user left
    throw;   // re-throw — NOT our job to swallow cancellation
}
finally {
    _metrics.Record(sw.Elapsed);   // always runs
}
Two classic mistakes: (1) catch (Exception) { } — an empty catch-all that swallows real crashes silently. (2) throw ex; instead of bare throw; — the first RESETS the stack trace, erasing where the error actually happened.

Now the words: exceptions unwind the stack until a matching catch. Prefer specific types; use exceptions for the exceptional, not for normal control flow (they're expensive). when is an exception filter that catches without unwinding if false.

11

Don't new up your dependenciesDI · singleton · scoped · transient

🤔 Goal: our controller needs an IChatClient, a logger, a DbContext. If the controller does new BedrockChatClient(...) itself, it's welded to the concrete class — impossible to swap for a fake in tests, and it has to know how to build every dependency's dependency.
🌍 Think of it like
A restaurant kitchen vs foraging for your own ingredients. Instead of each cook driving to the farm, ingredients are delivered to the station (injected). The container is the supply manager who knows how to source everything and hands it over ready to use.

You declare what you need in the constructor; the DI container supplies it. You register each service once with a lifetime that says how long one instance lives.

the three lifetimes — pick right or leak/share state
Singleton
one for the whole app
(HttpClient, config, cache)
Scoped
one per HTTP request
(DbContext)
Transient
new every time
(lightweight, stateless)
// Program.cs — register once
builder.Services.AddSingleton<IChatClient, BedrockChatClient>();  // shared
builder.Services.AddScoped<IMessageStore, EfMessageStore>();       // per request
builder.Services.AddTransient<PromptBuilder>();                    // each time

// consume — just ask for it in the constructor (primary ctor, C# 12)
public class ChatService(IChatClient chat, IMessageStore store) {
    // chat + store are handed to you, already built
}
The captive dependency trap: never inject a Scoped service (like DbContext) into a Singleton. The singleton holds that one request's DbContext forever, sharing it across all requests — a data-corruption and threading bug. The container will warn you in dev.

Now the words: this is Inversion of Control via constructor injection. The container resolves the whole dependency graph. Depend on the interface, not the concrete class, so tests can inject a fake.

12

The actual MiniChat endpointminimal API · middleware · model binding

🤔 Goal: expose POST /chat so the React frontend can send a message and get a reply. We need to parse the JSON body, run auth + logging on every request, and call our service.
🌍 Think of it like
An airport security line. A request walks through a series of checkpoints (middleware) in order — logging, then auth, then routing — and each can stop it or pass it along. The final gate is your endpoint. The response walks back out through the same line in reverse.

The middleware pipeline is an ordered chain every request flows through. Model binding automatically turns the JSON body into your ChatRequest record. DI hands your service to the endpoint.

a request's journey through the pipeline
POST /chat
Logging
Auth
Model bind
JSON→record
endpoint
// Program.cs — minimal API (C# 12, .NET 8)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ChatService>();
var app = builder.Build();

app.UseSerilogRequestLogging();   // middleware, in order
app.UseAuthentication();
app.UseAuthorization();

// body auto-binds to the record; service auto-injected
app.MapPost("/chat", async (ChatRequest req, ChatService svc, CancellationToken ct) =>
{
    var reply = await svc.CompleteAsync(req.Message, ct);
    return Results.Ok(new ChatResponse(reply));
});

// streaming variant → yields tokens to the frontend
app.MapGet("/chat/stream", (string q, ChatService svc, CancellationToken ct)
    => svc.StreamAsync(q, ct));   // IAsyncEnumerable → streamed JSON

app.Run();

public record ChatRequest(string Message, string? ConversationId);
public record ChatResponse(string Reply);
Controller vs minimal API: a controller class does the same job with [ApiController], [HttpPost] attributes and constructor-injected services. Minimal APIs are leaner for small services; controllers scale better for large ones. Both use the same pipeline, binding, and DI.

Now the words: middleware is registered with Use… and runs in order (order matters — auth before the endpoint). Model binding maps request data to parameters. Parameters are resolved from the body, route, query, or DI automatically.

13

Save the conversation to a databaseEF Core · DbContext · tracking · N+1

🤔 Goal: persist every message so chats survive a restart. Writing raw SQL strings by hand is tedious and injection-prone. And a naive load of "20 conversations, each with its messages" can fire 21 separate queries.
🌍 Think of it like
A bilingual assistant between your objects and tables. You speak in C# objects (Conversation, Message); EF Core translates to SQL and back. The DbContext is that assistant, and it also remembers what you changed so it can write it all in one SaveChanges.

EF Core maps classes to tables. The DbContext is your session with the database; it tracks loaded entities so edits get saved automatically. Migrations evolve the schema as your classes change.

tracking vs no-tracking, and the N+1 trap
public class ChatDb(DbContextOptions<ChatDb> o) : DbContext(o) {
    public DbSet<Conversation> Conversations => Set<Conversation>();
    public DbSet<Message> Messages => Set<Message>();
}

// write: DbContext tracks the new entity, saves in one round trip
db.Messages.Add(new Message { Role = "user", Text = req.Message });
await db.SaveChangesAsync(ct);

// read-only? skip change tracking → faster, less memory
var history = await db.Messages
    .AsNoTracking()
    .Where(m => m.ConversationId == id)
    .ToListAsync(ct);
click…
The N+1 problem: loop over 20 conversations and touch c.Messages lazily → 1 query for the list + 20 more for each one's messages. Fix with .Include(c => c.Messages) to load them in a single join, or a projection with Select.

Now the words: EF Core is an ORM. The DbContext's change tracker diffs loaded entities to generate UPDATE/INSERT. AsNoTracking disables that for read paths. Migrations: dotnet ef migrations add Init then dotnet ef database update.

14

Call the LLM: Amazon BedrockAWSSDK.BedrockRuntime · Converse + streaming

🤔 Goal: the whole point. Take the user's message, send it to a model on Amazon Bedrock, and stream the reply back. This ties together records (the DTOs), async (don't block), IAsyncEnumerable (streaming), DI (inject the client), and cancellation (user leaves).
🌍 Think of it like
Ordering from a kitchen through a single window. The Converse API is one standard order window that works the same no matter which chef (model) is cooking. You pass messages in a common shape; you get a reply in a common shape. The streaming version sends food out plate by plate instead of all at once.

The AWSSDK.BedrockRuntime package gives you a client. Converse is the unified request — same code for Claude, Llama, Titan. For live typing you use ConverseStream and await foreach the chunks, forwarding each token to your endpoint.

live · the full backend path, simulated end to end
public class BedrockChatClient(IAmazonBedrockRuntime bedrock) : IChatClient {

  public async IAsyncEnumerable<string> StreamAsync(
        string prompt, [EnumeratorCancellation] CancellationToken ct) {

    var req = new ConverseStreamRequest {
        ModelId  = "anthropic.claude-3-5-sonnet-20241022-v2:0",
        Messages = [ new Message {
            Role = ConversationRole.User,
            Content = [ new ContentBlock { Text = prompt } ]
        } ]
    };

    var resp = await bedrock.ConverseStreamAsync(req, ct);

    await foreach (var ev in resp.Stream.WithCancellation(ct))
        if (ev is ContentBlockDeltaEvent d)
            yield return d.Delta.Text;   // one token → to the frontend
  }
}
The full loop: React posts to /chat/stream → ASP.NET Core middleware + model binding → ChatService (scoped) → BedrockChatClient (singleton) → Bedrock Converse stream → IAsyncEnumerable tokens back through the endpoint → React appends each to state and redraws. Every concept in this tutorial shows up in that one request.

Now the words: IAmazonBedrockRuntime is registered in DI (AddAWSService). Converse/ConverseStream are the model-agnostic APIs; the streaming response is an event stream of ContentBlockDeltaEvents you filter and yield. Auth is via the standard AWS credential chain (IAM role, env, profile).

15

Interview rapid-firetap a card to flip

16

Mistakes that fail code reviewanti-patterns