GitHub Copilot for C#
This lesson is about the language and the runtime, not the editor. GitHub Copilot with Visual Studio in Cluster 2 covers solution context, the .NET Upgrade Agent and the IDE features; none of that is repeated here. What is here applies whether you work in Visual Studio, VS Code, Rider or a terminal.
C# occupies an unusual position in this cluster. It has a strong type system like
Java, but its most important safety feature —
nullable reference types — is opt-in, and whether it is on changes what correct
code looks like. It has LINQ, which is expressive and hides where the work
happens. And it has async/await, which C# invented for mainstream languages
and which generated code still gets wrong in the same two ways it has for a
decade.
C# quick referenceVerified August 21, 2026
- Type system
- Statically typed with nullable reference types as an opt-in compiler feature; whether it is on changes what correct code looks like.
- Package management
- NuGet via the dotnet CLI
- Manifest Copilot should see
- .csproj
- Testing
- xUnit, NUnit, MSTest
- Formatting
- dotnet format
- Linting
- Roslyn analyzers
- Static analysis
- Roslyn analyzers, nullable reference types
- Common frameworks
- ASP.NET Core, Entity Framework Core
- Typical Copilot work
- Minimal API endpoints, LINQ queries, DI registration, records and DTOs, xUnit tests
- First thing to review
- Async code that blocks, or LINQ that silently runs in memory instead of in the database
Fastest honest checkdotnet build -warnaserror && dotnet test
Key takeaways
- Whether nullable reference types are enabled is the single most important fact about your project for reviewing generated C#. With them off, the compiler is silent about the most common runtime failure in the language.
!— the null-forgiving operator — is C#‘s escape hatch. Every occurrence in a generated diff is a place the model could not satisfy the compiler.- LINQ over
IQueryableand LINQ overIEnumerablelook identical and behave completely differently. The first runs in the database; the second downloads the table. async void, and.Resultor.Wait()on a task, are the two async mistakes worth grepping for on every review.- The fastest honest check is
dotnet build -warnaserror && dotnet test.
Nullable reference types, and why they change everything
C# 8 introduced nullable reference types as an opt-in feature. In a project with
<Nullable>enable</Nullable>, string means “never null” and string? means
“may be null”, and the compiler warns when you dereference something it cannot
prove is present.
In a project without it, string means “may be null” and the compiler says
nothing at all.
This matters enormously for generated code. NullReferenceException is the most
common runtime failure in C#, and nullable reference types convert most of it
into a compile-time warning — the same transformation strictNullChecks performs
in TypeScript. With the feature off,
that entire category is invisible until production.
Two practical points:
Turn it on, and turn the warnings into errors. <Nullable>enable</Nullable>
plus <TreatWarningsAsErrors>true</TreatWarningsAsErrors> in the project file.
On a legacy codebase this is a migration, but it can be done file by file with
#nullable enable.
Watch for the null-forgiving operator. user!.Email tells the compiler “trust
me.” If the model added it to silence a warning rather than because it knows
something the compiler does not, it has converted a compile-time question into a
runtime crash — and the crash will be in the code path with the least test
coverage, which is the failure path.
Records, properties and the shape of data
Ask for a record explicitly when you want an immutable data carrier. Suggestions
still default to a class with mutable auto-properties, because that is what most
public C# looks like.
record gives you value equality, with expressions and a concise positional
syntax in one line. For a DTO or an API response model this is almost always what
you wanted, and it removes a category of bug: a mutable DTO shared between
requests is a data race that looks like ordinary code.
Related things to check in generated type definitions:
requiredmembers on types that must be fully initialised. Without it, an object initialiser that forgets a property compiles.initrather thansetfor properties that should not change after construction.readonly structfor small value types, if the suggestion produced astructat all — a mutable struct is a well-known source of surprising behaviour when copied.- Collection properties initialised to an empty collection rather than left
null. A null
List<T>property is aNullReferenceExceptionwaiting for the firstforeach.
LINQ: the thing that looks the same and is not
This is the most C#-specific review item in the lesson.
// IEnumerable<Order> — runs in memory, in this process.
var recent = orders.Where(o => o.PlacedAt > cutoff).ToList();
// IQueryable<Order> — translated to SQL and run in the database.
var recent = db.Orders.Where(o => o.PlacedAt > cutoff).ToList();Identical syntax. Completely different execution. Generated code cannot tell you which one it produced, because the difference is in the static type of the source, and that type is frequently several lines away.
Three failure modes follow, all of which appear in generated Entity Framework code:
Accidental client evaluation. A Where clause containing a method EF Core
cannot translate to SQL. Modern EF Core throws rather than silently downloading
the table — which is the right behaviour and a considerable improvement — but a
suggestion that calls a local helper method inside a query will hit it, and the
error message is not obviously about that.
ToList() too early. db.Orders.ToList().Where(...) downloads every order
and filters in memory. It works. It is fine on a hundred rows and fatal on a
million. This is one of the few performance bugs that is genuinely invisible in
review unless you are looking for the call order.
N+1 queries. A loop over entities that touches a navigation property issues
one query per row. Ask for Include explicitly, or project into a DTO with
Select so only the columns you need cross the wire.
Async and await
C# has had async/await longer than most languages, so suggestions are
generally idiomatic. Two mistakes persist because they are common in public code.
async void. Legal only for event handlers. Anywhere else, the caller cannot
await it and an exception inside it cannot be caught — it goes to the
unobserved-exception handler and typically ends the process. If a generated method
is async void and is not an event handler, it is wrong.
Blocking on a task. .Result, .Wait(), or .GetAwaiter().GetResult() on an
incomplete task. In some contexts this deadlocks outright; in ASP.NET Core it
consumes a thread-pool thread doing nothing, which under load becomes thread-pool
starvation and a service that stops responding while showing no errors.
Three more worth checking:
CancellationTokennot plumbed through. Generated handlers accept one and then never pass it to the calls they make. The result is a request that keeps working after the client has gone.ConfigureAwait(false)in library code. Less critical in ASP.NET Core than it once was, but still correct in a shared library.- Fire-and-forget tasks. A task started and not awaited runs unobserved. If a
suggestion does this deliberately, there should be a
catchinside it.
Practical project: an ASP.NET Core minimal API
Practical example
A minimal API endpoint with validation, a record DTO and an xUnit test
The shape a well-prompted .NET suggestion should take, and the specific things to check in it.
- Status
- Example implementation
- Not executed here
- This code was written against the documentation cited at the end of the lesson but was not run while writing it. Treat the commands as the ones to run, not as output that has been observed.
- Requires
- A .NET SDK (8 or later) and network access to restore packages
Files
copilot-csharp-demo/ ├── src/Orders.Api/ │ ├── Orders.Api.csproj │ ├── Program.cs │ └── OrderTotals.cs └── tests/Orders.Api.Tests/ ├── Orders.Api.Tests.csproj └── OrderTotalsTests.cs
The prompt
ASP.NET Core minimal API, .NET 8, nullable reference types enabled.
Create a record OrderLine with Quantity (int) and UnitPrice (decimal).
Create a static OrderTotals.Total that takes IReadOnlyList of OrderLine and returns decimal, rounded to 2 places with MidpointRounding.AwayFromZero.
Map POST /orders/total. Return 400 with a ProblemDetails body when any quantity or price is negative, or the list is empty.
Use decimal throughout, never double. Accept a CancellationToken and pass it on.
Implementation
public sealed record OrderLine(int Quantity, decimal UnitPrice);
public static class OrderTotals
{
public static decimal Total(IReadOnlyList<OrderLine> lines)
{
ArgumentNullException.ThrowIfNull(lines);
decimal sum = 0m;
foreach (var line in lines)
{
sum += line.Quantity * line.UnitPrice;
}
return Math.Round(sum, 2, MidpointRounding.AwayFromZero);
}
}var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/orders/total", (OrderLine[] lines) =>
{
if (lines.Length == 0)
{
return Results.Problem("At least one line is required.", statusCode: 400);
}
if (lines.Any(l => l.Quantity < 0 || l.UnitPrice < 0))
{
return Results.Problem("Quantity and unit price must not be negative.",
statusCode: 400);
}
return Results.Ok(new { total = OrderTotals.Total(lines) });
});
app.Run();Two details worth naming:
decimal, notdouble. .NET’sdecimalis a base-10 type designed for money;doubleis binary floating point and will produce totals that are off by a fraction of a cent. Generated code reaches fordoubleunless told otherwise.MidpointRounding.AwayFromZero.Math.Rounddefaults to banker’s rounding, soMath.Round(2.5m)is2. That is correct .NET and frequently not what a financial requirement meant.
Test
public class OrderTotalsTests
{
[Fact]
public void EmptyListTotalsZero() =>
Assert.Equal(0m, OrderTotals.Total(Array.Empty<OrderLine>()));
[Theory]
[InlineData(3, 1.005, 3.02)]
[InlineData(1, 2.00, 2.00)]
[InlineData(2, 0.005, 0.01)]
public void RoundsAwayFromZeroAtTheMidpoint(int qty, decimal price, decimal expected) =>
Assert.Equal(expected, OrderTotals.Total(new[] { new OrderLine(qty, price) }));
}[Theory] with [InlineData] is the xUnit form worth asking for by name.
Suggestions default to several near-identical [Fact] methods, which hides which
boundary cases are missing.
Run
dotnet build -warnaserror
dotnet testDependency injection and the project system
.NET’s built-in container is a genuine advantage for generated code — it is standard, so suggestions target it consistently rather than picking one of five third-party frameworks. The failures are about lifetimes, and they are quiet.
Lifetime mismatches. A singleton that depends on a scoped service is the
classic captive-dependency bug: the scoped service is resolved once and then
outlives every scope, so a DbContext intended to live for one request lives for
the life of the process. Modern .NET detects some of these at startup when scope
validation is on, which it is by default in development and frequently not in
production. That asymmetry is worth knowing: the bug your development environment
catches may be the bug your production environment ships.
A registration that was never added. AddScoped<IOrderService, OrderService>()
missing from Program.cs is not a compile error. It is a runtime failure at the
first request that needs it. Generated code that introduces a new service class
frequently does not register it, because the registration lives in a different
file the model may not have had in context.
Mutable state on a singleton. Same problem as the Spring case in GitHub Copilot for Java: a field on a singleton is shared across every concurrent request, and the code that puts it there looks like an ordinary private field.
The project system itself is worth a paragraph. .csproj files are small, they
carry the language version, the nullable setting, the analysis level and every
package reference — and they are the file to keep open for exactly that reason.
A suggestion that uses a C# 12 feature in a project targeting an older language
version fails to compile with a message about the language version rather than
about the feature, which is confusing the first time you see it.
NuGet and the package graph
The .NET package ecosystem is smaller and more centralised than npm, which makes outright hallucinated package names rarer. Two other problems take their place.
Transitive version conflicts. .NET resolves a single version of each assembly
for the whole application. Two packages wanting different versions of a third
produce either a build warning that is easy to ignore or a runtime
MissingMethodException that is not. A suggestion that adds a package is
therefore not a local change — it is a change to a shared graph.
Preview and out-of-band packages. Many .NET libraries ship versions aligned to a specific runtime. A package version intended for a newer runtime than you target will restore and then fail at load. The error mentions an assembly version, not the package, so tracing it back takes longer than it should.
The habit that covers both: run a restore and a build immediately after accepting any package reference, before writing the code that uses it. It takes seconds and it localises the failure to the change that caused it, rather than to the ten-file diff you are about to write on top of it.
Central Package Management — versions declared once in
Directory.Packages.props — makes this considerably easier to review, because a
suggested version change appears in one file rather than scattered across
projects.
Modern C# and the drift toward older idioms
C#‘s public corpus spans more than two decades, and the language has changed substantially. Generated code trends toward the middle of that corpus, which is older than what you are probably writing.
Patterns worth asking for by name, because the defaults skew older:
- Pattern matching —
switchexpressions and property patterns rather than chains ofifwith casts. More concise and, with the compiler’s exhaustiveness analysis, safer. - Collection expressions and target-typed
new, which remove a great deal of repetition from initialisation code. System.Text.Jsonrather thanNewtonsoft.Jsonunless you need something only the latter provides.HttpClientviaIHttpClientFactoryrather thanWebClientor a hand-managed static client.TimeProviderrather thanDateTime.Nowwhere the code needs to be testable, which is most places that read the clock.
None of these is a correctness issue on its own. Collectively they are a useful signal: a suggestion written entirely in the idioms of an older C# is a suggestion drawn from older code, and the rest of it deserves the same second look for the same reason.
Testing
xUnit, NUnit and MSTest are all mainstream, and Copilot will produce whichever matches the surrounding project — or xUnit if there is nothing to go on. Name the one you use.
The .NET-specific test advice:
- Ask for
[Theory]/[TestCase]tables rather than repeated facts. - Question the mocks. Generated tests reach for Moq or NSubstitute reflexively. A record and a pure static method need neither.
- For integration tests, ask for
WebApplicationFactory. It is the supported way to exercise an ASP.NET Core app in-process, and it is what a suggestion will produce only if you say so. - Use the EF Core in-memory provider with care. It does not enforce constraints or reproduce SQL translation behaviour, so a test that passes against it can fail against a real database. For query-shape tests, use the real engine.
Static analysis and formatting
.NET ships its analysis with the compiler, which is a genuine advantage.
Roslyn analyzers run as part of the build. Raise the analysis level in the project file and turn warnings into errors, and a large set of correctness problems — including most of the async mistakes above — fails the build rather than reaching review.
dotnet format removes style from the diff. As elsewhere in this cluster,
this matters more with generated code, because a suggestion arrives in whatever
style the model inferred.
.editorconfig is the file that makes both of the above consistent across a
team, and it is also context: a model that can see it produces code that matches
your conventions.
C#-specific risks
Nullable warnings suppressed with !. Covered above; the most common.
Client-side LINQ evaluation. Covered above; the most expensive.
Blocking async. .Result and .Wait(). Thread-pool starvation under load.
IDisposable not disposed. Any HttpClient message, DbConnection,
FileStream or CancellationTokenSource outside a using. The related and
opposite mistake: creating a new HttpClient per request, which exhausts sockets.
IHttpClientFactory is the answer, and generated code rarely uses it unprompted.
Struct and value-type surprises. A mutable struct copied on assignment, a
DateTime with an unspecified Kind compared against a UTC one.
Version drift. .NET Framework idioms in a .NET 8 project, Newtonsoft.Json
where System.Text.Json is available, WebClient instead of HttpClient.
Debugging and refactoring
For debugging, paste the full exception including inner exceptions — .NET’s
AggregateException in particular hides the real cause one or two levels down,
and a model reading the whole chain finds it faster than scrolling does.
Refactoring is strong for the same reason as Java: the compiler enumerates the breakage. With nullable reference types enabled, a signature change that introduces a null propagates as warnings through every affected call site, which turns a risky refactor into a work list.
Review workflow
- Build with warnings as errors, then run the testsdotnet build -warnaserror && dotnet test. Roslyn analyzers do a large part of the review here.
- Grep for !, #pragma warning disable and dynamicHuman judgementEach is a place the compiler was told to stop checking.
- Check every LINQ query's source typeHuman judgementIQueryable or IEnumerable? Where does the filtering actually happen?
- Grep for .Result, .Wait() and async voidHuman judgement
- Confirm CancellationToken is passed all the way downHuman judgement
- Check every IDisposable is in a usingHuman judgement
Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.
Best practices
- Enable nullable reference types and treat warnings as errors. Nothing else in this lesson matters as much.
- Ask for records,
decimalfor money, and an explicitMidpointRounding. - Name the test framework and ask for data-driven tests.
- State “EF Core
IQueryable” in the prompt whenever the code touches a database. - Use
IHttpClientFactoryrather than a newHttpClient.
Common mistakes
- Accepting
!to make a warning go away. - Calling
ToList()before the filter and never noticing. - Letting
async voidinto non-event-handler code. - Binding a request body straight onto an entity.
- Treating an in-memory-provider test as evidence a query works.
Where to go next
GitHub Copilot with Visual Studio covers the IDE features this lesson deliberately skipped. GitHub Copilot for Java is the closest neighbour if you work across both ecosystems, and GitHub Copilot for SQL is the follow-on for the EF Core concerns above.
Sources
Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.