dotnet-csharp-api-design
npx skills add https://github.com/novotnyllc/dotnet-artisan --skill dotnet-csharp-api-design
Agent 安装分布
Skill 文档
dotnet-csharp-api-design
Design-time principles for creating public .NET APIs that are intuitive, consistent, and forward-compatible. Covers naming conventions for API surface, parameter ordering, return type selection, error reporting strategies, extension points, and wire compatibility for serialized types. This skill addresses the design decisions that make APIs compatible and usable in the first place, before enforcement tooling gets involved.
Version assumptions: .NET 8.0+ baseline. Examples use modern C# features (primary constructors, collection expressions) where appropriate.
Scope
- Naming conventions for public API types, methods, and parameters
- Parameter ordering and overload progression
- Return type selection (nullable, IReadOnlyList, IAsyncEnumerable, ValueTask)
- Error reporting strategies (exceptions, Try pattern, result objects)
- Extension points (interfaces, delegates, builder patterns)
- Wire compatibility for serialized types
Out of scope
- Binary/source compatibility enforcement and tooling — see [skill:dotnet-library-api-compat]
- PublicApiAnalyzers, Verify snapshots, and CI validation of API surface — see [skill:dotnet-api-surface-validation]
- General C# naming conventions and file layout — see [skill:dotnet-csharp-coding-standards]
- HTTP API versioning and URL design — see [skill:dotnet-api-versioning]
- NuGet packaging and SemVer mechanics — see [skill:dotnet-nuget-authoring]
Cross-references: [skill:dotnet-library-api-compat] for compatibility enforcement, [skill:dotnet-api-surface-validation] for CI detection, [skill:dotnet-csharp-coding-standards] for general naming rules, [skill:dotnet-api-versioning] for HTTP API versioning, [skill:dotnet-nuget-authoring] for SemVer and packaging.
Naming Conventions for API Surface
Type Naming
Follow the .NET Framework Design Guidelines naming patterns for public API types:
| Type Kind | Suffix Pattern | Example |
|---|---|---|
| Base class | Base suffix only for abstract base types |
ValidatorBase |
| Interface | I prefix |
IWidgetFactory |
| Exception | Exception suffix |
WidgetNotFoundException |
| Attribute | Attribute suffix |
RequiredPermissionAttribute |
| Event args | EventArgs suffix |
WidgetCreatedEventArgs |
| Options/config | Options suffix |
WidgetServiceOptions |
| Builder | Builder suffix |
WidgetBuilder |
Method Naming
| Pattern | Convention | Example |
|---|---|---|
| Synchronous | Verb or verb phrase | Calculate(), GetWidget() |
| Asynchronous | Async suffix |
CalculateAsync(), GetWidgetAsync() |
| Boolean query | Is/Has/Can prefix |
IsValid(), HasPermission() |
| Try pattern | Try prefix, out parameter |
TryGetWidget(int id, out Widget widget) |
| Factory | Create prefix |
CreateWidget(), CreateWidgetAsync() |
| Conversion | To/From prefix |
ToDto(), FromEntity() |
Avoid Abbreviations in Public API
Spell out words in public APIs even if internal code uses abbreviations. Public APIs are consumed by developers who may not share the team’s domain shorthand:
// WRONG -- abbreviations in public surface
public IReadOnlyList<TxnResult> GetRecentTxns(int cnt);
// CORRECT -- spelled out for clarity
public IReadOnlyList<TransactionResult> GetRecentTransactions(int count);
Parameter Ordering
Consistent parameter ordering reduces cognitive load and enables fluent usage patterns across an API surface.
Standard Order
- Target/subject — the primary entity being operated on
- Required parameters — essential inputs without defaults
- Optional parameters — inputs with sensible defaults
- Cancellation token — always last (convention enforced by CA1068)
// Consistent ordering across the API surface
public Task<Widget> GetWidgetAsync(
int widgetId, // 1. Target
WidgetOptions options, // 2. Required
bool includeHistory = false, // 3. Optional
CancellationToken cancellationToken = default); // 4. Always last
public Task<Widget> UpdateWidgetAsync(
int widgetId, // 1. Target
WidgetUpdateRequest request, // 2. Required
bool validateFirst = true, // 3. Optional
CancellationToken cancellationToken = default); // 4. Always last
Overload Progression
Design overloads as a progression from simple to detailed. Each overload should delegate to the next more specific one:
// Simple -- sensible defaults
public Task<Widget> GetWidgetAsync(int widgetId,
CancellationToken cancellationToken = default)
=> GetWidgetAsync(widgetId, WidgetOptions.Default, cancellationToken);
// Detailed -- full control
public Task<Widget> GetWidgetAsync(int widgetId,
WidgetOptions options,
CancellationToken cancellationToken = default);
Return Type Selection
When to Return What
| Scenario | Return Type | Rationale |
|---|---|---|
| Single entity, always exists | Widget |
Throw if not found |
| Single entity, may not exist | Widget? |
Nullable reference type communicates optionality |
| Collection, possibly empty | IReadOnlyList<Widget> |
Immutable, indexable, communicates no mutation |
| Streaming results | IAsyncEnumerable<Widget> |
Avoids buffering entire result set |
| Operation result with detail | Result<Widget> / discriminated union |
Rich error info without exceptions |
| Void with async | Task |
Never async void except event handlers |
| Frequently synchronous completion | ValueTask<Widget> |
Avoids Task allocation on cache hits |
Prefer IReadOnlyList Over IEnumerable for Materialized Collections
// WRONG -- caller does not know if result is materialized or lazy
public IEnumerable<Widget> GetWidgets();
// CORRECT -- signals materialized, indexable collection
public IReadOnlyList<Widget> GetWidgets();
// CORRECT -- signals streaming/lazy evaluation explicitly
public IAsyncEnumerable<Widget> GetWidgetsStreamAsync(
CancellationToken cancellationToken = default);
The Try Pattern
Use the Try pattern for operations that have a common, non-exceptional failure mode:
// Parsing, lookup, validation -- failure is expected, not exceptional
public bool TryGetWidget(int widgetId, [NotNullWhen(true)] out Widget? widget);
// Async Try pattern -- return nullable instead of out parameter
public Task<Widget?> TryGetWidgetAsync(int widgetId,
CancellationToken cancellationToken = default);
Error Reporting Strategies
Exception Hierarchy
Design exception types that enable callers to catch at the right granularity:
// Base exception for the library -- callers can catch all library errors
public class WidgetServiceException : Exception
{
public WidgetServiceException(string message) : base(message) { }
public WidgetServiceException(string message, Exception inner) : base(message, inner) { }
}
// Specific exceptions derive from the base
public class WidgetNotFoundException : WidgetServiceException
{
public int WidgetId { get; }
public WidgetNotFoundException(int widgetId)
: base($"Widget {widgetId} not found.") => WidgetId = widgetId;
}
public class WidgetValidationException : WidgetServiceException
{
public IReadOnlyList<string> Errors { get; }
public WidgetValidationException(IReadOnlyList<string> errors)
: base("Widget validation failed.") => Errors = errors;
}
When to Use Exceptions vs Return Values
| Approach | When to Use |
|---|---|
| Throw exception | Unexpected failures, programming errors, infrastructure failures |
Return null / default |
“Not found” is a normal, expected outcome (query patterns) |
Try pattern (bool + out) |
Parsing or validation where failure is common and synchronous |
| Result object | Multiple failure modes that callers need to distinguish without try/catch |
Argument Validation
Validate public API entry points immediately and throw the standard .NET exceptions:
public Widget CreateWidget(string name, decimal price)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(price);
// Proceed with creation
return new Widget(name, price);
}
Use ArgumentException.ThrowIfNullOrWhiteSpace (.NET 8+) and ArgumentOutOfRangeException.ThrowIfNegativeOrZero (.NET 8+) instead of manual null checks with throw new ArgumentNullException(...). These throw helpers are optimized by the JIT (no delegate allocation, better inlining).
Extension Points
Designing for Extensibility Without Inheritance
Prefer composition and interfaces over class inheritance for extension points:
// GOOD -- interface-based extension point
public interface IWidgetValidator
{
ValueTask<bool> ValidateAsync(Widget widget, CancellationToken ct = default);
}
// GOOD -- delegate-based extension for simple hooks
public class WidgetServiceOptions
{
public Func<Widget, CancellationToken, ValueTask>? OnWidgetCreated { get; set; }
}
// GOOD -- builder pattern for complex configuration
public sealed class WidgetServiceBuilder
{
private readonly List<IWidgetValidator> _validators = [];
public WidgetServiceBuilder AddValidator(IWidgetValidator validator)
{
_validators.Add(validator);
return this;
}
public WidgetServiceBuilder AddValidator(
Func<Widget, CancellationToken, ValueTask<bool>> validator)
{
_validators.Add(new DelegateValidator(validator));
return this;
}
public WidgetService Build() => new(_validators);
}
Extension Method Guidelines
| Guideline | Rationale |
|---|---|
| Place extensions in the same namespace as the type they extend | Discoverable without extra using statements |
Never put extensions in System or System.Linq |
Namespace pollution affects all consumers |
| Prefer instance methods over extensions when you own the type | Extensions are a last resort for types you do not own |
Keep the extension’s this parameter as the most specific usable type |
IEnumerable<T> not object; avoids polluting IntelliSense |
Wire Compatibility for Serialized Types
Types that are serialized (JSON, Protobuf, MessagePack) or persisted form an implicit contract. Changing their shape breaks existing clients or stored data.
Safe Changes (Wire Compatible)
| Change | Why Safe |
|---|---|
| Add optional property with default | Old payloads deserialize with default; old clients ignore new field |
| Add new enum member at the end | Existing serialized values map to existing members |
Rename property with [JsonPropertyName] annotation |
Wire name stays the same |
Breaking Changes (Wire Incompatible)
| Change | Impact |
|---|---|
| Remove or rename property (without annotation) | Old payloads lose data; old clients send unrecognized fields |
| Change property type | Deserialization failure or silent data loss |
| Reorder enum members (for integer-serialized enums) | Existing stored integers map to wrong members |
| Change from class to struct or vice versa | Serializer behavior changes (null handling, default values) |
Defensive Serialization Design
// Version-tolerant DTO with explicit wire names
public sealed class WidgetDto
{
[JsonPropertyName("id")]
public int Id { get; init; }
[JsonPropertyName("name")]
public required string Name { get; init; }
// V2 addition -- optional with default, old payloads work fine
[JsonPropertyName("category")]
public string? Category { get; init; }
// V3 addition -- use JsonIgnoreCondition to exclude defaults from wire
[JsonPropertyName("priority")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Priority { get; init; }
}
Enum Serialization Strategy
// GOOD -- string serialization is rename-safe and human-readable
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum WidgetStatus
{
Draft,
Active,
Archived
}
// RISKY -- integer serialization breaks when members are reordered or inserted
// Only use when wire format size is critical and members are append-only
public enum WidgetPriority
{
Low = 0,
Medium = 1,
High = 2
// New members MUST go at the end with explicit values
}
API Design Checklist
Before shipping a new public API, verify each concern:
- Naming — follows .NET naming conventions, no abbreviations, consistent with rest of API surface
- Parameters — ordered (target, required, optional, CancellationToken), no more than ~5 parameters (use options object for complex APIs)
- Return types — appropriate for the scenario (nullable for optional, IReadOnlyList for collections, Task/ValueTask for async)
- Error handling — clear exception types, argument validation at entry points, Try pattern where failure is expected
- Extension points — interfaces or delegates, not virtual methods on concrete classes
- Wire safety — serialized types use explicit property names, additive-only evolution, enum strategy documented
- Compatibility — changes reviewed against [skill:dotnet-library-api-compat] rules before release
Agent Gotchas
- Do not use abbreviations in public API names — spell out words even when internal code uses shorthand. Public APIs are consumed by developers outside the team who do not share the domain vocabulary.
- Do not place CancellationToken before optional parameters — CA1068 enforces CancellationToken as the last parameter. Placing it earlier breaks the standard ordering convention and triggers analyzer warnings.
- Do not return mutable collections from public APIs — return
IReadOnlyList<T>orIReadOnlyCollection<T>instead ofList<T>orIList<T>. Mutable return types allow callers to corrupt internal state. - Do not change serialized property names without
[JsonPropertyName]annotations — renaming a C# property without preserving the wire name breaks all existing serialized data and API clients. - Do not add required parameters to existing public methods — this is a source-breaking change. Add a new overload or use optional parameters with defaults instead.
- Do not use
async voidin API surface — returnTaskorValueTask. The only validasync voidis framework event handlers. See [skill:dotnet-csharp-async-patterns]. - Do not design exception hierarchies without a base library exception — callers need a single catch point for all library errors. Always provide a base exception type that specific exceptions derive from.
- Do not put extension methods in the
Systemnamespace — namespace pollution affects every file in every consumer project. Use the library’s own namespace or a dedicated.Extensionssub-namespace.
Prerequisites
- .NET 8.0+ SDK
- Familiarity with C# naming conventions (see [skill:dotnet-csharp-coding-standards])
- Understanding of binary/source compatibility concepts (see [skill:dotnet-library-api-compat])
- System.Text.Json for wire compatibility examples