8Examples / blog
C# · Static analysis

A Coding Standard That Fails the Build

Every mature codebase has rules that are obvious to the people in it every day and invisible to the compiler. You can write them in a wiki, or repeat them in code review, or you can encode one as a Roslyn analyzer and let the toolchain enforce it. Here is a small one, end to end.

By Sean Bennett · August 24, 2026 · 10 min read

The C# compiler catches syntax and type errors. Rider, ReSharper, and the built-in .NET analyzers catch a long list of general code-quality problems. Neither of them knows what your application considers correct. There is a whole category of code that compiles, passes every standard analyzer, and is still wrong for your project.

That gap is what custom analyzers are for. The one in this post is on GitHub as csharp-custom-analyzer-rules, and everything below is from a build I ran while writing it.

The convention

The rule is called SMART001, and it says: any property whose name ends with Enumeration must be typed as a Smart Enum.

The Smart Enum pattern replaces a CLR enum with a sealed class holding a stable Id and a Name. The reason to bother is that a plain enum cannot carry behaviour, cannot be extended without a switch appearing somewhere, and gives persistence nothing to bind to beyond a bare integer.

samples/SmartEnums.Sample/Models/PaymentStatus.cs
public sealed class PaymentStatus : SmartEnum<PaymentStatus>       // Id + Name from the base
{
    public static readonly PaymentStatus Pending  = new(1, nameof(Pending),  isFinal: false);
    public static readonly PaymentStatus Captured = new(2, nameof(Captured), isFinal: true);

    private PaymentStatus(int id, string name, bool isFinal) : base(id, name) => IsFinal = isFinal;

    public bool IsFinal { get; }                                    // behaviour a CLR enum can't carry
}
A C# file in Rider defining a sealed PaymentStatus class deriving from SmartEnum with four static readonly members and a private constructor
The shape the rule is steering toward: four named values, a private constructor, and an IsFinal flag that a CLR enum has nowhere to put.

So the Enumeration suffix is a promise: this property models a closed set of named values. The analyzer holds the naming convention to that promise. If a property claims to be an enumeration, its type has to actually be one.

the rule in one glance
public sealed class Order
{
    public required PaymentStatus PaymentStatusEnumeration { get; init; }   // OK
    public string  ShippingStatusEnumeration { get; init; }                 // error SMART001
    public string  Reference { get; init; }                                 // not subject to the rule
}

It is an error, not a warning

This is the decision that makes the rule worth anything. SMART001 ships with DiagnosticSeverity.Error, so a violation fails the build rather than adding a warning to the pile nobody reads.

src/SmartEnums.Analyzers/EnumerationPropertyAnalyzer.cs
private static readonly DiagnosticDescriptor Rule = new(
    id: DiagnosticId,                                     // "SMART001"
    title: "Enumeration properties must use the Smart Enum pattern",
    messageFormat: "Property '{0}' ends with '{1}' so its type must derive from {2}, but it is '{3}'",
    category: "Design",
    defaultSeverity: DiagnosticSeverity.Error,
    isEnabledByDefault: true,
    ...);

Being conservative about that matters. If everything is an error, developers start fighting the tooling, and the tooling loses. The bar I would set is: promote a rule to error only when the team can honestly say that seeing this pattern always means it should change. A convention with real exceptions belongs at warning or info, or in a document.

Here is both directions, from the repository as it stands. The sample project keeps its deliberately broken code in a Violations/ folder that is excluded from a normal build, so the repo stays green by default and you opt into the failure with an MSBuild property.

verified against the repo
$ dotnet build
Build succeeded.
    0 Warning(s)
    0 Error(s)
Time Elapsed 00:00:02.97

$ dotnet build samples/SmartEnums.Sample -p:DemoViolation=true
Violations/BadOrder.cs(10,28): error SMART001: Property 'ShippingStatusEnumeration' ends with
  'Enumeration' so its type must derive from SmartEnum<TEnum> or SmartEnum<TEnum, TId>,
  but it is 'string'
Violations/BadOrder.cs(13,16): error SMART001: ... but it is 'int'
Violations/BadOrder.cs(16,22): error SMART001: ... but it is 'System.DayOfWeek'
Violations/BadOrder.cs(19,34): error SMART001: ... but it is 'IReadOnlyList<string>'
    4 Error(s)

Four properties, four errors, one of them the DayOfWeek case that is the entire point of the rule.

Start from a sample, not a production solution

The fastest way to develop a rule is a small model containing both the cases that should trigger it and the cases that should not. You want the feedback loop measured in seconds, not in whatever your real solution takes to compile.

An Order class in the IDE with several properties annotated by XML doc comments explaining which are compliant with the rule and which are not subject to it, with a build error listed below
The sample Order. Every property is labelled with why it does or does not fall under the rule, including one commented out and waiting to be switched on.

A rule that sounds trivial in English gets complicated the moment it meets real C#. Nullable annotations, arrays, sequences, generic type parameters, records, inheritance, accessibility, auto-properties versus fields: each one is a decision about whether the rule applies.

Looking through the type

The interesting part of the implementation is not detecting the suffix. It is deciding what counts as satisfying the rule. A property typed PaymentStatus obviously passes, but so should a nullable one, an array of them, a list of them, and a generic parameter constrained to one.

the unwrapping, trimmed
switch (type)
{
    // T Foo where T : SmartEnum<T> - the constraint carries the guarantee.
    case ITypeParameterSymbol typeParameter:
        foreach (var constraint in typeParameter.ConstraintTypes)
            if (IsSmartEnum(constraint, options, remainingDepth - 1)) return true;
        return false;

    case IArrayTypeSymbol array:
        return IsSmartEnum(array.ElementType, options, remainingDepth - 1);

    case INamedTypeSymbol named:
        return DerivesFromSmartEnum(named, options)
            || IsSmartEnumOrNull(GetSequenceElementType(named), options, remainingDepth - 1);

    default:
        return false;
}

That recursion is depth-limited, because a type nested four levels deep is not worth chasing and an analyzer that runs on every keystroke should not be doing unbounded work.

One detail I like: strings are deliberately excluded from the sequence unwrapping. A string is an IEnumerable<char>, so without that guard the diagnostic on string StatusEnumeration would helpfully report that the offending type is char. It would be technically true and useless.

What the rule refuses to report

The best analyzer rules are narrow and predictable. If a developer cannot immediately see why a warning appeared, the rule becomes noise, and noisy rules get suppressed globally within a week.

So an equal amount of the implementation is about staying quiet:

  • Overrides and explicit interface implementations. They cannot choose their own type. The declaration that owns the contract gets reported instead, which is the place you can actually fix it.
  • Types that failed to bind. The compiler has already said so. A second diagnostic on the same line is just noise.
  • Indexers and compiler-generated properties.
  • Any compilation where the Smart Enum base type is not reachable at all. A project that does not reference the abstractions has no way to satisfy the rule, so nagging it would be pointless.

That last one is a nice property to steal. The analyzer resolves its configuration once at compilation start and, if the base type is not there, it never registers the symbol action. It costs nothing and it cannot fire.

Your rule is a first-class analyzer

Once wired up, the custom rule loads into the compiler exactly like Microsoft’s do, and shows up in the same place in the project’s dependency tree.

The IDE solution tree with the sample project's Analyzers node expanded, showing the custom SmartEnums.Analyzers assembly listed among the Microsoft CodeAnalysis and interop source generators, and the violating property highlighted red in the editor
SmartEnums.Analyzers sitting in the Analyzers node between Microsoft.CodeAnalysis.NetAnalyzers and the interop source generators. The offending property is red in the editor, before any build.

There is one wiring gotcha worth knowing, because it costs everyone an afternoon once. Analyzers do not flow across a plain ProjectReference. The analyzer needs its own entry with the right item type.

samples/SmartEnums.Sample/SmartEnums.Sample.csproj
<ProjectReference Include="..\..\src\SmartEnums.Abstractions\SmartEnums.Abstractions.csproj" />

<!-- Analyzers do NOT flow across a plain ProjectReference. -->
<ProjectReference Include="..\..\src\SmartEnums.Analyzers\SmartEnums.Analyzers.csproj"
                  OutputItemType="Analyzer"
                  ReferenceOutputAssembly="false" />

In a real consumer this comes along for free with the NuGet package, which packs the analyzer alongside the base classes so a single PackageReference brings both. Note also that the analyzer project targets netstandard2.0 rather than whatever your application targets. Analyzers are loaded into the compiler process, not into your app.

Keep the rules as source

The rule belongs in a real project, not in one developer’s IDE inspection settings, because everything good about source code applies to it: version control, review, tests, repeatable builds, shared ownership, release history, CI.

The analyzer project expanded in the solution tree showing the analyzer implementation, an options file, shipped and unshipped analyzer release notes, and an MSBuild props file
The analyzer project is unremarkable: the rule, an options resolver, a props file that surfaces the MSBuild knobs to the compiler, and the shipped/unshipped release notes Roslyn expects.

Both knobs on this rule are configurable, as MSBuild properties or .globalconfig keys: the suffix that triggers it and the base types it accepts. Ardalis.SmartEnum is accepted out of the box, because if you are adopting this pattern you are probably already using that package.

It also means the standard can change the way anything else changes. If the architectural convention moves, the analyzer moves with it, in a pull request, with a version number.

When is a review comment worth automating?

If a reviewer is writing the same comment for the twentieth time, that is a signal. The test I would apply is whether the comment is deterministic. If reasonable engineers can disagree given context, it is a conversation and should stay one. If the answer is always the same, it is a rule, and a rule that can be detected by syntax or semantics should be.

Code review is expensive human attention. Static analysis is cheap, repeatable machine attention. Spending the former on mechanical conventions is a bad trade, and it crowds out the architecture and behaviour discussions that actually need a person.

The failure mode is going too far. Not every preference deserves a diagnostic. Good candidates are objective, repetitive, cheap to detect, easy to explain, and rarely overridden. Static analysis should remove boring decisions, not attempt to replace engineering judgement.

Agents benefit from this too

There is a newer reason I like encoding conventions this way. An AI coding agent can read a README and still miss a convention, the same way a new hire can. But an agent that runs the build gets the rule as a hard, machine-readable signal it cannot talk its way past.

That is the same argument as giving an agent a good engineering workflow: tests, types, and analyzers are the constraints that make delegated implementation safe. As more of the mechanical work becomes agentic, the value of having your architectural expectations executable goes up rather than down.

Then put it somewhere

A freshly created empty GitHub repository page for csharp-custom-analyzer-rules showing the quick setup instructions
The transition that matters: a local inspection helps one developer, a repository can become shared infrastructure. This is that repo about ninety seconds before its first push.

From there it can be referenced by multiple solutions, packed for reuse, versioned independently, tested in CI, and improved through pull requests. A rule in a wiki decays. A rule in a package gets adopted.

The compiler knows C#. A custom analyzer teaches the toolchain what your project considers correct, and severity decides whether anyone listens.

The full rule, the sample models, the deliberately broken ones, and the semantics of what is and is not reported are in the csharp-custom-analyzer-rules repository, with the rule documented in detail at docs/SMART001.md. Clone it, run dotnet build, then run it again with -p:DemoViolation=true and watch it fail on purpose.