GitHub Copilot for C#

GitHub Copilot Programming LanguagesAcademy lesson 31Cluster 3 · Lesson 6 of 13Intermediate14 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for C#GitHub Copilot Programming Languages6Intermediate/github-copilot/languages/csharp/

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.

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:

  • required members on types that must be fully initialised. Without it, an object initialiser that forgets a property compiles.
  • init rather than set for properties that should not change after construction.
  • readonly struct for small value types, if the suggestion produced a struct at 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 a NullReferenceException waiting for the first foreach.

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:

  • CancellationToken not 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 catch inside 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

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

Copilot promptGenerate the endpointCopilot Chat, with the .csproj open

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, not double. .NET’s decimal is a base-10 type designed for money; double is binary floating point and will produce totals that are off by a fraction of a cent. Generated code reaches for double unless told otherwise.
  • MidpointRounding.AwayFromZero. Math.Round defaults to banker’s rounding, so Math.Round(2.5m) is 2. 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 test

Dependency 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 matchingswitch expressions and property patterns rather than chains of if with 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.Json rather than Newtonsoft.Json unless you need something only the latter provides.
  • HttpClient via IHttpClientFactory rather than WebClient or a hand-managed static client.
  • TimeProvider rather than DateTime.Now where 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

Accepting a C# suggestion
  1. Build with warnings as errors, then run the testsdotnet build -warnaserror && dotnet test. Roslyn analyzers do a large part of the review here.
  2. Grep for !, #pragma warning disable and dynamicHuman judgementEach is a place the compiler was told to stop checking.
  3. Check every LINQ query's source typeHuman judgementIQueryable or IEnumerable? Where does the filtering actually happen?
  4. Grep for .Result, .Wait() and async voidHuman judgement
  5. Confirm CancellationToken is passed all the way downHuman judgement
  6. 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, decimal for money, and an explicit MidpointRounding.
  • Name the test framework and ask for data-driven tests.
  • State “EF Core IQueryable” in the prompt whenever the code touches a database.
  • Use IHttpClientFactory rather than a new HttpClient.

Common mistakes

  • Accepting ! to make a warning go away.
  • Calling ToList() before the filter and never noticing.
  • Letting async void into 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.

Primary sources