Playbook: dotnet-standards
C# 12 / .NET 8 and PowerShell cmdlet design rules for this repository. Referenced by
code-review, new-cmdlet and
cmdlet-scaffolder.
Two rule sets apply at once, and where they disagree the PowerShell one wins: this is a module
whose users are shell users, not a class library.
PowerShell cmdlet design
These come from Microsoft's cmdlet development guidelines. Violations are user-facing.
Naming
Verb-PnPNoun, one approved verb (Get-Verb), singular noun even when returning many objects.
PnP prefix always. New cmdlet names should not collide with other modules' nouns.
- Parameter names should match the ones used by comparable cmdlets in this module before inventing a
new one —
-Identity, -Connection, -Force, -Includes, -Batch have established meanings.
Output
- Emit objects, never formatted text.
WriteObject(x) — and WriteObject(collection, true) to
enumerate, so the pipeline sees items rather than one array.
[OutputType(typeof(T))] on every cmdlet that returns something.
- Never
Console.WriteLine. Channels: WriteObject (data), WriteWarning (recoverable),
WriteVerbose (diagnostics), WriteDebug, WriteProgress (long operations).
- Do not return
null to mean "not found" where the user asked for a specific item — write an
error. Returning nothing for a filter that matched nothing is correct.
Input
[Parameter] per parameter with deliberate Mandatory, Position, ValueFromPipeline.
Positional only for the one obvious parameter; everything else named.
- Use PipeBind types (
ListPipeBind, SitePipeBind, …) so a name, an ID or an object all bind.
ParameterSpecified(nameof(X)) to distinguish "not supplied" from "supplied as the default".
[ValidateNotNull] on any reference-typed parameter dereferenced in ExecuteCmdlet — otherwise
-Param $null is a NullReferenceException rather than a message.
[ValidateSet] / [ValidateRange] / [ValidateCount] rather than hand-rolled checks in the body.
- Prefer
SwitchParameter over bool for flags. A bool parameter forces -Flag $true and reads
wrong in PowerShell.
Safety
Anything destructive or overwriting: SupportsShouldProcess = true, then actually call
ShouldProcess. Adding a prompt where none existed breaks unattended scripts — see
api-surface-diff.
-Force may bypass ShouldContinue, never ShouldProcess. ShouldProcess is what implements
-WhatIf and -Confirm, so short-circuiting it breaks simulation:
if (Force || ShouldContinue($"Remove {Identity}?", Resources.Confirm)) // correct — the repo's pattern
if (Force || ShouldProcess($"{target}", "Remove")) // WRONG — -Force -WhatIf deletes
In the second form -Force short-circuits the ||, ShouldProcess is never called, and
-Force -WhatIf performs the operation instead of simulating it. Where both are wanted, gate on
ShouldProcess first and use Force || only on the inner ShouldContinue. This is a live defect
in src/Commands/Apps/RemoveEntraIDServicePrincipalAppRoleAssignment.cs:52 — flag it if you touch
that file, and never copy that line as a model.
Never hardcode credentials, tenant names or endpoints.
Errors
ThrowTerminatingError(new ErrorRecord(...)) for fatal errors, with a meaningful
ErrorCategory and the object that caused it. Prefer it over a bare throw — and this is not
only style, it changes what the user receives:
WriteError and ThrowTerminatingError surface under -ErrorAction Stop as a pipeline stop,
which PnPConnectedCmdlet.ProcessRecord rethrows untouched
(src/Commands/Base/PnPConnectedCmdlet.cs:57-60). Your ErrorRecord, its category and its
target object survive intact.
- A raw
throw reaches the generic catch. Under the default error action it becomes
PSInvalidOperationException with the original as inner; under -ErrorAction Stop or
SilentlyContinue it becomes new ErrorRecord(new Exception(message), source, ErrorCategory.NotSpecified, null) — type, inner exception, category and target object are all
lost, so everything the user needs must be in the message text. Under -ErrorAction Ignore
LogError is skipped, so nothing is written at all.
- Error messages belong in
Resources.resx, referenced as Resources.MessageName.
C# 12 / .NET 8
EnforceCodeStyleInBuild and EnableNETAnalyzers are on. Warnings are findings.
Style
- 4 spaces, not tabs. Braces on their own line (Allman), as the existing files do.
- PascalCase for types/methods/properties; camelCase for locals and parameters;
_camelCase for
private fields; I prefix on interfaces.
var where the type is evident from the right-hand side.
- One type per file. Enums go in
src/Commands/Enums/, models in their own files — do not group
several types in one model file.
- XML doc comments on utility classes, models and enums. Cmdlet classes do not need them; the
markdown documentation is their reference.
- File-scoped namespaces and collection expressions (
[a, b]) are fine in new code; do not churn
existing files to adopt them.
Correctness
?. and ?? over nested null checks; do not use ! to silence a nullable warning you have not
actually reasoned about.
using/await using for everything IDisposable. HttpClient is not per-call disposable — use
the connection's existing client rather than newing one up.
CultureInfo.InvariantCulture on every ToString/Parse of a date or number that crosses a wire
or a file. A format string like "yyyy-MM-ddTHH:mm:ssZ" takes its separators from the current
culture and yields 13.53.41 under some locales.
StringComparison.OrdinalIgnoreCase for identifiers, URLs and property names. Never
culture-sensitive comparison for machine-readable strings.
- Prefer LINQ for collection work, but not inside a loop that re-enumerates a remote collection.
Async
- The cmdlet pipeline is synchronous. The established pattern here is
SomethingAsync(...).GetAwaiter().GetResult(). Do not use .Result or .Wait(), and do not
introduce async void.
- Do not add
ConfigureAwait churn to existing call sites.
Cross-platform — .NET 8 on Windows, Linux and macOS
Path.Combine, Path.DirectorySeparatorChar. No backslash string surgery, no drive letters.
- Case-sensitive file systems: filename casing must match exactly.
- Do not mix
Environment.NewLine (what StringBuilder.AppendLine writes) with hardcoded \r\n in
generated files — the output then churns purely from changing OS.
- File permissions: anything written containing a private key must be owner-only on Unix.
Dependencies and the ALC
- New package references have assembly-load-context consequences. The module assembly and CSOM live
in
Core; every other dependency is private and goes to Common. Adding a reference without
placing it correctly breaks loading at runtime, not at build.
- Do not add a dependency for something the existing helpers already do.
Performance
- Request only the properties needed —
DefaultRetrievalExpressions / EnsureProperties, $select
on Graph. Do not fetch a full field collection to read one field.
ExecuteQueryRetry(), never ExecuteQuery().
- Batch where the API supports it rather than calling per item in a loop.
- Graph collections:
GraphRequestHelper.GetResultCollection follows @odata.nextLink;
Get does not and silently returns the first page only.
Build
dotnet build src/PnP.PowerShell.sln
Must be warning-clean. src/Tests is off limits — do not add or modify files there.
A clean build is where your work stops. Never commit, push, or open a PR — see
Human in the loop.
1---2name: dotnet-standards3description: C# 12 / .NET 8 and PowerShell cmdlet design rules for this repository - naming, output and error channels, parameter validation, ShouldProcess, async, culture, cross-platform and ALC constraints. Use when writing or reviewing any C# in this repo, cmdlet or otherwise.4---56# Playbook: dotnet-standards78C# 12 / .NET 8 and PowerShell cmdlet design rules for this repository. Referenced by9[`code-review`](../code-review/SKILL.md), [`new-cmdlet`](../new-cmdlet/SKILL.md) and10[`cmdlet-scaffolder`](../cmdlet-scaffolder/SKILL.md).1112Two rule sets apply at once, and where they disagree the **PowerShell** one wins: this is a module13whose users are shell users, not a class library.1415---1617## PowerShell cmdlet design1819These come from Microsoft's cmdlet development guidelines. Violations are user-facing.2021**Naming**22- `Verb-PnPNoun`, one approved verb (`Get-Verb`), **singular** noun even when returning many objects.23- `PnP` prefix always. New cmdlet names should not collide with other modules' nouns.24- Parameter names should match the ones used by comparable cmdlets in this module before inventing a25 new one — `-Identity`, `-Connection`, `-Force`, `-Includes`, `-Batch` have established meanings.2627**Output**28- Emit **objects**, never formatted text. `WriteObject(x)` — and `WriteObject(collection, true)` to29 enumerate, so the pipeline sees items rather than one array.30- `[OutputType(typeof(T))]` on every cmdlet that returns something.31- Never `Console.WriteLine`. Channels: `WriteObject` (data), `WriteWarning` (recoverable), 32 `WriteVerbose` (diagnostics), `WriteDebug`, `WriteProgress` (long operations).33- Do not return `null` to mean "not found" where the user asked for a specific item — write an34 error. Returning nothing for a filter that matched nothing is correct.3536**Input**37- `[Parameter]` per parameter with deliberate `Mandatory`, `Position`, `ValueFromPipeline`.38 Positional only for the one obvious parameter; everything else named.39- Use **PipeBind** types (`ListPipeBind`, `SitePipeBind`, …) so a name, an ID or an object all bind.40- `ParameterSpecified(nameof(X))` to distinguish "not supplied" from "supplied as the default".41- `[ValidateNotNull]` on any reference-typed parameter dereferenced in `ExecuteCmdlet` — otherwise42 `-Param $null` is a `NullReferenceException` rather than a message.43- `[ValidateSet]` / `[ValidateRange]` / `[ValidateCount]` rather than hand-rolled checks in the body.44- Prefer `SwitchParameter` over `bool` for flags. A `bool` parameter forces `-Flag $true` and reads45 wrong in PowerShell.4647**Safety**48- Anything destructive or overwriting: `SupportsShouldProcess = true`, then actually call49 `ShouldProcess`. Adding a prompt where none existed breaks unattended scripts — see50 [`api-surface-diff`](../api-surface-diff/SKILL.md).51- **`-Force` may bypass `ShouldContinue`, never `ShouldProcess`.** `ShouldProcess` is what implements52 `-WhatIf` and `-Confirm`, so short-circuiting it breaks simulation:5354 ```csharp55 if (Force || ShouldContinue($"Remove {Identity}?", Resources.Confirm)) // correct — the repo's pattern56 if (Force || ShouldProcess($"{target}", "Remove")) // WRONG — -Force -WhatIf deletes57 ```5859 In the second form `-Force` short-circuits the `||`, `ShouldProcess` is never called, and60 `-Force -WhatIf` performs the operation instead of simulating it. Where both are wanted, gate on61 `ShouldProcess` first and use `Force ||` only on the inner `ShouldContinue`. This is a live defect62 in `src/Commands/Apps/RemoveEntraIDServicePrincipalAppRoleAssignment.cs:52` — flag it if you touch63 that file, and never copy that line as a model.64- Never hardcode credentials, tenant names or endpoints.6566**Errors**67- `ThrowTerminatingError(new ErrorRecord(...))` for fatal errors, with a meaningful68 `ErrorCategory` and the object that caused it. Prefer it over a bare `throw` — and this is not69 only style, it changes what the user receives:70 - `WriteError` and `ThrowTerminatingError` surface under `-ErrorAction Stop` as a pipeline stop,71 which `PnPConnectedCmdlet.ProcessRecord` rethrows untouched72 (`src/Commands/Base/PnPConnectedCmdlet.cs:57-60`). Your `ErrorRecord`, its category and its73 target object survive intact.74 - A raw `throw` reaches the generic catch. Under the default error action it becomes75 `PSInvalidOperationException` with the original as inner; under `-ErrorAction Stop` or76 `SilentlyContinue` it becomes `new ErrorRecord(new Exception(message), source,77 ErrorCategory.NotSpecified, null)` — **type, inner exception, category and target object are all78 lost**, so everything the user needs must be in the message text. Under `-ErrorAction Ignore`79 `LogError` is skipped, so nothing is written at all.80- Error messages belong in `Resources.resx`, referenced as `Resources.MessageName`.8182---8384## C# 12 / .NET 88586`EnforceCodeStyleInBuild` and `EnableNETAnalyzers` are on. Warnings are findings.8788**Style**89- **4 spaces, not tabs.** Braces on their own line (Allman), as the existing files do.90- PascalCase for types/methods/properties; camelCase for locals and parameters; `_camelCase` for91 private fields; `I` prefix on interfaces.92- `var` where the type is evident from the right-hand side.93- One type per file. Enums go in `src/Commands/Enums/`, models in their own files — do not group94 several types in one model file.95- XML doc comments on utility classes, models and enums. Cmdlet classes do not need them; the96 markdown documentation is their reference.97- File-scoped namespaces and collection expressions (`[a, b]`) are fine in new code; do not churn98 existing files to adopt them.99100**Correctness**101- `?.` and `??` over nested null checks; do not use `!` to silence a nullable warning you have not102 actually reasoned about.103- `using`/`await using` for everything `IDisposable`. `HttpClient` is not per-call disposable — use104 the connection's existing client rather than newing one up.105- `CultureInfo.InvariantCulture` on every `ToString`/`Parse` of a date or number that crosses a wire106 or a file. A format string like `"yyyy-MM-ddTHH:mm:ssZ"` takes its separators from the current107 culture and yields `13.53.41` under some locales.108- `StringComparison.OrdinalIgnoreCase` for identifiers, URLs and property names. Never109 culture-sensitive comparison for machine-readable strings.110- Prefer LINQ for collection work, but not inside a loop that re-enumerates a remote collection.111112**Async**113- The cmdlet pipeline is synchronous. The established pattern here is114 `SomethingAsync(...).GetAwaiter().GetResult()`. Do not use `.Result` or `.Wait()`, and do not115 introduce `async void`.116- Do not add `ConfigureAwait` churn to existing call sites.117118**Cross-platform** — .NET 8 on Windows, Linux and macOS119- `Path.Combine`, `Path.DirectorySeparatorChar`. No backslash string surgery, no drive letters.120- Case-sensitive file systems: filename casing must match exactly.121- Do not mix `Environment.NewLine` (what `StringBuilder.AppendLine` writes) with hardcoded `\r\n` in122 generated files — the output then churns purely from changing OS.123- File permissions: anything written containing a private key must be owner-only on Unix.124125**Dependencies and the ALC**126- New package references have assembly-load-context consequences. The module assembly and CSOM live127 in `Core`; **every other dependency is private and goes to `Common`**. Adding a reference without128 placing it correctly breaks loading at runtime, not at build.129- Do not add a dependency for something the existing helpers already do.130131**Performance**132- Request only the properties needed — `DefaultRetrievalExpressions` / `EnsureProperties`, `$select`133 on Graph. Do not fetch a full field collection to read one field.134- `ExecuteQueryRetry()`, never `ExecuteQuery()`.135- Batch where the API supports it rather than calling per item in a loop.136- Graph collections: `GraphRequestHelper.GetResultCollection` follows `@odata.nextLink`;137 `Get` does not and silently returns the first page only.138139---140141## Build142143```144dotnet build src/PnP.PowerShell.sln145```146147Must be warning-clean. `src/Tests` is off limits — do not add or modify files there.148149A clean build is where your work stops. **Never commit, push, or open a PR** — see150[Human in the loop](../../../AGENTS.md#human-in-the-loop).