Mert Özen Further With Every Line
Software Design Coding Conventions and Team Standards — #02

Encoding C# Conventions in .editorconfig: Code-Style Options, Naming Rules and Enforcing on Build

Mert Özen Nov 14, 2026 12 min 4 views
Encoding C# Conventions in .editorconfig: Code-Style Options, Naming Rules and Enforcing on Build

A naming rule's own severity line is not read on build; to enforce the rules in dotnet build you need dotnet_diagnostic.IDE1006.severity alongside EnforceCodeStyleInBuild. The post covers the .editorconfig hierarchy, the IDE rule IDs behind code-style options, the symbols-style-severity triple of naming rules, a test folder override, how category-Style enforces defaults you never chose, and rules that run only in the IDE, all with real build output.

Version note: The code in this post was built and run with C# 14 and .NET 10 (SDK 10.0.402).

Guess First: The Rule Is There, the Warning Is Not

The Bookstore team writes the naming decisions from the first post into .editorconfig. The first rule is simple: interfaces start with I. The rule is in the file, its severity is warning, and the property that carries code-style rules into the build is switched on in Directory.Build.props at the repo root:

root = true

[*.cs]
indent_style = space
indent_size = 4

# Naming: I prefix for interfaces
dotnet_naming_rule.interfaces_begin_with_i.symbols = interfaces
dotnet_naming_rule.interfaces_begin_with_i.style = begins_with_i
dotnet_naming_rule.interfaces_begin_with_i.severity = warning
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_symbols.interfaces.applicable_accessibilities = *
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.capitalization = pascal_case
<Project>
  <PropertyGroup>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
  </PropertyGroup>
</Project>

The interface written for stock checks in the ordering module, however, is called StockChecker, so it clearly violates the rule:

namespace Bookstore.Ordering;

public interface StockChecker
{
    Task<bool> IsInStockAsync(string isbn, int quantity);
}

According to the naming rules docs, the IDE flags this violation with the severity written in the rule. But how many warnings does dotnet build give on the command line? One or zero? Before you guess, think about what each line of the rule tells the build.

How .editorconfig Is Read: Hierarchy and Globs

EditorConfig is an editor-independent format; its spec is published at editorconfig.org, and the .NET compiler (Roslyn) also reads it as analyzer configuration. When a .cs file is compiled, the tool starts in the file's folder and collects every .editorconfig on the way up. It stops at a file containing root = true. If the same property appears in more than one file, the one closest to the source file wins; if two sections in the same file set the same property, the later one wins.

In practice: you put a single .editorconfig with root = true at the repo root, so a personal file in a developer's home folder cannot affect the repo. If you want different rules for a specific folder, you put a small file without the root line in that folder; you write only the lines that differ and inherit the rest from the root.

Section headers are globs. [*.cs] matches C# files in every folder, [*.{cs,csx}] matches two extensions at once, and [**/Generated/*.cs] matches a Generated folder at any depth. If the header contains a /, the pattern is resolved relative to the folder of the .editorconfig itself; otherwise it is matched in every folder. Bookstore's layout looks like this; the root .editorconfig carries all rules with root = true, the one under tests/ only the test-specific differences:

Bookstore/
├── .editorconfig
├── Directory.Build.props
├── Bookstore.slnx
├── src/
│   └── Bookstore.Ordering/
└── tests/
    ├── .editorconfig
    └── Bookstore.Ordering.Tests/

Code-Style Options and IDE Rule IDs

A code-style option says two things: a preference and (optionally) a severity. In csharp_style_namespace_declarations = file_scoped:warning the preference is file_scoped and the severity is warning. Behind every option there are one or more IDE rules, and that ID is what you see in build output, in the dotnet format report and when suppressing. The main mappings Bookstore uses:

OptionBookstore preferenceRule ID
csharp_style_var_for_built_in_types, _when_type_is_apparent, _elsewherefalse (explicit types)IDE0008 (wants explicit type), IDE0007 (wants var)
csharp_style_namespace_declarationsfile_scopedIDE0161 (wants file-scoped), IDE0160 (wants block)
csharp_using_directive_placementoutside_namespaceIDE0065
csharp_style_expression_bodied_methodswhen_on_single_lineIDE0022
csharp_style_expression_bodied_propertiestrueIDE0025
dotnet_style_prefer_collection_expressionwhen_types_loosely_matchIDE0028, IDE0300–IDE0305
csharp_style_prefer_primary_constructorstrue (suggestion)IDE0290

Knowing this table helps in two ways. First, when the build raises a warning you can go from the ID to the option; each IDE rule's page on Microsoft Learn names the option that controls it. Second, as we will see below, some tools read only the severity attached to the ID, not the one at the end of the option line.

We wrote these options with a :warning suffix for a small Bookstore StockService and built it; the file has a block-scoped namespace, a classic constructor, new List<string>() and a one-line block-bodied method:

[*.cs]
csharp_style_namespace_declarations = file_scoped:warning
csharp_style_prefer_primary_constructors = true:warning
dotnet_style_prefer_collection_expression = when_types_loosely_match:warning
csharp_style_expression_bodied_methods = when_on_single_line:warning
$ dotnet build --no-incremental
StockService.cs(1,11): warning IDE0161: Convert to file-scoped namespace
StockService.cs(7,16): warning IDE0290: Use primary constructor
StockService.cs(14,35): warning IDE0028: Collection initialization can be simplified
StockService.cs(25,30): warning IDE0022: Use expression body for method

$ dotnet format style --verify-no-changes
StockService.cs(1,11): warning IDE0161: Convert to file-scoped namespace
StockService.cs(25,30): warning IDE0022: Use expression body for method
StockService.cs(7,16): warning IDE0290: Use primary constructor

The build saw all four rules. The code analysis page on Microsoft Learn says that the option = value:severity format has been respected on build since .NET 9, and the output confirms it. But dotnet format did not see IDE0028. We measured the same gap in the "Refactoring Tools and Code Style: Quick Actions, Alt+Enter, .editorconfig and Code Cleanup" post of the IDEs series; it is still there in SDK 10.0.402. The lesson is the same: write every rule you enforce in CI with a separate dotnet_diagnostic.IDExxxx.severity line as well, so both tools see the same thing.

Anatomy of a Naming Rule: Symbols, Style, Severity

A naming rule consists of three separate definitions bound to each other by name. Symbols describe a group of symbols: which kind (field, method, interface…), which accessibility, which modifiers. Style describes the shape of the name: prefix, suffix, capitalization. The rule combines the two and gives a severity. Line order does not matter; the naming rules page on Microsoft Learn says property order is irrelevant. Bookstore's four rules:

[*.cs]
# Severity: one setting for all naming rules on build
dotnet_diagnostic.IDE1006.severity = warning

# Rule 1: private fields are _camelCase
dotnet_naming_rule.private_fields_underscore.symbols = private_fields
dotnet_naming_rule.private_fields_underscore.style = underscore_camel
dotnet_naming_rule.private_fields_underscore.severity = warning

# Rule 2: constants are PascalCase
dotnet_naming_rule.constants_pascal.symbols = constants
dotnet_naming_rule.constants_pascal.style = pascal
dotnet_naming_rule.constants_pascal.severity = warning

# Rule 3: async methods end with Async
dotnet_naming_rule.async_methods_suffix.symbols = async_methods
dotnet_naming_rule.async_methods_suffix.style = ends_with_async
dotnet_naming_rule.async_methods_suffix.severity = warning

# Rule 4: interfaces start with I
dotnet_naming_rule.interfaces_begin_with_i.symbols = interfaces
dotnet_naming_rule.interfaces_begin_with_i.style = begins_with_i
dotnet_naming_rule.interfaces_begin_with_i.severity = warning

# Symbols
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_symbols.constants.applicable_kinds = field
dotnet_naming_symbols.constants.applicable_accessibilities = *
dotnet_naming_symbols.constants.required_modifiers = const
dotnet_naming_symbols.async_methods.applicable_kinds = method
dotnet_naming_symbols.async_methods.applicable_accessibilities = *
dotnet_naming_symbols.async_methods.required_modifiers = async
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_symbols.interfaces.applicable_accessibilities = *

# Styles
dotnet_naming_style.underscore_camel.required_prefix = _
dotnet_naming_style.underscore_camel.capitalization = camel_case
dotnet_naming_style.pascal.capitalization = pascal_case
dotnet_naming_style.ends_with_async.required_suffix = Async
dotnet_naming_style.ends_with_async.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.capitalization = pascal_case

To test it we wrote an OrderService that breaks each rule: a private const named bulkDiscountThreshold, a private static field named defaultCurrency, a private field named items, a PlaceOrder method that returns Task<bool> without an Async suffix, and the StockChecker interface. The build:

$ dotnet build Bookstore.slnx --no-incremental
src/Bookstore.Ordering/OrderService.cs(14,38): warning IDE1006: Naming rule violation: Missing prefix: '_'
src/Bookstore.Ordering/OrderService.cs(13,36): warning IDE1006: Naming rule violation: Missing prefix: '_'
src/Bookstore.Ordering/OrderService.cs(12,23): warning IDE1006: Naming rule violation: These words must begin with upper case characters: bulkDiscountThreshold
src/Bookstore.Ordering/OrderService.cs(18,29): warning IDE1006: Naming rule violation: Missing suffix: 'Async'
src/Bookstore.Ordering/OrderService.cs(3,18): warning IDE1006: Naming rule violation: Missing prefix: 'I'
tests/Bookstore.Ordering.Tests/OrderServiceTests.cs(10,23): warning IDE1006: Naming rule violation: Missing suffix: 'Async'

First, the answer to the guess: with the opening file, the build gave zero warnings. A naming rule's own severity line is read only by the IDE; the compiler does not understand it. The naming rules page on Microsoft Learn says so in a footnote and shows the dotnet_diagnostic.IDE1006.severity line for enforcing on build. All naming violations are reported under a single ID, IDE1006. We tested this separately too: we set the private field rule's severity to suggestion and the const rule's to error, and none of the six build warnings changed. On build, the level is always IDE1006's level. The one exception is none: it switches the rule off completely, and we use that in the test folder below.

Rule Order and Conflicts

Look at the bulkDiscountThreshold line. This symbol fits two groups at once: a private field (Rule 1 wants the _ prefix) and a const (Rule 2 wants PascalCase). The build did not say "Missing prefix: '_'"; it said "These words must begin with upper case characters", so Rule 2 won. The reason is not the order in the file. According to the naming rules page, rules are ordered not by their position in the file but by how specific they are: the rule with the narrower accessibility, modifiers and symbol kind comes first. The const group's required_modifiers = const line makes it narrower than the private field group. To confirm, we swapped the two rule blocks and built again; the same line got the same message.

Before Visual Studio 2019, the first matching rule was applied and rules had to be ordered from most to least specific; that is why many old examples on the internet are careful about order. Today order does not matter, but conflicts are still possible. For example, "public methods are PascalCase" and "async methods end with Async" intersect on a public async method. The docs recommend writing a separate, more specific rule for the intersection. The docs also point out that a group with required_modifiers = static covers constants too (constants are implicitly static); if you add an s_ prefix rule for private static fields as the runtime does, you should have the const rule spelled out. In our test the const rule won with both accessibility definitions, but since you cannot retest the outcome on every SDK update, closing the intersection with an explicit rule is safer.

Per-Folder Overrides: Test Projects

Test methods have their own conventions. When a test returns async Task, most teams do not put Async at the end of the test name; the test runner reports the method by name and the suffix is only noise. The last line of the output above is exactly that: "Missing suffix: 'Async'" for CalculateTotal_applies_bulk_discount_at_five_books. The fix is a small .editorconfig in the tests/ folder:

[*.cs]
# No Async suffix required on test methods
dotnet_naming_rule.async_methods_suffix.severity = none

This file has no root = true, so it inherits everything from the root and switches off just one rule. On build, the warning in the test file disappeared and the five warnings under src/ stayed. The same technique lets you apply different rules to, say, a benchmark project or a migrations folder.

The underscores in the test name produced no warning, because Bookstore has no general PascalCase rule for methods; .NET's default naming styles stay at IDE suggestion level. If you add a rule for method names too, you will need to switch that one off in the test folder as well.

Enforcing on Build and Rolling It Out Across the Solution

Code-style rules are off by default in the command-line build; Visual Studio shows them as suggestions, but dotnet build does not run them. The way to turn them on is the MSBuild (Microsoft Build Engine) property EnforceCodeStyleInBuild. Instead of writing it into every .csproj, you put it in Directory.Build.props at the repo root; MSBuild searches upward from the project folder for this file and includes the first one it finds in every project. When a new project is added, the rule comes with it automatically.

One way to set rule levels in bulk is the category severity: dotnet_analyzer_diagnostic.category-Style.severity = warning makes the whole Style category a warning. It sounds handy; we added it to the Bookstore solution and built:

$ dotnet build Bookstore.slnx --no-incremental
   5 warning IDE1006: Naming rule violation
   2 warning IDE0160: Convert to block scoped namespace
   1 warning IDE0058: Expression value is never used
   1 warning IDE0022: Use block body for method
   1 warning IDE0008: Use explicit type instead of 'var'

(We removed duplicate lines and counted the warnings by ID.) The second line is instructive: the build asks us to convert our file-scoped namespaces to block-scoped ones. We had not set the namespace option in this trial file, and the category setting enforced the option's default, block_scoped. Same with IDE0022: the default is block body. A category severity means "also turn every default you never chose into a rule". If you use it, first set every option in the category deliberately and switch off the rules you do not want one by one with dotnet_diagnostic.IDExxxx.severity = none. Bookstore chose to go rule by rule.

Keeping Generated Code Out

Analyzers skip generated code by default: if the file name ends with .g.cs or .designer.cs, or if the file starts with an <auto-generated> comment. But not every code generator adds that marker. In Bookstore, a client for a payment service was generated by a tool and placed in a Generated folder without the marker; the build raised naming warnings for it too:

src/Bookstore.Ordering/Generated/PaymentApiClient.cs(5,20): warning IDE1006: Naming rule violation: Missing prefix: '_'
src/Bookstore.Ordering/Generated/PaymentApiClient.cs(7,31): warning IDE1006: Naming rule violation: Missing suffix: 'Async'

There is no point fixing this file; the tool rewrites it on the next generation. We added a section to the root .editorconfig:

# Generated code: keep analyzers away
[**/Generated/*.cs]
generated_code = true

On the next build no warning came from the Generated folder; the remaining five belong to OrderService.

Common Pitfalls

Rules that run only in the IDE. Some code-style rules never run on build, for performance reasons, whatever EnforceCodeStyleInBuild and the severity say. The EnforceOnBuildValues.cs file in the Roslyn source marks them "Never": IDE0001 and IDE0002 (simplify names), IDE0003 and IDE0009 (remove/add this.), IDE0049 (int instead of Int32), IDE0035 (unreachable code) and IDE0079 (unnecessary suppression) are some of them. That is why in the first post the this. lines were caught by dotnet format, not by the build. If these rules matter to you, CI must also run dotnet format --verify-no-changes. IDE0005 (unnecessary using) is a separate case: on build it runs only with GenerateDocumentationFile on, as we saw in the IDEs series.

Expecting dotnet format to fix naming violations. We tried:

$ dotnet format style Bookstore.slnx --diagnostics IDE1006 --severity warn
Unable to fix IDE1006. Code fix NamingStyleCodeFixProvider doesn't support Fix All in Solution.

A rename is a refactoring; since it can affect call sites, reflection and serialized names, it is not done in bulk. Naming violations are fixed file by file with the IDE's rename.

Confusing .editorconfig with the global AnalyzerConfig. .NET also recognizes a .globalconfig file: a configuration that starts with is_global = true, has no path-based sections and applies to every file in the project. It is a good fit for distributing rules company-wide through a shared NuGet package. Two mistakes are common: writing a [*.cs] header into .globalconfig, and assuming the globalconfig wins when the two files conflict. The compiler reports the first:

CSC : warning InvalidGlobalSectionName: Global analyzer config section name '*.cs' is invalid as it is not an absolute path. Section will be ignored. Section was declared in file: '.globalconfig'

For the second, we wrote dotnet_diagnostic.IDE1006.severity = error and dotnet_diagnostic.IDE0008.severity = error into .globalconfig. Since .editorconfig never sets IDE0008, the var in the test file became an error; IDE1006 stayed a warning because it is warning in .editorconfig. In a conflict, .editorconfig wins.

Turning on TreatWarningsAsErrors unprepared. After the test folder override and the generated_code setting, five naming warnings were left in the solution; when we built the same solution with -p:TreatWarningsAsErrors=true, all five became errors and the build broke (Build FAILED, 5 Error(s)). This property turns not only style warnings but everything the compiler warns about, nullable warnings included, into errors; in an old codebase that means hundreds of errors. First bring warnings to zero, or raise individual rule severities to error one at a time, and flip the global switch last. The strategy for that transition is in the fifth post.

A Small Experiment

★☆☆ Build your own project before and after turning on EnforceCodeStyleInBuild. If the warning count does not change, your .editorconfig has no code-style rule with warning or error severity; check which options end with :warning.

★★☆ Add a rule with an s_ prefix for private static fields (required_modifiers = static) and check in the build output which rule your private constants fall under. Which rule would you have to make more specific to change the outcome?

★★★ Temporarily add the dotnet_analyzer_diagnostic.category-Style.severity = warning line, count the output by rule ID and decide for each ID: set its option or turn it off with none. Finally remove the category line and leave an .editorconfig that holds only deliberate decisions.

We wrote the style and naming rules into .editorconfig and tied them to the build. Indentation, spacing and line breaks are still open, though. In the next post we look at choosing a formatter: dotnet format or CSharpier, and why leaving the same rule in the hands of two tools starts a "format war".