Skip to content
Back to skills

Dotnet Best Practices

ASecurity

General modern C#/.NET language discipline — nullable reference types, type modeling, async/LINQ/disposal correctness, error-handling shape, immutability. Not Umbraco-specific. Use when writing or reviewing any C# code, alongside `umbraco-extensibility` (which covers Umbraco's own extension mechanisms, not language discipline) and `ef-core-data` (persistence specifics). Complements `typescript-best-practices` on the frontend side.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 15, 2026
developmenttypescriptrustc#sqlexpressapidatabasefrontendsecurity

Works with

  • api

Security analysis

A100/100

Scanned October 15, 2026

npx -y skills add mattbrailsford/umbraco-devkit --skill dotnet-best-practices --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Dotnet Best Practices?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Dotnet Best Practices
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/mattbrailsford-dotnet-best-practices/badge)](https://www.skillsdirectory.com/skills/mattbrailsford-dotnet-best-practices)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: dotnet-best-practices
description: >-
  General modern C#/.NET language discipline — nullable reference types, type modeling,
  async/LINQ/disposal correctness, error-handling shape, immutability. Not Umbraco-specific.
  Use when writing or reviewing any C# code, alongside `umbraco-extensibility` (which covers
  Umbraco's own extension mechanisms, not language discipline) and `ef-core-data` (persistence
  specifics). Complements `typescript-best-practices` on the frontend side.
---

# C# language discipline

## 🎯 Why: Design for Change

These rules keep the compiler and type system catching the changes you are about to break.

## Nullable reference types — enabled from day one

`<Nullable>enable</Nullable>` in every project, not just new ones. Annotate `?` only on types
that can really be `null`, and always on those. A nullable warning is a real defect, not noise
to suppress:

```csharp
// Weak — every caller has to guess (or check the source) whether this can return null
public Item GetItem(Guid id) { /* ... */ }

// Strong — the signature tells the caller what to handle, the compiler enforces it
public Item? GetItem(Guid id) { /* ... */ }
public Item GetRequiredItem(Guid id) { /* throws if not found — name says so */ }
```

Question the null-forgiving operator (`!`) in production code during review. It is fine at a
trusted boundary (a value null-checked just above, a framework guarantee the compiler can't
see). It is not a way to silence a warning you haven't resolved.

## Type modeling: make invalid states unrepresentable

Prefer a type that can't express the wrong thing over a type that can plus a runtime check.
A sealed hierarchy plus pattern matching is C#'s version of a TypeScript discriminated union:

```csharp
// Weak — every consumer has to remember to check Status before trusting the payload
public class FetchResult
{
    public string Status { get; init; } // "idle" | "loading" | "success" | "error"
    public Item? Data { get; init; }
    public string? Error { get; init; }
}

// Strong — the compiler enforces exhaustive handling via the switch expression below
public abstract record FetchResult
{
    public sealed record Idle : FetchResult;
    public sealed record Loading : FetchResult;
    public sealed record Success(Item Data) : FetchResult;
    public sealed record Failed(string Error) : FetchResult;
}

string Render(FetchResult result) => result switch
{
    FetchResult.Success s => s.Data.Name,
    FetchResult.Failed f => f.Error,
    FetchResult.Idle or FetchResult.Loading => "…",
    _ => throw new UnreachableException() // exhaustive — a new case is a compile warning here
};
```

Use this for mutually exclusive shapes (operation state, workflow step, validation outcome)
instead of one class with many nullable properties and a status string.

## Error handling: exceptions for the exceptional, a result shape for the routine

A failed validation or a lookup that finds nothing is a routine, expected outcome. Returning
it as a result is not swallowing an error; it forces the caller to handle it:

```csharp
public async Task<Result<Item>> SaveAsync(Item item)
{
    var validation = _validator.Validate(item);
    if (!validation.IsValid) return Result<Item>.Failure(validation.Errors);

    var saved = await _repository.SaveAsync(item);
    return Result<Item>.Success(saved);
}

// Usage — the caller can't accidentally skip the failure path
var result = await SaveAsync(item);
if (!result.Succeeded) { /* handle it */ }
```

Once a service has more than a couple of call sites like this, use one small
`Result<T>`/`Attempt<T>`-style type rather than a per-method shape. Umbraco's services use
`Attempt<T>`/`Attempt<T, TStatus>` for the same purpose, so matching it keeps you consistent
with the host instead of adding a second convention.

Reserve `throw`/`try`/`catch` for truly exceptional, programmer-error conditions (a violated
precondition, a config value that can't be missing), not for expected failures a caller should
branch on.

## Async: beyond naming

(Async *naming* — `[Action][Entity]Async` — is `umbraco-package-conventions`'s territory; this
is about async *behavior*.)

- **Never `async void`**, except a top-level UI event handler with no other option. Its
  unhandled exceptions crash the process instead of faulting an observable `Task`.
- **Flow a `CancellationToken`** through any async chain that reaches I/O (DB, HTTP, file).
  Accept it as the last parameter and pass it all the way down; don't drop it at the top.
- **Never `.Result` or `.Wait()` on a `Task`** in application code. Sync-over-async causes
  deadlocks and thread-pool starvation. If a boundary you don't control forces it, flag it
  rather than working around it silently.
- **`ConfigureAwait(false)`**: ASP.NET Core (what Umbraco runs on) has no
  `SynchronizationContext`, so don't add it by habit in application/controller/service code.
  Do add it in reusable library code that might run under another context (a desktop app, a
  different host).

## LINQ: know when it runs

LINQ `IEnumerable<T>` is usually **deferred**: each enumeration re-runs the query (re-hits the
database, re-iterates the source). Materialize once with `.ToList()`/`.ToArray()` before
enumerating more than once, and only *after* you finish composing the query. Materializing an
`IQueryable<T>` too early runs the rest of the query in memory instead of SQL (see
`ef-core-data` for the EF Core side). Prefer `.Any()` over `.Count() > 0`; it stops at the
first match.

## Immutability by default

Prefer `record`/`record struct` and `init`-only properties for data rather than
identity-with-behavior (DTOs, value objects, options/parameter objects, the `Result<T>` shape
above). An immutable instance can't be changed by one caller under another.

## Disposal: `using` declarations, not manual `Dispose()` calls

```csharp
// Prefer — disposed automatically at the end of the enclosing scope
using var connection = new SqlConnection(connectionString);

// For IAsyncDisposable
await using var stream = await OpenStreamAsync();
```

Don't hand-roll `try`/`finally` around `Dispose()` when a `using`/`await using` declaration
gives the same guarantee with no path to skip it.

## Collections at a boundary

Accept the most general useful parameter type (`IEnumerable<T>` if you iterate once,
`IReadOnlyList<T>` if you need count/indexing), not a concrete `List<T>`, so callers needn't
build a list just to call you. Don't *return* a mutable `List<T>`/array from a public member
when callers have no reason to mutate it; return `IReadOnlyList<T>` or
`IReadOnlyCollection<T>` so mutation is a compile error, not a runtime surprise for other
holders of the reference.

## Scope note

This skill covers general C#/.NET language discipline: types, nulls, async, LINQ, error shapes,
disposal, immutability. Not covered here:

- Umbraco extension mechanisms (Composers, collection builders, notification handlers):
  `umbraco-extensibility`.
- Async *naming*, visibility (internal by default), public-API compatibility:
  `umbraco-package-conventions`.
- EF Core/persistence specifics: `ef-core-data`.
- Security-sensitive patterns: `security-dotnet`.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.