C# Coding Conventions: Adapting a Standard to Your Team

When a team decides it needs a coding convention, the usual move is to borrow one from a big company, such as Microsoft’s C# conventions, the Airbnb JavaScript guide or one of Google’s style guides. That’s a sensible starting point. But each of those documents was written for one company’s team size, codebase and tools. Some of their rules will cost your team more than they’re worth, and they may be missing a rule your team needs. In this post I’ll go through what should shape your coding convention. Then I’ll take a sample convention based on Microsoft’s and change it one rule at a time, explaining why each change makes sense for a particular team.

Start from an existing standard

Writing a convention from scratch takes time and gives you something no new hire will recognize. For C#, two Microsoft documents are worth knowing. The C# coding conventions on Microsoft Learn are the general guidance. The coding style for dotnet/runtime is stricter and more opinionated, because it was written for one of the largest open-source repositories on GitHub. Rules like Allman braces, _camelCase private fields and the s_ prefix for static fields come from there.

For React, the React documentation covers the rules React itself depends on, like component naming and the rules of hooks. For the rest of your JavaScript and TypeScript, the Airbnb guide is the best known. It has been slow to keep up with newer ESLint versions, though, and many teams now start from the typescript-eslint recommended configs plus Prettier. Google also publishes a TypeScript style guide.

What should shape your coding convention

Your team’s experience. A team made up mostly of junior developers needs stricter rules with fewer judgment calls. “Use var when the type is obvious” sounds simple until three people disagree about what “obvious” means. A senior team can work with fewer rules and more trust. Either way, involve the team. Share a draft, discuss the rules people disagree on, and write down the reason for every rule you keep. If nobody understands why a rule exists, people will start suppressing it.

Project size and lifetime. A prototype that will be thrown away in three months doesn’t need the same rules as a product the company will maintain for ten years. In a large codebase shared by several teams, being consistent across teams matters more than anyone’s personal preference. That’s also where limits on method length, nesting and complexity start to pay off.

Tooling. If a rule can be enforced by .editorconfig, an analyzer or a formatter, let the tool enforce it. If it can’t, ask whether it’s worth having at all, because rules that only exist in code review get enforced unevenly, depending on who reviews the pull request. Choose rules your tools can check.

Performance and security. A convention can also ban things, such as blocking on async code, BinaryFormatter or SQL built by concatenating strings. These are the rules with the biggest payoff, and analyzers can enforce all of them.

Documentation and onboarding. Keep the written convention short. For anything the tools enforce, the .editorconfig file is the documentation. The written page only needs the rules a machine can’t check, each with a one-line reason. Link it from the README so a new developer finds it before opening their first pull request, not after.

Review and feedback. Code reviews shouldn’t spend time on formatting, since the formatter handles that. Watch for patterns in review comments instead. If reviewers keep asking for the same thing, it probably belongs in the convention. If developers keep suppressing a rule, it probably doesn’t. Revisit the convention a couple of times a year.

The sample convention

This is the starting point, based mostly on the dotnet/runtime style and Microsoft’s C# conventions:

  • Braces: Allman style, with each brace on its own line. A single-line statement can go without braces if it’s indented on its own line and isn’t nested inside other blocks. A nested using can start on the next line at the same indentation. The single-line form if (x) throw ...; is never allowed. If any branch of an if/else chain uses braces, they all must.
  • Indentation: four spaces, no tabs.
  • Naming: PascalCase for types, methods, constants and public fields (use public fields sparingly). _camelCase for private and internal fields. Make fields readonly wherever possible.
  • Modifiers: always state visibility, even when it’s the default, and put it first.
  • Usings: at the top of the file, outside the namespace, sorted alphabetically with System.* first.
  • Whitespace: no more than one empty line in a row, and no extra spaces inside expressions.
  • Types: use language keywords (int, string) instead of framework names (Int32, String). Use var when the type is obvious from the right-hand side or the exact type doesn’t matter.
  • Layout: fields at the top of the type. Use nameof(...) instead of writing member names as strings. Write non-ASCII characters as \uXXXX escapes.
  • Comments: only when the code can’t say it. Each comment goes on its own line, starts with a capital letter, ends with a period, and has one space after //.
  • Design: follow SOLID and prefer composition over deep inheritance. Keep state in the narrowest scope that works. Replace magic numbers with named constants. Dispose of IDisposable objects with using. Use pattern matching (is, as) for type tests and casts. Give query variables meaningful names, such as seattleCustomers, not result.

Adapting the convention for our team

None of the rules above are wrong. Below are six changes I’d make, each tied to one of the factors from earlier.

1. Braces on every block, for a junior-heavy team

The sample convention lets a single statement go without braces. That’s fine for experienced developers, but it’s how one of the best-known bugs in security history happened. In 2014, a duplicated goto fail; line under an unbraced if in Apple’s SSL code skipped certificate verification entirely. If your team is mostly junior, require braces on every block. Fewer judgment calls means fewer mistakes:

// Allowed by the sample convention
if (source == null)
    throw new ArgumentNullException(nameof(source));

// Our version: braces every time
if (source == null)
{
    throw new ArgumentNullException(nameof(source));
}

// Better still, on .NET 6 and later
ArgumentNullException.ThrowIfNull(source);

Setting csharp_prefer_braces = true:warning in .editorconfig enforces this, so nobody has to point it out in review.

2. Limits on nesting and line length, for a large codebase

Four-space indentation stays. In a large, long-lived project, though, the bigger readability problem is code that drifts to the right. We add two rules: lines of 120 characters at most, and no more than three levels of nesting inside a method. Guard clauses are usually the fix:

// Before: the real work is three levels deep
public void Ship(Order order)
{
    if (order != null)
    {
        if (order.IsPaid)
        {
            if (order.Items.Count > 0)
            {
                warehouse.Ship(order);
            }
        }
    }
}

// After: guard clauses keep the main path at the top level
public void Ship(Order order)
{
    if (order is null || !order.IsPaid || order.Items.Count == 0)
    {
        return;
    }

    warehouse.Ship(order);
}

Roslyn’s built-in rules don’t enforce either limit. SonarAnalyzer’s S134 flags deep nesting, and a formatter like CSharpier wraps long lines automatically.

3. Explicit types where var hides them, because we review in the browser

“Use var when the type is obvious” assumes you’re reading the code in an IDE, where you can hover over the variable. Most of our code review happens in pull requests on GitHub or Azure DevOps, where hovering doesn’t work. So we narrowed the rule. var is fine when the type appears on the right-hand side. Everywhere else, write the type:

// Hard to review in a pull request: what does GetPending return?
var pending = orderService.GetPending(customerId);

// Clear without an IDE
IReadOnlyList<Order> pending = orderService.GetPending(customerId);

// Still fine: the type is right there
var orders = new List<Order>();

Three .editorconfig settings express this exactly (shown in the tooling section below), and IDE0007 and IDE0008 flag violations as you type.

4. Bans on blocking calls, for a performance-critical service

The sample convention’s using rules are about tidiness, not performance. Unused using directives have no runtime cost, and their effect on compile time is too small to notice. Removing them is still worth doing. Enable IDE0005, and note that it only reports during a build if GenerateDocumentationFile is turned on. Since .NET 6, implicit and global usings have made most of the System.* list disappear anyway.

For a service where latency matters, the conventions that help are bans on code that wastes threads:

// Banned: blocks a thread pool thread while it waits
Order order = orderClient.GetAsync(id).Result;

// Required
Order order = await orderClient.GetAsync(id);

VSTHRD002 from the Microsoft.VisualStudio.Threading.Analyzers package catches synchronous waits like this one. Microsoft.CodeAnalysis.BannedApiAnalyzers lets you ban any API in a BannedSymbols.txt file, with a message telling developers what to use instead:

P:System.Threading.Tasks.Task`1.Result;Use await instead
T:System.Runtime.Serialization.Formatters.Binary.BinaryFormatter;Insecure and removed in .NET 9

5. XML documentation on public APIs, after review feedback

“Only comment when the code can’t speak for itself” is good advice inside a method. After a few months of reviews, though, it was clear that people calling our shared libraries kept asking the same questions. What happens when the address is missing? Is the cost in euros? So we changed the rule. Public members of shared libraries get XML documentation comments, and comments inside methods still explain why, not what:

/// <summary>
/// Calculates the shipping cost for an order, including any remote-area surcharge.
/// </summary>
/// <param name="order">The order to price. It must have a delivery address.</param>
/// <returns>The cost in the order's currency.</returns>
public decimal CalculateShipping(Order order)
{
    // The carrier rejects parcels over its weight limit instead of quoting them, so split them first.
    IReadOnlyList<Parcel> parcels = SplitByWeight(order, carrier.MaxWeight);
    ...
}

These comments show up in IntelliSense for everyone who calls the method. With GenerateDocumentationFile on, the compiler also warns about public members that have no documentation (CS1591). In projects where that would be too noisy, suppress the warning.

6. Dropping a rule that doesn’t fit

Adapting a convention also means removing rules. The \uXXXX escape rule makes sense in a repository edited by thousands of outside contributors, using every editor and file encoding there is. Our files are UTF-8 and our UI strings include accented customer names, so writing "Caf\u00e9" instead of "Café" only made the code harder to read. We dropped the rule and set charset = utf-8 in .editorconfig instead.

Example React and TypeScript conventions

  • Name components in PascalCase. Name hooks in camelCase starting with use, as in useFetch. The lint rules rely on that prefix to recognize hooks.
  • Put one component per file. A small private helper component can stay in the same file as the component that uses it.
  • Keep a component’s styles and tests in the same folder as the component, so deleting a feature means deleting one folder.
  • Use self-closing tags for elements without children: <Spinner />.
  • Use double quotes in JSX attributes. That’s what Airbnb’s guide and Prettier’s default (jsxSingleQuote: false) both expect, even in projects that use single quotes everywhere else.
  • Call hooks only at the top level of a component, never inside loops, conditions or nested functions. eslint-plugin-react-hooks enforces this with rules-of-hooks, and its exhaustive-deps rule catches missing effect dependencies.
  • Turn on strict mode in tsconfig.json for new projects.

Let the tools enforce it

For C#, the .editorconfig file at the root of the repository is where the convention actually lives. Visual Studio, Rider, VS Code and the build all read it. Here’s the part that covers the changes above:

root = true

[*.cs]
charset = utf-8
indent_style = space
indent_size = 4

# Braces on every block
csharp_prefer_braces = true:warning

# var only when the type is on the right-hand side
csharp_style_var_for_built_in_types = false:warning
csharp_style_var_when_type_is_apparent = true:warning
csharp_style_var_elsewhere = false:warning

# Private fields: _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
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_style.underscore_camel.capitalization = camel_case
dotnet_naming_style.underscore_camel.required_prefix = _

# Unused usings
dotnet_diagnostic.IDE0005.severity = warning

By default, code-style rules only show up in the IDE. To make the build report them, add this to a Directory.Build.props file so every project picks it up:

<PropertyGroup>
  <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
  <GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>

Then run dotnet format --verify-no-changes in CI, so a pull request that breaks the convention fails before anyone reviews it. StyleCop.Analyzers adds ordering and documentation rules that the built-in analyzers don’t cover. If you’d rather not debate formatting at all, CSharpier formats C# the way Prettier formats JavaScript. On the front end, set up ESLint with a flat config (eslint.config.js), typescript-eslint and Prettier, and run them on staged files before each commit with husky and lint-staged.

When you first apply a formatter or a new rule to an existing codebase, do it in a single commit that contains only that change. Then add the commit’s hash to a .git-blame-ignore-revs file. GitHub’s blame view and git blame --ignore-revs-file will skip that commit, so the history still shows who actually wrote each line.

Share...
 

Hamid Mosalla

Hi, I'm Hamid ("Arman"). I'm a software developer with 8+ years of experience in C#, .NET Core, Software Architecture and Web Development. I enjoy creating dev tools, contributing to open-source projects, and sharing insights on my blog. Outside of tech, I’m into indie cinema, classical music and abstract art.

 

Leave a Reply

Your email address will not be published. Required fields are marked *