AI writes .NET code faster than anyone on your team.
It can also write code that looks good but can fall apart during careful review.
I have spent years improving the quality of code I write, long before the AI was around. I learned to write code that is easy to read and maintain, and I have been refining my skills ever since.
Now I'm doing the same by teaching AI agents to write code with the same quality I do.
But most developers fail to get AI to write high-quality, clean code. The reason is simple. The AI followed the rules it could see, and it never saw yours.
Most developers try to fix this by putting the rules in the prompt. I tried that for many months.
The rules work for a few files, maybe a few iterations in a session. But as the session goes on, the context gets loaded with information, and your rules start to be ignored.
So only two things actually work reliably that I want to show you today:
- Static compiler guardrails. If bad code doesn't compile, the agent sees the error, fixes it, and you never review that mistake at all.
- A skill for reviewing code. It's being used in another session, so it never competes with the original task.
In this post, we will explore:
- Set Project-Wide Standards with Directory.Build.props
- Add Static Code Analysis Packages
- Enforce Coding Standards with .editorconfig
- Centralize Package Management
- Code Reviews: AI First, Human Second
- Why Coding Rules in Prompts Don't Work
- Build a Clean Code Review Skill for Claude
- Make Sure the Review Catches Every Issue
Let's dive in.
1. Set Project-Wide Standards with Directory.Build.props
Every .NET solution should start with a Directory.Build.props file. This file defines project-wide settings that apply to all projects in your solution.
Without this file, you end up duplicating the same configuration across multiple .csproj files.
When you want to change a setting, you have to update every project file by hand. This leads to inconsistencies and wasted time.
Directory.Build.props solves this problem by centralizing configuration in a single file.
You create this file in the same directory as your .sln file, and MSBuild applies it to all projects in the solution.
Here is the configuration I use for every new project:
xml<Project> <PropertyGroup> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <AnalysisLevel>latest</AnalysisLevel> <AnalysisMode>All</AnalysisMode> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <CodeAnalysisTreatWarningsAsErrors>true</CodeAnalysisTreatWarningsAsErrors> <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> </PropertyGroup> </Project>
Here is what each setting does:
Nullable: Enables nullable reference types, which help prevent null reference exceptions. The compiler warns you when you might be using a null value incorrectly.
ImplicitUsings: Adds common namespace imports to every file. You don't need to write using System; or using System.Linq; anymore.
AnalysisLevel: Sets the code analysis level to the latest version. You get the most recent code quality checks from Microsoft.
AnalysisMode: Turns on all code analysis rules. This gives you the most feedback on code quality.
TreatWarningsAsErrors: Stops compilation if there are any warnings. This forces you to fix issues right away instead of letting them pile up.
CodeAnalysisTreatWarningsAsErrors: Applies the same strict treatment to code analysis warnings.
EnforceCodeStyleInBuild: Runs code style checks during build, not just in the IDE. Your CI/CD pipeline will catch style violations.
Now here is why this file matters more than ever.
An AI agent doesn't read your standards. It runs dotnet build, and it reads the output.
TreatWarningsAsErrors turns every analyzer rule into a build error. The agent detects the error, fixes it, and rebuilds. That loop runs before you ever open the diff.
Without this one line, the same rules become warnings. Warnings can get ignored by agents.
That is the pattern behind everything below. A rule the build enforces gets followed every time, and a rule that only lives in a document gets skipped as soon as the agent has busy context.
2. Add Static Code Analysis Packages
Code quality is something you need to care about from day 1. It's easier to follow the best coding practices than to fix them later.
For this, we can use static code analysis.
Static code analyzers examine your code without running it. They catch common mistakes, enforce coding standards, and find potential bugs before they reach production.
Some of the analyzers can even catch code quality issues. They can detect:
- Too long or too complex methods
- Methods with too many parameters
- Too much nesting
- Unused code
The analyzers run during compilation, so you get feedback right away in your IDE and in your CI/CD pipeline.
Add these analyzer packages to your Directory.Build.props file:
xml<Project> <PropertyGroup> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <AnalysisLevel>latest</AnalysisLevel> <AnalysisMode>All</AnalysisMode> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <CodeAnalysisTreatWarningsAsErrors>true</CodeAnalysisTreatWarningsAsErrors> <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> </PropertyGroup> <ItemGroup> <PackageReference Include="Meziantou.Analyzer" Version="2.0.257"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> <PackageReference Include="SonarAnalyzer.CSharp" Version="10.16.0.128591"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> <PackageReference Include="Roslynator.Analyzers" Version="4.14.1"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> <PackageReference Include="xunit.analyzers" Version="1.26.0"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> </ItemGroup> </Project>
Here is what each analyzer provides:
SonarAnalyzer.CSharp: Focuses on code quality, security holes, and code smells. It finds complex methods, duplicated code, and potential bugs.
Meziantou.Analyzer: Catches performance issues, security holes, and incorrect API usage. It has hundreds of rules covering async/await patterns, LINQ usage, string handling, and more.
Roslynator.Analyzers: Provides code analysis and refactoring suggestions. It helps you write cleaner, more idiomatic C# code.
xunit.analyzers: Makes sure you write tests correctly. It catches common testing mistakes, such as missing assertions or wrong test attributes.
The IncludeAssets configuration makes sure that these analyzers run during compilation but don't get included in your published application.
With TreatWarningsAsErrors enabled, your build will fail if any analyzer finds an issue. This might seem strict, but it stops technical debt from piling up.
You should always aim for zero warnings in your project. Many developers ignore warnings, but warnings often point at real problems that will cause bugs later.
These four packages carry thousands of rules between them. That's thousands of rules an AI agent has to satisfy, and none of them cost you a single line in a prompt.
For more information about code quality best practices, check out this article.
3. Enforce Coding Standards with .editorconfig
Analyzers find issues, but you also need to set which rules matter for your team and how strict they should be.
The .editorconfig file defines coding standards and sets the severity level for each analyzer rule. You can mark rules as errors, warnings, suggestions, or turn them off completely.
Place this file in the same directory as your .sln file. Every developer on your team will follow the same rules, no matter which IDE they use.
We used this file for years across different teams.
And now every AI agent can use it too and comply with your coding standards.
Here is a basic .editorconfig file to get started:
iniroot = true [*] charset = utf-8 indent_style = space indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true [*.cs] # Nullable reference types dotnet_diagnostic.CS8600.severity = error dotnet_diagnostic.CS8601.severity = error dotnet_diagnostic.CS8602.severity = error dotnet_diagnostic.CS8603.severity = error dotnet_diagnostic.CS8604.severity = error # Code style rules dotnet_style_qualification_for_field = false:warning dotnet_style_qualification_for_property = false:warning dotnet_style_qualification_for_method = false:warning dotnet_style_qualification_for_event = false:warning # Naming conventions dotnet_naming_rule.interface_should_begin_with_i.severity = error dotnet_naming_rule.interface_should_begin_with_i.symbols = interface dotnet_naming_rule.interface_should_begin_with_i.style = begins_with_i dotnet_naming_symbols.interface.applicable_kinds = interface dotnet_naming_style.begins_with_i.required_prefix = I dotnet_naming_style.begins_with_i.capitalization = pascal_case # Async methods should end with Async dotnet_naming_rule.async_methods_end_in_async.severity = error dotnet_naming_rule.async_methods_end_in_async.symbols = any_async_methods dotnet_naming_rule.async_methods_end_in_async.style = end_in_async dotnet_naming_symbols.any_async_methods.applicable_kinds = method dotnet_naming_symbols.any_async_methods.applicable_accessibilities = * dotnet_naming_symbols.any_async_methods.required_modifiers = async dotnet_naming_style.end_in_async.required_suffix = Async dotnet_naming_style.end_in_async.capitalization = pascal_case
You can download the complete
.editorconfigfile from my article.
Look at the last block. The async naming rule is set to error, so a method named GetShipment that returns a Task breaks the build.
Move every rule you can into this file. Whatever .editorconfig can express, express there, and save your review time for the things a config file can't check.
With .editorconfig, code reviews become faster because developers don't need to argue about formatting or naming conventions.
The tooling enforces these rules for you.
4. Centralize Package Management
As your solution grows, managing NuGet package versions across many projects gets harder. Different projects end up using different versions of the same package, causing compatibility issues and making updates harder.
Central Package Management (CPM) solves this by managing all package versions in one place. And because of this, your agent spends much less time and much fewer tokens working with the needed packages.
You create a Directory.Packages.props file that defines all package versions for your solution.
Place this file in the same directory as your sln or slnx file:
xml<Project> <PropertyGroup> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> </PropertyGroup> <ItemGroup> <!-- Web --> <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" /> <!-- Database --> <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.0" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0" /> <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" /> <!-- Testing --> <PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" /> <!-- Code Analysis --> <PackageVersion Include="Meziantou.Analyzer" Version="2.0.257" /> <PackageVersion Include="SonarAnalyzer.CSharp" Version="10.16.0.128591" /> <PackageVersion Include="Roslynator.Analyzers" Version="4.14.1" /> <PackageVersion Include="xunit.analyzers" Version="1.26.0" /> </ItemGroup> </Project>
You need the ManagePackageVersionsCentrally property in this file to enable CPM.
With CPM enabled, your project files reference packages without a version:
xml<ItemGroup> <PackageReference Include="Microsoft.EntityFrameworkCore" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> </ItemGroup>
With CPM enabled, a PackageReference that carries its own version results in a build error.
The agent has to add the version to Directory.Packages.props, so you can see it in one place and review it in a single line of the diff.
5. Code Reviews: AI First, Human Second
The build is the cheapest gate you have, but it is not the last one.
Manual code reviews take time, which is why you should use AI-powered code review tools to speed up the process.
I have four phases of code reviews:
- Review with Claude: locally or in GitHub
- Specialized AI review tools such as CodeRabbit
- Specialized enterprise Quality review tools such as SonarQube
- Manual review
- There is one detail worth getting right when the code was written by an agent. Run the review in a separate place from the session that wrote the code.
An agent that reviews its own work in the same chat has every reason it made those choices still sitting in front of it. It defends the code instead of reading it. A fresh reviewer will see more issues (much like we humans do).
-
Code Rabbit is an AI code review tool that reviews pull requests automatically. It finds issues, suggests improvements, and gives feedback within minutes. AI code reviews do not replace human reviews, but they cut the time spent on routine feedback. Your team can focus on architectural decisions and business logic.
-
If you want to push code quality further, I suggest using third-party software for code analysis, for example:
These tools sit above the analyzers. They track quality across the whole repository over time.
That view matters when an AI writes a large share of your code. A single pull request can look fine while duplication climbs, complexity creeps up, and test coverage drops week after week.
SonarQube and Qodana both surface that trend and plug into your pipeline, so the numbers show up on the pull request.
CodeRabbit has a free tier (it is also free for OSS projects). CodeRabbit, SonarQube or Qodana is not for personal use; they are for companies.
For production, your company can pay for CodeRabbit and SonarQube/Qodana.
If it's out of the budget, make sure to review the code with Claude at least.
- After all automated reviews - here comes a review by a human.
6. Why Coding Rules in Prompts Don't Work
At this point, the machine catches a lot. It still can't catch everything.
No analyzer will tell you that ShipmentHelper is a bad class name.
No pipeline will flag method signature Send(order, true) as a bad practice.
Those are issues only we humans can catch. But can AI help here? Yes, of course.
The obvious fix is to write your rules into the prompt.
Here's what that looks like in practice:
markdownAdd an endpoint to cancel a shipment. Use Vertical Slice Architecture. Don't create Manager or Helper classes. Don't use #region. Don't pass boolean flags. Return IReadOnlyList instead of IEnumerable. Suffix async methods with Async and take a CancellationToken. Don't extract an interface for a single implementation. Prefer composition over inheritance.
I have written prompts like this. They work, and then they stop working.
The reason is context. Your rules go in at message one. Then the agent reads X files, runs the build twice, reads a failed test and writes Y more files. By the time it names a class, your rules are thousands of tokens back, competing with everything it has read since.
The rules are still in the context window. They are just one small part of a much larger pile of text now, and the agent weighs them accordingly.
There's a second option - using the CLAUDE.md file. And it's the one that hurts on a real team. Those eight lines ride along with every request, on every task, including the ones where nobody names a class. You pay for them constantly, and they help you rarely.
It's added to every session, so it has to stay short, around 100 lines. It contains the facts about your project and overall architecture, not the step-by-step recipe for every task you do.
There is a better way: skills.
A skill packages a set of instructions, and even example files, that Claude loads only when needed. You write the recipe once, and Claude follows it every time, in every session.
The split is worth remembering:
CLAUDE.mdis always-on context. It holds the facts that are true for the whole project - your tech stack, your conventions, your folder layout.- A skill is an on-demand capability. It holds the procedure for a specific kind of task and is loaded only when that task comes up.
A review is exactly the kind of task that fits a skill. It happens at a known moment, follows the same steps every time, and requires a long list of rules that would be dead weight in every other conversation.
I wrote about the format in detail in Creating Claude Skills for .NET Apps. Now let's build one.
7. Build a Clean Code Review Skill for Claude
A skill is a folder. The only required file is SKILL.md.
Here is the one I use for clean code reviews:
.claude/skills/dotnet-clean-code-review/ ├── SKILL.md └── references/ ├── clean-code-rules.md └── detection-commands.md
It lives in .claude/skills/ inside the repository, so it's committed to git. When a teammate pulls the latest code, they also get the skill. Everyone's review runs the same checks.
The top of SKILL.md is the frontmatter, and it does one job: it decides when the skill loads.
markdown--- name: dotnet-clean-code-review description: Review C# and .NET code for clean code problems - Manager/Helper/Utils class names, #region blocks, boolean flag parameters, weak return types, async naming and CancellationToken, single-implementation interfaces, and deep inheritance. Use when the user asks to "review my changes", "do a clean code review", "check this class", "is this code clean", or wants naming and readability feedback on C# code. Do NOT use for correctness bugs or security issues - that is /code-review. Do NOT use when writing new code - the feature skills cover that. ---
Three things make this description work. It names the concrete checks so that Claude can match a request against them. It lists the phrases you would actually type. And it says when not to use it.
Claude always sees the name and description, which is about one line of text. It loads the rest of the file only when your request matches.
Then come the rules. Here are the seven I check on every review.
1. Never write async void, suffix async methods with Async, and accept a CancellationToken.
An async void method can't be awaited, and any exception inside it terminates the process rather than reaching the caller.
A missing suffix hides from the reader that the call is awaitable. A method without a token keeps working after the client has disconnected.
csharp// Before public async void ProcessShipment(Guid shipmentId) { } // After public async Task ProcessShipmentAsync(Guid shipmentId, CancellationToken cancellationToken) { }
2. Return the most specific useful type.
IEnumerable<T> on a result that's already in memory invites the caller to enumerate it twice, and the second pass can re-run the whole query.
Return IReadOnlyList<T> when the data is materialized.
csharp// Before public IEnumerable<Shipment> GetShipments() => shipments.Where(x => x.IsActive); // After public IReadOnlyList<Shipment> GetShipments() => shipments.Where(x => x.IsActive).ToList();
3. Never pass boolean flags as parameters.
Send(order, true) tells the reader nothing at the call site. You have to open the method to learn what true means, and a flag usually means the method does two different jobs.
csharp// Before await SendAsync(order, true); // After await SendDraftAsync(order);
4. Avoid Manager, Helper, and Utils class names.
These names convey no responsibility, so nothing prevents the next developer from adding another unrelated method. The class grows forever, and nobody can say what it's for. Name the class after the one job it does, or move each method next to the type it works on as an extension method.
5. Delete #region blocks.
A region hides code instead of removing it. A class that needs regions to remain readable should be split.
6. Don't extract an interface for a single implementation just to satisfy DI.
.NET registers and injects a concrete class perfectly well. A one-implementation interface costs an extra file and an extra jump on every "go to definition", and it buys nothing until a second implementation exists.
csharp// Before builder.Services.AddScoped<IShipmentNumberGenerator, ShipmentNumberGenerator>(); // After builder.Services.AddScoped<ShipmentNumberGenerator>();
Keep the interface when a second implementation exists, when a test needs a stub, or when it's a cross-module contract.
7. Prefer composition over inheritance.
In modern software development, developers prefer composition over inheritance. When using inheritance, you are tied strictly to the exact order of classes, and when you need to change one class in a chain, you would need to change all others.
Composition, on the other hand, is much more flexible. It's often implemented as the Decorator pattern when one class accepts the same class under an interface. This way, you can compose behaviors dynamically and easily change the order of your compositions or decorators, often without affecting any other classes.
This skill contains the references/ folder with all the required information.
clean-code-rules.md carries the twelve clean code rules from: naming, nesting, early returns, magic numbers, and the rest - each with a before and after example.
Claude opens that file only during the review.
9. Make Sure the Review Catches Every Issue
Writing the rules down is the easy part. Making sure all of them are checked is what determines whether the skill is worth anything.
Ask any model to "review this for clean code" against a 500-line diff, and it does it poorly.
Three things in the skill prevent that.
First, a fixed order of steps. The skill shows the process, so the review can't start with whatever catches the eye:
markdown### Step 1. Decide the scope ### Step 2. Run the detection ### Step 3. Judgement pass ### Step 4. Write the report
Second, a grep command for every rule that can have one. Five of the seven rules have a pattern you can find mechanically, so the skill runs the search instead of relying on memory:
bashrg -n --glob "*.cs" "\basync\s+void\b" rg -n --glob "*.cs" "^\s*#region" rg -n --glob "*.cs" -e "\b(class|record|struct|interface)\s+[A-Za-z0-9_]*(Manager|Helper|Helpers|Utils|Utility|Utilities)\b"
Third, a report shape that makes a skipped rule visible. Every rule gets a row, whether it found anything or not:
| Rule | Result |
|---|---|
| 1. async void / Async suffix / CancellationToken | 1 finding |
| 2. Most specific return type | PASS |
| 3. Boolean flag parameters | PASS |
| 4. Manager / Helper / Utils names | 2 findings |
| 5. #region blocks | PASS |
| 6. Single-implementation interfaces | PASS |
| 7. Composition over inheritance | PASS |
| 8. Readability (naming, nesting, magic values) | 3 findings |
To use it, open a new session and say:
markdownReview my changes for clean code
Or call it by name with /dotnet-clean-code-review.
Use a new session on purpose. The session that wrote the code is the worst reviewer of it, because every choice still has its reasoning attached. A fresh session sees only the code, without biases.
For code reviews, I prefer to use the Claude Fable model. If you can't afford it, use Opus.
P.S.: This works the same as other AI Agents such as Codex, GitHub Copilot or Cursor.
Summary
Let's recap the key takeaways:
- Guardrails beat instructions.
Directory.Build.propswithTreatWarningsAsErrorsturns your standards into build errors. The agent fixes them in its own loop, before you open the diff. - Analyzers do the work of a thousand prompt lines. Meziantou, SonarAnalyzer, Roslynator, and xunit.analyzers cover async mistakes, LINQ traps, and test errors without costing you a single token.
- Move every rule you can into
.editorconfig. Whatever a config file can express, express there, and save your review time for what it can't check. - Central Package Management stops invented versions. An agent writes package versions from memory. CPM forces every version into a single file you can review on a single line.
- Reviews are the second gate. CodeRabbit clears the routine feedback so your team spends its time on architecture and business logic, and SonarQube or Qodana track the trend across the whole repository.
- Rules in prompts fade, rules in skills don't. Put your clean code rules in a skill so they load at review time and cost nothing the rest of the time.
- Make the review measurable. A fixed order of steps, a grep per rule, and a report row for every rule turn this into something you can trust.
None of this makes the AI a better engineer. It makes your standards enforceable, and setting those standards was always your job.
Start today with Directory.Build.props and TreatWarningsAsErrors. It's one file, it takes five minutes, and it changes what the agent hands you.
P.S.: You can also apply the TreatWarningsAsErrors rule per project, or you can override this rule in some projects to make sure that some of your legacy class libraries don't fire hundreds or thousands of errors at once.
I've attached a dotnet-clean-code-review skill to this post, that you can use to review your code for quality.
Hope you find this newsletter useful. See you next time.


Comments