.NET and C# Diagrams

Last updated:

Build Time and Run Time

Flow

Where IL becomes native code under JIT, ReadyToRun, and Native AOT.

The three .NET compilation strategies split by build time and run time Roslyn compiles C# source to IL plus metadata in an assembly at build time. Under the default JIT path, the JIT compiles each method to native code on its first call at run time. ReadyToRun is a publish step that adds precompiled native code to the same assembly, so that code runs immediately and the JIT later recompiles hot methods. Native AOT is a publish step that produces a native executable with no IL, which runs with no JIT at all. BUILD TIME RUN TIME C# source *.cs files Roslyn C# compiler IL + metadata the assembly (.dll) JIT per method, first call Native code runs tier 0, then tier 1 READYTORUN dotnet publish -p:PublishReadyToRun=true IL + native code both in the same assembly Precompiled code runs at once the JIT still recompiles hot methods and anything precompilation missed NATIVE AOT dotnet publish -p:PublishAot=true Native executable no IL shipped Runs directly no IL and no JIT in the process

Direct Dependency Wins and Cousin Dependencies

Dependency graph

Two graph shapes that both resolve Package B to 2.0.0 by different rules.

NuGet's direct-dependency-wins and cousin-dependency rules Left, direct dependency wins: the app references Package A and Package B 2.0.0 or higher directly, and Package A needs Package B 1.0.0 or higher. Both requests sit in the app's subgraph, so the app's direct reference wins and B resolves to 2.0.0, ignoring the 1.0.0 request. Right, cousin dependencies: the app references Package A and Package C, which need Package B 1.0.0 or higher and 2.0.0 or higher respectively. Neither is a direct reference, so NuGet picks the lowest version satisfying both, which is 2.0.0. DIRECT DEPENDENCY WINS same subgraph, one request made directly by the app App Package A Package B >= 2.0.0, direct Package B >= 1.0.0 ignored The app's direct reference wins over anything below it in the same subgraph. Result: B 2.0.0. COUSIN DEPENDENCIES different subgraphs, neither request made by the app App Package A Package C Package B >= 1.0.0 Package B >= 2.0.0 No direct reference, so the lowest version that satisfies both requests wins. Result: B 2.0.0.

One Load Context per Plugin

Structure

Two plugins loading different Contoso.Data versions beside a shared contract.

Plugin isolation with one AssemblyLoadContext per plugin The default AssemblyLoadContext holds the host executable, the runtime assemblies, and the shared contract assembly Contoso.Plugins.Abstractions. Two plugin contexts, analytics and reporting, each hold their plugin assembly and their own version of Contoso.Data, version 2.0.0 and version 3.0.0. Each plugin context's Load override returns null for the contract, so both resolve the one copy in the default context rather than loading their own. Isolation is by name resolution only, not a security boundary. AssemblyLoadContext.Default Host.exe runtime assemblies (System.*) Contoso.Plugins.Abstractions shared contract Load returns null for the contract, so both contexts resolve this one copy ALC "analytics" Analytics.dll the plugin Contoso.Data 2.0.0 its private copy private dependencies found through its own deps.json ALC "reporting" Reporting.dll the plugin Contoso.Data 3.0.0 its private copy private dependencies found through its own deps.json

One Instant, Three Wall Clocks

Structure

A single UTC instant read as three different local times.

One instant displayed as three zones' wall-clock times The instant 2026-03-01 12:00:00 UTC is one point on the global timeline. In New York the wall clock reads 2026-03-01 07:00 at offset minus five hours, in London 12:00 at offset zero, and in Tokyo 21:00 at offset plus nine hours. Converting changes the display, never the moment. One instant 2026-03-01 12:00:00 UTC New York wall clock 2026-03-01 07:00 offset -05:00 London wall clock 2026-03-01 12:00 offset +00:00 Tokyo wall clock 2026-03-01 21:00 offset +09:00

Spring Forward and Fall Back

Flow

New York's clock skipping an hour in March and repeating one in November.

UTC against the New York wall clock across both 2026 daylight saving transitions Spring forward on 2026-03-08: as UTC runs 06:00, 06:30, 07:00, 07:30, 08:00, the New York clock reads 01:00, 01:30, then jumps to 03:00, 03:30, 04:00, so 02:00 to 02:59 is never shown. Fall back on 2026-11-01: as UTC runs 04:00, 05:00, 05:30, 06:00, 06:30, 07:00, the New York clock reads 00:00, 01:00, 01:30 in EDT, then 01:00, 01:30 again in EST, then 02:00, so 01:00 to 01:59 is shown twice. The UTC axis never skips or repeats. SPRING FORWARD, 2026-03-08 UTC New York 06:00 01:00 06:30 01:30 07:00 03:00 07:30 03:30 08:00 04:00 02:00 to 02:59 never shown Clocks jump from 01:59:59 to 03:00. A local time of 02:30 that night matches no instant at all. FALL BACK, 2026-11-01 UTC New York 04:00 00:00 05:00 01:00 05:30 01:30 06:00 01:00 06:30 01:30 07:00 02:00 EDT, -04:00 EST, -05:00 01:00 to 01:59 shown twice, an hour apart Clocks run 01:00 to 01:59:59 twice. A local time of 01:30 that night matches two instants.

What await Does to the Caller

C4 · Dynamic

An async method running synchronously, returning a task, then resuming later.

Timeline of a call to an async method that awaits an HTTP request The caller calls GetCustomerAsync, which runs on the caller's thread: it logs, then awaits GetStringAsync. The HTTP request is not complete, so the method registers a continuation and returns an incomplete Task of Customer to the caller, whose thread is then free for other work. No thread waits while the request is in flight. When the response arrives, the continuation runs, deserializes the JSON, and returns, which completes the task and resumes anything awaiting it. Caller its thread GetCustomerAsync the async method HTTP request in flight, no thread call Log("starting") runs synchronously await GetStringAsync(...) not complete: register continuation incomplete Task<Customer> thread free for other work no thread is blocked while this waits response arrives continuation Deserialize, return task completes, awaiters resume

Thread Pool Queues

Structure

Per-worker local queues, one global queue, and idle workers stealing.

The .NET thread pool's global queue, per-worker local queues, and work stealing Work queued from a non-pool thread goes to one global FIFO queue. Work queued by a pool thread goes to that worker's own local queue. Each worker takes from its own local queue first, newest item first, then from the global queue, then steals the oldest item from another worker's local queue. Here worker 3's queue is empty, so it takes from the global queue and steals from worker 1. NON-POOL THREADS Main thread, timers, I/O queue work from outside Global queue first in, first out Worker 1 a pool thread local queue Worker 2 a pool thread local queue Worker 3 a pool thread local queue empty 1. own queue, newest first oldest newest 2. global queue 3. steal the oldest item from another worker Work queued by a pool thread goes to that thread's own local queue, which keeps related data warm in its core's cache. Stealing keeps every core busy when work items vary in size.

A Bounded Channel Pipeline

Flow

Four concurrent stages joined by bounded channels that push back when full.

A multi-stage pipeline of bounded channels with backpressure Items flow from a source through a parse stage into a channel bounded at 100 items, then a validate stage, a second channel bounded at 100, and a save stage. Every stage runs concurrently as its own task. When a channel is full, the upstream stage's WriteAsync waits until the downstream stage reads, so a slow save stage slows validate, which slows parse, instead of letting items pile up in memory. source IAsyncEnumerable parse its own task bounded 100 validate its own task bounded 100 save its own task ITEMS FLOW RIGHT WriteAsync waits when full WriteAsync waits when full

How a Deferred Query Pulls Elements

C4 · Dynamic

Each element travelling the whole chain before the next is read.

A foreach pulling elements one at a time through Select and Where The foreach asks Select for the next element, Select asks Where, and Where asks the source. The source yields 1, Where accepts it, Select projects it to 10, and foreach receives 10. Then the source yields 2, Where rejects it and asks the source again, and the source yields 3, which Where accepts and Select projects to 30. No list is built between stages, and each element travels the whole chain before the next is read. foreach Select(x => x * 10) Where(x => x % 2 == 1) source next? 1 1 10 ELEMENT 1 2 rejected, asks again ELEMENT 2 3 3 30 ELEMENT 3 request for the next element element handed back

Where the Filtering Happens

Flow

An IQueryable sending one query, against an IEnumerable pulling every row.

IQueryable translates the query to SQL while IEnumerable filters in memory Top, IQueryable: Where, OrderBy, and Take build one expression tree in your process, the provider translates it into one SQL query, and the database returns only the 10 matching rows. Bottom, IEnumerable: the database sends every row into your process, where Where, OrderBy, and Take run in memory to produce the same 10 rows. Both produce the same result; the difference is how much data crosses the network and where the filtering runs. IQUERYABLE System.Linq.Queryable Your process Where OrderBy Take Database runs WHERE, ORDER BY, and the row limit one SQL query 10 rows IENUMERABLE System.Linq.Enumerable Database returns the whole table Your process Where OrderBy Take every row 10 rows, after the filtering work in memory

Entity States in the Change Tracker

State machine

The five tracking states and the calls that move an entity between them.

EF Core change tracker entity states and transitions A detached entity becomes Unchanged when a tracking query loads it or Attach is called, and Added when Add is called. Remove on an Added entity detaches it. Changing a property of an Unchanged entity makes it Modified once change detection runs. Remove on an Unchanged or Modified entity makes it Deleted. A successful SaveChanges moves Added and Modified entities to Unchanged and Deleted entities to Detached. Added produces an INSERT, Modified an UPDATE of the changed columns, and Deleted a DELETE. your call or a detected change after a successful SaveChanges Detached not tracked Added INSERT on save Unchanged nothing on save Modified UPDATE changed columns Deleted DELETE on save tracking query, Attach property changed, detected SaveChanges Add Remove SaveChanges Remove Remove SaveChanges

The Root Provider and Its Scopes

Structure

Which provider holds each instance, and where a singleton's scoped dependency ends up.

Where the DI container keeps singleton, scoped, and transient instances The root provider lives as long as the app and holds one instance of each singleton, plus every disposable transient resolved directly from the root, which it keeps until the app shuts down. Each scope, such as one per HTTP request, holds its own scoped instances and the disposable transients resolved in it, and disposes them in reverse order when the scope ends. A singleton that takes a scoped service in its constructor gets an instance created by the root provider rather than by any scope, so that instance lives until the app shuts down and every request shares it, which is a captive dependency. Root provider lives as long as the app Singletons one of each, shared by everything Disposable transients resolved from the root: held until the app shuts down Scope A request 1 Scoped instances one of each, shared in this scope Disposable transients resolved in this scope Disposing the scope disposes everything it created, in reverse order of creation. Scope B request 2 Scoped instances one of each, shared in this scope Disposable transients resolved in this scope Disposing the scope disposes everything it created, in reverse order of creation. Captured scoped instance created by the root for a singleton: held until the app shuts down Captive dependency: a singleton's scoped dependency never belongs to a request scope, so every request shares that one instance.

What an HttpClient Owns

Structure

HttpClient settings over a handler chain over a pool of long-lived connections.

The layers under HttpClient down to pooled TCP connections HttpClient holds settings that apply to every request, such as BaseAddress, Timeout, and DefaultRequestHeaders, and passes each request to a handler chain. Optional delegating handlers for logging, auth, or retries sit in front of SocketsHttpHandler, which owns the connection pool. Each pooled TCP connection resolved the server's IP address when it opened. If DNS later points the name at a new address, open connections keep using the old one until they close, which PooledConnectionLifetime forces. HttpClient BaseAddress, Timeout, DefaultRequestHeaders thin: settings only DelegatingHandlers optional: logging, auth, retries SocketsHttpHandler owns the pool PooledConnectionLifetime Connection pool TCP connection to api.example.com IP 203.0.113.10, resolved when it opened TCP connection to api.example.com IP 203.0.113.10, resolved when it opened DNS is resolved only when a connection opens. If api.example.com moves to 203.0.113.20, these connections keep using the old address until they close.

The Delegating Handler Chain

Flow

Handlers nested in registration order, request inward and response outward.

Order of delegating handlers registered with AddHttpMessageHandler AuthTokenHandler, registered first, is outermost. It wraps TimingHandler, registered second, which wraps SocketsHttpHandler at the bottom of the chain. A request passes inward through AuthTokenHandler, then TimingHandler, then SocketsHttpHandler to the network, and the response passes back outward in reverse order. Because TimingHandler sits inside AuthTokenHandler, it observes every attempt the auth handler sends. AuthTokenHandler AddHttpMessageHandler first: outermost TimingHandler added second: sees every attempt the auth handler sends SocketsHttpHandler bottom of the chain, talks to the network request response network

Local and Distributed Cache Layers

Structure

A private cache per instance over one shared store over the source.

Two-level caching across several application instances Instances A, B, and C each have a local in-memory cache, the L1 layer, which is fast, private to that instance, and lost on restart. All three read through to one shared distributed cache such as Redis or SQL Server, the L2 layer, which is serialized and one network hop away, and which falls back to the database or API. When instance A removes an entry, the removal reaches L2, but the copies already in B's and C's local caches stay until they expire. Instance A Local cache (L1) IMemoryCache: fast, private, lost on restart Instance B Local cache (L1) IMemoryCache: fast, private, lost on restart Instance C Local cache (L1) IMemoryCache: fast, private, lost on restart Redis or SQL Server (L2) shared, serialized, one network hop Database or API the source of truth A removes an entry: the removal reaches L2 B and C still serve their own L1 copies until those expire. Nothing tells them to drop it.

From ILogger to Providers

Flow

One logging call passing through a category and filter rules to several providers.

How an ILogger entry reaches its providers OrderService depends on ILogger of OrderService, whose category is the full type name MyApp.Orders.OrderService. Each entry goes to the logger factory, where filter rules pick a minimum level by provider and category, preferring a provider-specific rule. The entry then fans out to every provider whose rule lets it through: the console provider writes to stdout, the debug provider to the debugger output, the EventSource provider to EventPipe, and other providers such as Serilog or OpenTelemetry to their own destinations. OrderService your code ILogger<OrderService> category: MyApp.Orders .OrderService Logger factory filter rules by provider and category Console provider stdout Debug provider debugger output EventSource provider EventPipe, dotnet-trace Other providers Serilog, OpenTelemetry Each provider applies its own matching rule, so the console can show Information while a log service receives Debug. The host registers Console, Debug, EventSource, and EventLog (Windows).

PeriodicTimer Ticks Around Slow Work

Flow

A fixed 100 ms cadence where one 350 ms run swallows three ticks.

Measured PeriodicTimer ticks with a 100 ms period and one 350 ms run With a 100 ms period, ticks fall due every 100 ms from creation. Measured ticks came at 110, 204, 569, 601, and 700 ms. The run that started at the 204 ms tick took 350 ms and ended around 555 ms, so the ticks due at 300, 400, and 500 ms collapsed into one pending tick delivered at 569 ms, and the cadence resumed at 600 ms. Two runs never overlap. due tick work 0 ms 100 ms 200 ms 300 ms 400 ms 500 ms 600 ms 700 ms one run, 350 ms 300, 400, 500 collapse into one tick Ticks keep the cadence measured from creation. Ticks that fall due during a run collapse into one pending tick, so the loop runs once, promptly, and never overlaps itself.

I2C Bus Versus SPI Chip Selects

Structure

One addressed two-wire bus, against shared SPI wires plus a select line per device.

How devices attach to an I2C bus and to an SPI bus I2C: the Pi's two bus wires, SDA and SCL, are shared by every device, and each device has its own address, such as 0x76, 0x68, and 0x3C, which the controller sends to pick it. Adding a device costs only a free address. SPI: three wires, SCLK, MOSI, and MISO, are shared by every device, and each device also has its own chip-select line from the Pi, CS0 for the ADC and CS1 for the LCD. The controller picks a device by pulling its CS line low, so each added device costs another GPIO pin. I2C two shared wires; the address picks the device Pi controller SDA SCL 0x76 BME280 0x68 MPU-6050 0x3C SSD1306 Another device costs a free address. SPI three shared wires, plus one chip select per device Pi controller SCLK MOSI MISO ADC LCD CS0 CS1 Another device costs another GPIO pin for its select line.

How xUnit Runs Tests in Parallel

Flow

One lane per test collection by default, against every test case in parallel.

xUnit default collection parallelism compared with ParallelMode.All Default mode: each test class is its own collection and gets one lane. OrderTests runs A1, A2, A3 in sequence, CustomerTests runs B1, B2, and a named collection called Database merges two classes, whose tests C1, C2, and D1 share one lane. The lanes run side by side. With ParallelMode.All, set by an assembly attribute in xUnit v3 4.0, individual test cases run in parallel across the available threads, so tests from the same class can run at the same time. DEFAULT: ONE LANE PER COLLECTION OrderTests A1 A2 A3 CustomerTests B1 B2 "Database" collection C1 C2 D1 Two classes in one named collection share one lane. C1, C2, and D1 never overlap. PARALLELMODE.ALL: TEST CASES RUN IN PARALLEL thread 1 A1 B2 D1 thread 2 A2 C1 thread 3 B1 A3 thread 4 C2 A1, A2, and A3 from the same class can run at once, so every test, not just every class, must be isolated.

VSTest Versus Microsoft.Testing.Platform

Flow

A separate runner loading a test library, against a self-hosting test app.

How tests are launched under VSTest and under Microsoft.Testing.Platform VSTest: dotnet test starts vstest.console, a separate runner, which loads the test project's output, MyTests.dll, a library, then discovers and runs its tests. Microsoft.Testing.Platform: the test project builds an executable, MyTests.exe, that contains both the tests and the runner, so dotnet test, dotnet run, or launching the file runs the tests directly with no vstest.console. VSTEST dotnet test vstest.console separate runner process MyTests.dll a library: tests only starts loads MICROSOFT.TESTING.PLATFORM dotnet test dotnet run MyTests.exe tests and runner in one app, also runs launched directly runs

How the Diagnostic Tools Reach a Process

Structure

Four tools connecting through one diagnostic port into the runtime.

The .NET diagnostic tools connecting to a process through its diagnostic port dotnet-counters, dotnet-trace, dotnet-gcdump, and dotnet-dump run as separate processes on the same machine or in the same container. Each connects to the target process's diagnostic port, a named pipe on Windows or a Unix domain socket elsewhere. Through it, counters and trace ask the runtime to stream metrics and events from EventPipe, gcdump asks for the heap's object graph, which forces a full gen 2 collection, and dump asks the runtime to write the process memory to a file. No debugger, profiler, or code change is needed. Tool processes same machine or container, same user dotnet-counters metrics dotnet-trace events dotnet-gcdump heap graph dotnet-dump memory dump Your .NET process no debugger or profiler loaded, no restart Diagnostic port named pipe (Windows) Unix socket (Linux, macOS) EventPipe streams events and metrics GC and heap walked for a gcdump, forces gen 2 Dump writer writes process memory to disk

The Shape of a Memory Leak

Chart

Heap size over time, with a level floor when healthy and a rising one when leaking.

Managed heap size over time for a healthy process and a leaking one Both charts plot managed heap size against time. The heap grows as the process allocates and drops at each garbage collection, making a sawtooth. The dots mark the heap size after each collection. In the healthy process the dots stay at a level floor. In the leaking process the heap still saws up and down, but each collection drops back to a higher floor than the last, because objects that are still referenced survive every collection. HEALTHY: FLOOR STAYS LEVEL time heap size LEAKING: FLOOR KEEPS RISING time heap size heap size after each GC, the dotnet.gc.last_collection.heap.size counter

Found this useful? Share it:

Share on LinkedIn