How I Set Up a .NET Core 10 Web API Project to Work With GitHub Copilot
Copilot Without Context Is a Fast Way to Generate Tech Debt
Out of the box, Copilot defaults to the patterns it was trained most heavily on: controllers, [ApiController], IActionResult, DbContext calls direct in endpoints. That is Microsoft tutorial code. It is not production architecture.
The suggestions are fluent and fast. They are also wrong for any team that has moved past the scaffold-and-ship phase. Without project context, you spend as much time rejecting and rewriting Copilot's output as you save by using it.
The fix is not a prompt tweak. It is a one-time setup that locks Copilot into your conventions before it writes a single character. Every inline suggestion, every Chat response, every scaffolded file comes out shaped by your rules — not Microsoft's tutorial defaults.
If you read my Claude Code .NET Core 10 setup post, this is the same argument. The tool changes; the principle does not.
The Copilot Equivalent of CLAUDE.md
The single most impactful thing you can do is create .github/copilot-instructions.md in your repo root and commit it. GitHub Copilot reads this file automatically — no config flag, no extension setting, no opt-in. Just the file.
What belongs in it: your architecture rules, forbidden patterns, dependency preferences, return type conventions, async hygiene, logging approach, testing strategy. Everything a new team member would need to understand before they write their first endpoint.
Here is the file I use for a .NET Core 10 minimal API project:
# Project Conventions — .NET Core 10 Minimal API
## Architecture
- Use minimal APIs only. No controllers. No [ApiController]. No [Route] attributes on classes.
- Endpoints are registered in static extension methods (e.g. UserEndpoints.MapUserEndpoints).
- Use IEndpointRouteBuilder as the extension target, never WebApplication directly inside feature files.
## Data Access
- EF Core only. Every entity gets its own IEntityTypeConfiguration<T> class in Infrastructure/Persistence/Configurations/.
- Never configure entities inside OnModelCreating directly — call ApplyConfigurationsFromAssembly.
- Repository pattern: IUserRepository interface in Domain/, UserRepository implementation in Infrastructure/.
## Return Types
- All service and repository methods return Result<T> (use FluentResults or your own generic Result<T, E>).
- Never throw exceptions for business logic failures. Only throw for truly exceptional/unexpected conditions.
- Map Result<T> to IResult (TypedResults.Ok / TypedResults.Problem) at the endpoint layer only.
## Async
- Every I/O method is async. Every async method accepts CancellationToken as its last parameter.
- Never use .Result or .Wait(). Never use async void except for event handlers.
## Logging
- Serilog only. Never use ILogger<T> directly in services — inject it via constructor, type ILogger<T>.
- Use structured logging. No string interpolation in log calls — use message templates.
## Validation
- FluentValidation for all request models. Register validators with AddValidatorsFromAssembly.
- Validate at the endpoint boundary before calling any service.
## Testing
- Integration tests use Testcontainers (PostgreSQL). No in-memory database in integration tests.
- Unit tests mock repositories, never DbContext.
## Never Do This
- Never scaffold controllers with dotnet new or the VS scaffold wizard.
- Never use EF Core lazy loading.
- Never return raw entity types from endpoints — always map to a response DTO.
- Never use var when the type is not obvious from the right-hand side.
- Never add using statements for System.Linq at file level — it is implicit in .NET 10.
The "Never Do This" section matters as much as the positive rules. Copilot needs to know what the floor is, not just the ceiling.
Reinforcing it in VS Code
.vscode/settings.json lets you add workspace-specific instruction snippets on top of the shared file:
{
"github.copilot.chat.codeGeneration.instructions": [
{
"file": ".github/copilot-instructions.md"
},
{
"text": "Always use TypedResults, never plain Results. Prefer TypedResults.Ok<T> so the OpenAPI schema is inferred."
}
]
}
The file entry pulls in the shared conventions from the committed file. The text entry holds session-specific or team-specific overrides that do not need to live in the global instructions. The two are additive — both apply simultaneously.
Commit copilot-instructions.md. Check in settings.json with the file reference. Keep the text entries for anything still being iterated — branch-specific overrides, experiment-phase conventions, individual developer preferences.
Built-in Agents, and How to Build Your Own
Copilot Chat ships with three built-in participants, each scoped to a specific class of question. Most developers only ever reach for one — which means two are sitting there unused.
@workspace runs a semantic search across the VS Code-indexed project. Use it for architecture questions: "where is the authentication middleware registered?", "what services implement INotificationSender?", "what calls this repository interface?". It works against workspace embeddings built by VS Code — not a text search, a meaning search.
@vscode is scoped to editor state, settings, and installed extensions. If you need to know why a formatting rule is firing or how to configure a linter, this is the right participant. Do not send code questions here.
@terminal injects the last terminal output as context. This is the one to reach for when debugging a failed dotnet build or a migration error — paste nothing, just type @terminal what is causing this build failure? and it reads the output automatically.
Building a scoped custom extension
Built-in participants cover general IDE and project questions. For a tightly scoped workflow — EF Core migrations in a specific directory, endpoint scaffolding for a specific feature layer — a custom Copilot Extension gives you a named participant with its own system prompt.
Extensions are GitHub Apps that register a chat participant. They appear as @your-name in the Copilot Chat panel. Here is the contribution point in package.json:
{
"contributes": {
"chatParticipants": [
{
"id": "myapi.db-migration",
"fullName": "DB Migration Assistant",
"name": "db-migration",
"description": "EF Core migration helper scoped to Infrastructure/",
"isSticky": true
}
]
}
}
And the participant handler in extension.ts:
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
const participant = vscode.chat.createChatParticipant(
'myapi.db-migration',
async (request, _ctx, stream, token) => {
const infraPath = 'src/MyApi/Infrastructure/';
const systemPrompt = `You are an EF Core migration assistant.
All suggestions must target the ${infraPath} directory.
Only suggest dotnet ef migrations and dotnet ef database commands.
Never suggest schema changes outside of IEntityTypeConfiguration classes.`;
const messages = [
vscode.LanguageModelChatMessage.User(systemPrompt),
vscode.LanguageModelChatMessage.User(request.prompt),
];
const [model] = await vscode.lm.selectChatModels({ family: 'gpt-4o' });
const response = await model.sendRequest(messages, {}, token);
for await (const chunk of response.text) {
stream.markdown(chunk);
}
}
);
context.subscriptions.push(participant);
}
That is the whole extension. The key is the system prompt: scope it tightly to a directory and a command set, and the participant stops being a general-purpose AI and starts being a focused tool. The VS Code extension machinery is a dozen lines; the value is in how precisely you define the constraint.
/new-entity — One Command to Scaffold an Entire Feature Slice
Copilot Chat supports custom slash commands registered by VS Code extensions. They appear as /command-name in the chat input. Where a custom participant gives you a scoped conversational agent, a slash command gives you a repeatable, convention-enforcing operation.
/new-entity is the command I use most. One invocation scaffolds:
- The entity class in
src/MyApi/Domain/Entities/ IEntityTypeConfiguration<T>inInfrastructure/Persistence/Configurations/- The repository interface in
Domain/Repositories/ - The EF Core repository implementation in
Infrastructure/Repositories/ - The exact
dotnet ef migrations addcommand to run next
Every entity produced by this command looks identical. No one on the team has to remember where the configuration class goes, how the repository interface is named, or which project flag the migration command needs. The command knows.
Register the command in package.json:
{
"contributes": {
"chatParticipants": [
{
"id": "myapi.db-migration",
"name": "db-migration",
"commands": [
{
"name": "new-entity",
"description": "Scaffold an EF Core entity, configuration, and repository for a new domain concept."
}
]
}
]
}
}
Handle it in extension.ts inside the participant handler:
// Inside the participant handler, before the default response:
if (request.command === 'new-entity') {
const entityName = request.prompt.trim(); // e.g. "Product"
const scaffoldPrompt = `
Scaffold the following files for a new EF Core entity called "${entityName}":
1. src/MyApi/Domain/Entities/${entityName}.cs — entity class, no data annotations
2. src/MyApi/Infrastructure/Persistence/Configurations/${entityName}Configuration.cs — IEntityTypeConfiguration<${entityName}>
3. src/MyApi/Domain/Repositories/I${entityName}Repository.cs — repository interface
4. src/MyApi/Infrastructure/Repositories/${entityName}Repository.cs — EF Core implementation
Follow all conventions in .github/copilot-instructions.md.
After showing the files, output the exact shell command to run:
dotnet ef migrations add Add${entityName} --project src/MyApi
`;
const messages = [
vscode.LanguageModelChatMessage.User(scaffoldPrompt),
];
const [model] = await vscode.lm.selectChatModels({ family: 'gpt-4o' });
const response = await model.sendRequest(messages, {}, token);
for await (const chunk of response.text) {
stream.markdown(chunk);
}
return;
}
The handler is just constructing a structured prompt. There is no magic here beyond that. The critical detail is that the prompt references .github/copilot-instructions.md by path — so when your conventions change, you update one file and every subsequent /new-entity invocation picks up the change automatically. The command and the conventions stay in sync without any maintenance overhead.
Keep Copilot's Context Window Clean
Context quality matters more than context volume. Feeding Copilot bin/, obj/, and a full EF Core Migrations/ folder means the model is spending token budget on machine-generated output rather than your actual code.
.copilotignore
.copilotignore works exactly like .gitignore — it controls which files Copilot excludes from workspace indexing and inline suggestions. Create it in the repo root:
# Build output
bin/
obj/
# EF Core migrations — managed by tooling, not Copilot
**/Migrations/**
# VS user files
*.user
*.suo
.vs/
# Test results
TestResults/
The Migrations/ exclusion is the one that makes the biggest difference in practice. EF Core migration files are long, repetitive, and entirely generated — they teach Copilot nothing useful about your domain model and crowd out the files that do.
@workspace vs inline Copilot
These two modes use different context mechanisms. Inline Copilot sees the current file and the files you have open. @workspace queries a VS Code-maintained semantic index built from embeddings of your project files. .copilotignore controls what goes into that index.
Use @workspace for architecture questions and cross-file lookups. Use inline Copilot for writing new code in the context of the current file. Mixing them up means asking inline Copilot to find all implementations of an interface — it cannot, because it does not have that index. @workspace can.
The index builds on first use and updates incrementally. It does not need to be triggered manually.
Repomix for large architectural questions
When a question spans the whole codebase — "how is authentication wired end-to-end?" — even @workspace can struggle to surface the right context. Repomix flattens the repo into a single LLM-friendly file you can paste directly into Copilot Chat or reference as a #file: attachment.
I covered the full Repomix workflow in my Claude Code .NET setup post — it works identically here. Flatten, paste, ask.
For teams that want this at scale — retrieval-augmented generation over a private knowledge base with automatic context injection — custom Copilot Extensions can implement a references skill that does this automatically before every response. That is a topic for a dedicated post, but worth flagging if you are building for a larger team.
Claude Code vs GitHub Copilot: Same Workflow, Different Tools
Both tools reward the same investment: explicit conventions, scoped agents, repeatable commands. The comparison is not about which is better. It is a translation key — if you have set up one, you already know what to build for the other.
| Capability | Claude Code | GitHub Copilot |
|---|---|---|
| Project instructions file | CLAUDE.md (repo root) | .github/copilot-instructions.md |
| Custom agents / participants | Claude sub-agents (YAML in .claude/agents/) | Copilot Extensions (VS Code extension, GitHub App) |
| Custom slash commands | Claude slash command skills (.claude/commands/) | VS Code extension chatParticipants.commands contribution |
| Token-efficient repo search | Repomix + Explore sub-agent + mcp-server-filesystem | .copilotignore + @workspace indexing + Repomix |
| IDE integration | Terminal / Claude Code CLI | VS Code native (Copilot Chat panel + inline) |
The structural parallel is not a coincidence. Both tools are built around the same problem: LLMs need scoped, structured context to produce reliable output in large codebases. The mechanism differs; the approach is the same.
One Afternoon. Every Session After Is Precise.
copilot-instructions.md takes 30 to 45 minutes to write properly. The @db-migration participant and /new-entity command take an afternoon — most of that time is defining the scaffold prompt, not writing TypeScript.
After that investment, every new entity follows conventions. Every migration command is correct. Every endpoint suggestion comes shaped to your architecture rather than Microsoft's tutorial defaults. You stop reviewing Copilot output for structural correctness and start reviewing it for domain logic.
The compounding effect is real. A project six months in has dozens of entities, hundreds of endpoints, and accumulated decisions baked into the instructions file. Every one of those accumulated decisions shapes every future suggestion automatically.
If you have not set up your Claude Code context yet, the Claude Code .NET Core 10 setup post covers the same ground for Claude — start with whichever tool you use daily. The setup is one afternoon either way. The payback runs for the life of the project.