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.
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.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.
// 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
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).
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).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.
x.Score = 99struct Vote { public int Score; } // value → copied class Msg { public int Score; } // reference → shared
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.
msgA == msgB should arguably be true — but with a normal class it's false unless they're literally the same object.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.
// 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() ?? ""; } }
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" }.
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.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).
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
! 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>.
for loops with temp lists for each of these is noisy and easy to get wrong.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.
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}
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.
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.
// 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
-= 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.
StringCache, UserCache, etc. is copy-paste. Using object everywhere loses type safety and boxes value types.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.
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();
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.
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.
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 }
.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.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.
// 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 }
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).
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.
// 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 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.
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.
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 }
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.
new up your dependenciesDI · singleton · scoped · transientIChatClient, 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.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.
// 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 }
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.
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.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.
// 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);
[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.
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.
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);
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.
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.
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 } }
/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).