Skip to main content

Elevating Code Quality with Custom GitHub Copilot Instructions

Elevating Code Quality with Custom GitHub Copilot Instructions
Table of Contents

The first time I let Copilot loose on one of my Angular repos, it wrote a perfectly reasonable component for 2021: constructor injection, class properties for state, NgModule imports. We were on Angular 19 with signals and standalone components.

Nothing was wrong with the suggestion except that it matched the average of Copilot’s training data instead of our codebase, and every suggestion like it created review churn. The fix wasn’t a better prompt each time; it was a repository instruction file that tells Copilot how we write code, once.

This post walks through that setup for Angular 19 and .NET 8+ projects, including the actual instruction files I use as templates.

What’s covered
#

  • Why uncustomized Copilot output drifts from your standards, with a before/after example
  • Setting up .github/copilot-instructions.md and verifying it’s actually being used
  • Prompt files (public preview) for reusable, task-specific instructions
  • Full Angular 19 and .NET 8+ instruction files you can copy
  • Maintenance, troubleshooting, and team-adoption notes

Why custom instructions matter
#

GitHub Copilot, like most large language models, performs best when given clear context and guidelines. Without specific instructions, it generates code based on general patterns learned during training, which may not match your team’s coding standards or the latest framework practices.

Custom instruction files bridge this gap by giving Copilot project-specific context, effectively “teaching” the AI your preferred coding style, architectural patterns, and technical requirements.

From Generic to Specific: The Transformation
#

Consider this generic Angular component suggestion from an uncustomized Copilot:

@Component({
  selector: 'app-user-list',
  templateUrl: './user-list.component.html',
  styleUrls: ['./user-list.component.css']
})
export class UserListComponent implements OnInit {
  users: User[] = [];
  
  constructor(private userService: UserService) { }
  
  ngOnInit(): void {
    this.userService.getUsers().subscribe(users => {
      this.users = users;
    });
  }
}

The code works, but it ignores Angular 19’s newer features. With custom instructions that call for signals and functional injection, Copilot produces this instead:

@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './user-list.component.html',
  styleUrls: ['./user-list.component.css']
})
export class UserListComponent {
  private userService = inject(UserService);
  
  users = signal<User[]>([]);
  loading = signal<boolean>(false);
  
  constructor() {
    this.loadUsers();
  }
  
  private loadUsers(): void {
    this.loading.set(true);
    this.userService.getUsers().subscribe({
      next: (data) => {
        this.users.set(data);
        this.loading.set(false);
      },
      error: (err) => {
        console.error('Failed to load users', err);
        this.loading.set(false);
      }
    });
  }
}

Same request, different era of Angular: signals for state, inject() instead of the constructor, standalone component. That is the whole value proposition in one diff.

Setting Up Custom Instructions in VS Code
#

GitHub Copilot supports two types of custom instructions files:

  1. Repository custom instructions - These apply to any conversation held in the context of the repository, providing codebase-wide context.
  2. Prompt files (public preview) - These allow you to save common prompt instructions in Markdown files that you can reuse in specific chat interactions.

Repository Custom Instructions
#

Repository custom instructions allow you to specify project-wide instructions in a single file. To set this up:

  1. Create a .github folder at the root of your repository
  2. Inside the .github folder, create a file named copilot-instructions.md
  3. Add your custom instructions to this file using Markdown format

Prerequisites:

  • The “Use Instruction Files” option must be enabled in your settings (this is enabled by default)
  • Instructions should be written as short, self-contained statements in Markdown format

For team-wide settings, the .github/copilot-instructions.md file is ideal as it can be version-controlled and shared across the team.

Verifying Your Instructions Are Being Used
#

Custom instructions are not visible in the Chat view or inline chat, but you can verify they’re being used by looking at the References list of a response in the Chat view. If custom instructions were added to the prompt, the .github/copilot-instructions.md file is listed as a reference, and you can click it to open the file.

Using Prompt Files (Public Preview)
#

Prompt files, currently in public preview, let you build and share reusable prompt instructions with additional context. Custom instructions add codebase-wide context; prompt files add instructions to specific chat interactions.

Enabling Prompt Files
#

To enable prompt files:

  1. Open the command palette (Ctrl+Shift+P on Windows/Linux or Cmd+Shift+P on Mac)
  2. Type “Open Workspace Settings (JSON)” and select it
  3. Add "chat.promptFiles": true to enable the .github/prompts folder as the location for prompt files

Creating Prompt Files
#

  1. Open the command palette (Ctrl+Shift+P on Windows/Linux or Cmd+Shift+P on Mac)
  2. Type “prompt” and select “Chat: Create Prompt”
  3. Enter a name for your prompt file (the .prompt.md extension will be added automatically)
  4. Write your prompt instructions using Markdown formatting
  5. You can reference other files in the workspace using Markdown links (e.g., [index](../../web/index.ts)) or using the #file:../../web/index.ts syntax

Using Prompt Files
#

  1. At the bottom of the Copilot Chat view, click the Attach context icon
  2. In the dropdown menu, click “Prompt…” and choose the prompt file you want to use
  3. Optionally, attach additional files, including other prompt files, to provide more context
  4. Type any additional information in the chat prompt box if needed
  5. Submit the chat prompt

Prompt File Examples
#

Here’s an example of a prompt file for generating a new React form component:

Your goal is to generate a new React form component.

Ask for the form name and fields if not provided.

Requirements for the form:
- Use form design system components: [design-system/Form.md](../docs/design-system/Form.md)
- Use `react-hook-form` for form state management:
  - Always define TypeScript types for your form data
  - Prefer *uncontrolled* components using register
  - Use `defaultValues` to prevent unnecessary rerenders
- Use `yup` for validation:
  - Create reusable validation schemas in separate files
  - Use TypeScript types to ensure type safety
  - Customize UX-friendly validation rules

Please note the design system document should be present at given path.

Alternative Setup Methods
#

If you’re unable to use the repository custom instructions approach, you can also:

  1. Use VS Code settings:
    • Create a .vscode/settings.json file
    • Add custom instructions under the GitHub Copilot section
  "github.copilot.chat.codeGeneration.instructions": [
  {
    "text": "Always add a comment: 'Generated by Copilot'."
  },
  {
    "text": "In TypeScript always use underscore for private field names."
  },
  {
    "file": "angular-instructions.md" // import instructions from file `angular-instructions.md`
  },
  {
    "file": "dotnet-instructions.md" // import instructions from file `dotnet-instructions.md`
  }
],
  1. Set up workspace-level instructions:

    • Open VS Code’s Command Palette (Ctrl+Shift+P / Cmd+Shift+P)
    • Type “Copilot: Configure”
    • Select “Configure for Current Repository”
    • Paste your instructions
  2. Use personal instructions:

    • Through GitHub.com → Settings → Copilot → Custom Instructions
    • These apply to all your projects but don’t get shared with the team

Enabling or Disabling Repository Custom Instructions
#

You can choose whether or not to have custom instructions added to your chat questions:

  1. Open the Setting editor by using the keyboard shortcut Cmd+, (Mac) or Ctrl+, (Linux/Windows)
  2. Type “instruction file” in the search box
  3. Select or clear the checkbox under “Code Generation: Use Instruction Files”

Getting more out of Copilot Chat commands
#

Beyond custom instructions, GitHub Copilot Chat has slash commands worth building into your workflow:

Chat commands worth knowing
#

  • /explain - Get detailed explanations of complex code segments
  • /fix - Identify and suggest fixes for bugs or issues
  • /tests - Generate unit tests for your functions or classes
  • /doc - Create documentation for your code with proper formatting
  • /optimize - Receive suggestions for performance improvements

Workspace Context Awareness
#

GitHub Copilot automatically understands your workspace context by analyzing:

  • File structure and project organization
  • Dependencies in package.json, requirements.txt, or project files
  • Existing code patterns and conventions
  • Git history and recent changes
Key point: When asking Copilot Chat questions, reference specific files with the #file:path/to/file.ts syntax. The extra context noticeably improves suggestion accuracy.

Example Chat Interactions
#

/tests #file:src/services/user.service.ts
Generate comprehensive unit tests for the UserService class including edge cases and error scenarios.

/explain #file:src/components/user-profile.component.ts
Explain how this Angular component uses signals for state management and the benefits of this approach.

/fix 
The authentication is failing intermittently. Here's the error: [paste error message]

Sample Custom Instruction Files
#

Below are the full custom instruction files for Angular 19 and .NET 8+ that I use as templates. Copy them and edit the parts that don’t match your stack.

Angular 19 Custom Instructions
#

For Angular 19, create a file at .github/angular-instructions.md with the following content:

# GitHub Copilot Instructions for Angular 19+

Generate code following these modern Angular 19 + best practices:

## Dependency Injection
- Always use functional injection with `inject()` instead of constructor-based injection
- Example: `private userService = inject(UserService);` instead of constructor injection
- Apply this for services, tokens, and parent component references

## State Management
- Use signals for component state instead of class properties
  - Example: `counter = signal<number>(0);`
- Use computed signals for derived state
  - Example: `doubleCounter = computed(() => this.counter() * 2);`
- Prefer signals over RxJS observables for component-level state

## Component Structure
- Use standalone components as the default approach
- Use the modern control flow syntax (`@if`, `@for`, etc.)
- Implement OnPush change detection strategy for better performance

## Services & HTTP
- For service calls using subscribe, use the next syntax:
  this.userService.getUsers().subscribe({
    next: (users) => this.users.set(users),
    error: (error) => this.errorMessage.set(error)
  });

## Routing & Guards
- Use functional syntax for guards and resolvers
- Example:
  export const authGuard = () => {
    const router = inject(Router);
    const authService = inject(AuthService);
    
    if (authService.isAuthenticated()) {
      return true;
    }
    
    return router.parseUrl('/login');
  };

## HTTP Interceptors
- Use functional interceptors
- Example:
  export const authInterceptor: HttpInterceptorFn = (req, next) => {
    const authService = inject(AuthService);
    const token = authService.getToken();
    
    if (token) {
      const authReq = req.clone({
        headers: req.headers.set('Authorization', `Bearer ${token}`)
      });
      return next(authReq);
    }
    
    return next(req);
  };

## Styling
- Use Angular Material 19 with proper theming
- Implement TailwindCSS for utility-first styling
- Ensure components support theme switching
- Apply consistent color tokens from design system

## Input/Output Handling
- Use strongly typed inputs and outputs
- Example:
  @Input({ required: true }) id!: string;
  @Output() saved = new EventEmitter<User>();

## Sample Component
import { Component, computed, signal, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { UserService } from './user.service';

@Component({
  selector: 'app-user-profile',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule, MatButtonModule, MatInputModule],
  template: `
    <div class="p-4 bg-surface-container rounded-lg">
      <h2 class="text-xl font-bold text-on-surface mb-4">User Profile</h2>
      
      @if (isLoading()) {
        <div class="flex justify-center">
          <mat-spinner diameter="40"></mat-spinner>
        </div>
      } @else if (user()) {
        <div class="mb-4">
          <p class="text-on-surface-variant">Username: {{user()?.username}}</p>
          <p class="text-on-surface-variant">Email: {{user()?.email}}</p>
        </div>
        
        <button mat-raised-button color="primary" class="mt-2" (click)="onEdit()">
          Edit Profile
        </button>
      } @else {
        <p class="text-error">User not found</p>
      }
    </div>
  `
})
export class UserProfileComponent {
  @Input() set userId(id: string) {
    this.loadUser(id);
  }
  @Output() editRequested = new EventEmitter<string>();
  
  private userService = inject(UserService);
  
  user = signal<User | null>(null);
  isLoading = signal<boolean>(false);
  error = signal<string | null>(null);
  
  userInitials = computed(() => {
    const u = this.user();
    return u ? `${u.firstName.charAt(0)}${u.lastName.charAt(0)}` : '';
  });
  
  private loadUser(id: string): void {
    this.isLoading.set(true);
    this.error.set(null);
    
    this.userService.getUserById(id).subscribe({
      next: (userData) => {
        this.user.set(userData);
        this.isLoading.set(false);
      },
      error: (err) => {
        this.error.set('Failed to load user');
        this.isLoading.set(false);
      }
    });
  }
  
  onEdit(): void {
    if (this.user()) {
      this.editRequested.emit(this.user()!.id);
    }
  }
}

.NET 8+ Custom Instructions
#

For .NET 8+, create a file at .github/dotnet-instructions.md with the following content:

# GitHub Copilot Instructions for .NET 8+

Generate code following these modern .NET 8+ best practices:

## C# Language Features
- Use C# 12+ features such as primary constructors, required properties, file-scoped namespaces
- Use pattern matching where appropriate
- Use nullable reference types with proper null checks
- Use records for DTOs and immutable data

## Project Architecture
- Follow Clean Architecture principles with Domain/Application/Infrastructure layers
- Use minimal APIs for simpler endpoints
- Prefer vertical slice architecture with MediatR for larger applications
- Use CQRS pattern to separate reads and writes when appropriate

## APIs and Endpoints
- For minimal APIs:
  app.MapGet("/api/products", async (IMediator mediator) => 
      await mediator.Send(new GetProductsQuery()))
      .WithName("GetProducts")
      .WithOpenApi()
      .RequireAuthorization("read:products");

- For controller-based APIs:
  [HttpGet]
  public async Task<ActionResult<IEnumerable<ProductDto>>> GetProducts(
      CancellationToken cancellationToken)
  {
      var query = new GetProductsQuery();
      var result = await _mediator.Send(query, cancellationToken);
      return Ok(result);
  }

## Dependency Injection
- Use constructor injection with required parameters:
  public class ProductService(
      IRepository<Product> repository,
      IMapper mapper,
      ILogger<ProductService> logger)
      : IProductService 
  {
      // Properties are implicitly created from constructor parameters
  }

## Entity Framework Core
- Use EF Core 8.0+ features
- Configure entities using Fluent API in separate configuration classes
- Use strongly-typed IDs when appropriate
- Use bulk operations for performance
- Use compiled queries for frequently used queries

## Error Handling
app.UseExceptionHandler(exceptionHandlerApp => 
{
    exceptionHandlerApp.Run(async context => 
    {
        context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        context.Response.ContentType = "application/problem+json";
        
        var exceptionHandlerFeature = context.Features.Get<IExceptionHandlerFeature>();
        if (exceptionHandlerFeature != null)
        {
            var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
            logger.LogError(exceptionHandlerFeature.Error, "Unhandled exception");
            
            var problemDetails = new ProblemDetails
            {
                Status = StatusCodes.Status500InternalServerError,
                Title = "An unexpected error occurred",
                Detail = exceptionHandlerFeature.Error.Message,
                Instance = context.Request.Path
            };
            
            await context.Response.WriteAsJsonAsync(problemDetails);
        }
    });
});

## Authentication & Authorization
- Use ASP.NET Core Identity or custom JWT authentication
- Implement proper role/policy-based authorization
- Use Auth0 or IdentityServer for OAuth2/OpenID Connect

## Performance Optimization
- Use Memory Cache or Distributed Cache appropriately
- Implement rate limiting for APIs
- Use output caching middleware for HTTP responses
- Use response compression
- Implement proper database indexing

## Sample Service Implementation
public class GetProductByIdHandler : IRequestHandler<GetProductByIdQuery, ProductDto?>
{
    private readonly ApplicationDbContext _dbContext;
    private readonly IMapper _mapper;
    private readonly ILogger<GetProductByIdHandler> _logger;

    public GetProductByIdHandler(
        ApplicationDbContext dbContext,
        IMapper mapper,
        ILogger<GetProductByIdHandler> logger)
    {
        _dbContext = dbContext;
        _mapper = mapper;
        _logger = logger;
    }

    public async Task<ProductDto?> Handle(
        GetProductByIdQuery request,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation("Getting product with ID {ProductId}", request.Id);
        
        var product = await _dbContext.Products
            .AsNoTracking()
            .FirstOrDefaultAsync(p => p.Id == request.Id, cancellationToken);
            
        return product is null ? null : _mapper.Map<ProductDto>(product);
    }
}

## Configuration and Options Pattern
public class EmailSettings
{
    public required string SmtpServer { get; init; }
    public required int Port { get; init; }
    public required string Username { get; init; }
    public required string Password { get; init; }
    public bool EnableSsl { get; init; } = true;
}

// In Program.cs
builder.Services.Configure<EmailSettings>(
    builder.Configuration.GetSection("EmailSettings"));

// In service
public class EmailService(IOptions<EmailSettings> options)
{
    private readonly EmailSettings _settings = options.Value;
    
    public async Task SendEmailAsync(string to, string subject, string body)
    {
        // Implementation using _settings
    }
}

## Logging
builder.Host.UseSerilog((context, services, configuration) => configuration
    .ReadFrom.Configuration(context.Configuration)
    .ReadFrom.Services(services)
    .Enrich.FromLogContext()
    .WriteTo.Console());

## Health Checks and Monitoring
builder.Services.AddHealthChecks()
    .AddDbContextCheck<ApplicationDbContext>()
    .AddCheck("Redis", new RedisHealthCheck(_connectionMultiplexer))
    .AddUrlGroup(new Uri("https://api.example.com/health"), name: "External API");

What you actually get out of this
#

The payoff goes beyond nicer autocomplete:

  • Consistent code quality: codifying practices in instruction files means AI-assisted code follows the same standards regardless of who wrote the prompt. This matters most on teams with mixed experience levels or during onboarding.
  • Faster adoption of new patterns: during a framework upgrade (.NET 8, Angular 19), instructions steer Copilot toward the target patterns, so developers see the new style in every suggestion instead of hunting through migration docs.
  • Less technical debt: steering Copilot away from deprecated patterns and known anti-patterns stops debt at the point of generation, before review.
  • Knowledge transfer: the instruction file doubles as living documentation of how your team writes code. New joiners read one file instead of reverse-engineering conventions.
  • Cheaper code reviews: when generated code already matches house style, reviews spend their time on business logic and architecture instead of pattern policing.

The migration case is the one I’d push hardest. An instruction file that says “signals over RxJS for component state, standalone components, functional injection” effectively encodes your migration guide into every developer’s editor.

Best Practices for Instruction File Maintenance
#

Like any living documentation, Copilot instruction files require regular maintenance:

  1. Version Control: Keep the .github/copilot-instructions.md file in version control to track changes over time.
  2. Regular Updates: Update instructions when adopting new framework versions or when evolving coding standards.
  3. Team Contributions: Allow team members to suggest improvements to the instruction files through pull requests.
  4. Periodic Review: Schedule regular reviews of instruction effectiveness and refine as needed.
  5. Framework-Specific Separation: You can maintain separate sections in your Markdown file for different frameworks or create multiple prompt files if needed.

Troubleshooting Common Issues
#

Instructions Not Being Applied
#

If your custom instructions aren’t being used:

  1. Verify the file path: the file must be exactly at .github/copilot-instructions.md
  2. Check settings: Confirm “Use Instruction Files” is enabled in VS Code settings
  3. File format: Make sure your instruction file uses proper Markdown formatting
  4. Repository context: Instructions only apply when working within the repository containing the file

Poor Suggestion Quality
#

If Copilot suggestions don’t match your instructions:

  1. Be specific: Use concrete examples rather than vague descriptions
  2. Add context: Include technology versions and specific frameworks
  3. Use constraints: Explicitly state what NOT to do alongside what to do
  4. Test iteratively: Refine instructions based on actual suggestions received

Performance Issues
#

If Copilot becomes slow with custom instructions:

  1. Optimize file size: Keep instruction files concise and focused
  2. Split complex instructions: Use multiple prompt files for different scenarios
  3. Regular cleanup: Remove outdated or conflicting instructions

Team Adoption Challenges
#

To improve team adoption of custom instructions:

  1. Document benefits: Share before/after examples of improved suggestions
  2. Gradual rollout: Start with core patterns and expand incrementally
  3. Training sessions: Conduct workshops on using Copilot Chat effectively
  4. Feedback loops: Create channels for team members to suggest improvements

Worth remembering
#

  • Uncustomized Copilot writes the average of its training data. If your codebase isn’t average, every suggestion needs correcting.
  • One .github/copilot-instructions.md file, version-controlled and reviewed like code, fixes that for the whole team.
  • Concrete examples beat vague rules in instruction files; “use inject(), here’s what that looks like” outperforms “follow modern DI practices”.
  • Check the References list in Chat responses to confirm the instructions are actually being applied. Silent misconfiguration is the most common failure.
  • Instruction files rot. Treat them like documentation: update them with each framework upgrade and accept pull requests against them.

Where I’d start
#

Write one instructions file for your main repository this week. Don’t aim for the templates above on day one; start with the five rules that generate the most review comments on your team, then watch what Copilot suggests for a few days and refine. The Angular and .NET files in this post are where mine ended up after that loop, not where they started.

Related

Handling Authorization and Role-Based Access Control (RBAC)

··19 mins
Introduction # Static role checks ([Authorize(Roles = "Admin")]) fall apart the first time someone asks you to add a permission without a redeploy. Once roles and permissions have to change at runtime, hard-coded role attributes become a liability. The Contact Management Application takes a different route: a dynamic policy provider that builds authorization policies from the database at request time, covering both the backend API and the Angular frontend, wired into JWT authentication without breaking the separation of concerns Clean Architecture expects.