.agents/skills/csharp-api-design/SKILL.md
Design stable, compatible public APIs using extend-only design principles. Manage API compatibility, wire compatibility, and versioning for NuGet packages and distributed systems.
npx skillsauth add woutervanranst/Arius7 api-designInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
4 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Use this skill when:
| Type | Definition | Scope | |------|------------|-------| | API/Source | Code compiles against newer version | Public method signatures, types | | Binary | Compiled code runs against newer version | Assembly layout, method tokens | | Wire | Serialized data readable by other versions | Network protocols, persistence formats |
Breaking any of these creates upgrade friction for users.
The foundation of stable APIs: never remove or modify, only extend.
Resources:
// ADD new overloads with default parameters
public void Process(Order order, CancellationToken ct = default);
// ADD new optional parameters to existing methods
public void Send(Message msg, Priority priority = Priority.Normal);
// ADD new types, interfaces, enums
public interface IOrderValidator { }
public enum OrderStatus { Pending, Complete, Cancelled }
// ADD new members to existing types
public class Order
{
public DateTimeOffset? ShippedAt { get; init; } // NEW
}
// REMOVE or RENAME public members
public void ProcessOrder(Order order); // Was: Process()
// CHANGE parameter types or order
public void Process(int orderId); // Was: Process(Order order)
// CHANGE return types
public Order? GetOrder(string id); // Was: public Order GetOrder()
// CHANGE access modifiers
internal class OrderProcessor { } // Was: public
// ADD required parameters without defaults
public void Process(Order order, ILogger logger); // Breaks callers!
// Step 1: Mark as obsolete with version (any release)
[Obsolete("Obsolete since v1.5.0. Use ProcessAsync instead.")]
public void Process(Order order) { }
// Step 2: Add new recommended API (same release)
public Task ProcessAsync(Order order, CancellationToken ct = default);
// Step 3: Remove in next major version (v2.0+)
// Only after users have had time to migrate
Prevent accidental breaking changes with automated API surface testing.
dotnet add package PublicApiGenerator
dotnet add package Verify.Xunit
[Fact]
public Task ApprovePublicApi()
{
var api = typeof(MyLibrary.PublicClass).Assembly.GeneratePublicApi();
return Verify(api);
}
Creates ApprovePublicApi.verified.txt:
namespace MyLibrary
{
public class OrderProcessor
{
public OrderProcessor() { }
public void Process(Order order) { }
public Task ProcessAsync(Order order, CancellationToken ct = default) { }
}
}
Any API change fails the test - reviewer must explicitly approve changes.
*.verified.txt filesFor distributed systems, serialized data must be readable across versions.
| Direction | Requirement | |-----------|-------------| | Backward | Old writers → New readers (current version reads old data) | | Forward | New writers → Old readers (old version reads new data) |
Both are required for zero-downtime rolling upgrades.
Phase 1: Add read-side support (opt-in)
// New message type - readers deployed first
public sealed record HeartbeatV2(
Address From,
long SequenceNr,
long CreationTimeMs); // NEW field
// Deserializer handles both old and new
public object Deserialize(byte[] data, string manifest) => manifest switch
{
"Heartbeat" => DeserializeHeartbeatV1(data), // Old format
"HeartbeatV2" => DeserializeHeartbeatV2(data), // New format
_ => throw new NotSupportedException()
};
Phase 2: Enable write-side (opt-out, next minor version)
// Config to enable new format (off by default initially)
akka.cluster.use-heartbeat-v2 = on
Phase 3: Make default (future version)
After install base has absorbed read-side code.
Prefer schema-based formats over reflection-based:
| Format | Type | Wire Compatibility | |--------|------|-------------------| | Protocol Buffers | Schema-based | Excellent - explicit field numbers | | MessagePack | Schema-based | Good - with contracts | | System.Text.Json | Schema-based (with source gen) | Good - explicit properties | | Newtonsoft.Json | Reflection-based | Poor - type names in payload | | BinaryFormatter | Reflection-based | Terrible - never use |
See dotnet/serialization skill for details.
Mark non-public APIs explicitly:
// Attribute for documentation
[InternalApi]
public class ActorSystemImpl { }
// Namespace convention
namespace MyLibrary.Internal
{
public class InternalHelper { } // Public for extensibility, not for users
}
Document clearly:
Types in
.Internalnamespaces or marked with[InternalApi]may change between any releases without notice.
// DO: Seal classes not designed for inheritance
public sealed class OrderProcessor { }
// DON'T: Leave unsealed by accident
public class OrderProcessor { } // Users might inherit, blocking changes
// DO: Small, focused interfaces
public interface IOrderReader
{
Order? GetById(OrderId id);
}
public interface IOrderWriter
{
Task SaveAsync(Order order);
}
// DON'T: Monolithic interfaces (can't add methods without breaking)
public interface IOrderRepository
{
Order? GetById(OrderId id);
Task SaveAsync(Order order);
// Adding new methods breaks all implementations!
}
| Version | Changes Allowed | |---------|----------------| | Patch (1.0.x) | Bug fixes, security patches | | Minor (1.x.0) | New features, deprecations, obsolete removal | | Major (x.0.0) | Breaking changes, old API removal |
[Obsolete] for at least one minor versionBefore removing or changing something, understand why it exists.
Assume every public API is used by someone. If you want to change it:
When reviewing PRs that touch public APIs:
[Obsolete] instead).verified.txt changes reviewed)// "Bug fix" that breaks users
public async Task<Order> GetOrderAsync(OrderId id) // Was sync!
{
// "Fixed" to be async - but breaks all callers
}
// Correct: Add new method, deprecate old
[Obsolete("Use GetOrderAsync instead")]
public Order GetOrder(OrderId id) => GetOrderAsync(id).Result;
public async Task<Order> GetOrderAsync(OrderId id) { }
// Changing defaults breaks users who relied on old behavior
public void Configure(bool enableCaching = true) // Was: false!
// Correct: New parameter with new name
public void Configure(
bool enableCaching = false, // Original default preserved
bool enableNewCaching = true) // New behavior opt-in
// AVOID: Type names in wire format
{ "$type": "MyApp.Order, MyApp", "Id": 123 }
// Renaming Order class = wire break!
// PREFER: Explicit discriminators
{ "type": "order", "id": 123 }
testing
Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.
data-ai
Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
development
Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work.
tools
Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach.