| name | api-design |
| description | Design stable, compatible public APIs using extend-only design principles. Manage API compatibility, wire compatibility, and versioning for NuGet packages and distributed systems. |
| invocable | false |
Public API Design and Compatibility
When to Use This Skill
Use this skill when:
- Designing public APIs for NuGet packages or libraries
- Making changes to existing public APIs
- Planning wire format changes for distributed systems
- Implementing versioning strategies
- Reviewing pull requests for breaking changes
The Three Types of Compatibility
| 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.
Extend-Only Design
The foundation of stable APIs: never remove or modify, only extend.
Three Pillars
- Previous functionality is immutable - Once released, behavior and signatures are locked
- New functionality through new constructs - Add overloads, new types, opt-in features
- Removal only after deprecation period - Years, not releases
Benefits
- Old code continues working in new versions
- New and old pathways coexist
- Upgrades are non-breaking by default
- Users upgrade on their schedule
Resources:
API Change Guidelines
Safe Changes (Any Release)
public void Process(Order order) { ... }
public void Process(Order order, CancellationToken ct)
{
}
public void Send(Message msg) { ... }
public void Send(Message msg, Priority priority)
{
}
public interface IOrderValidator { }
public enum OrderStatus { Pending, Complete, Cancelled }
public class Order
{
public DateTimeOffset? ShippedAt { get; init; }
}
Unsafe Changes (Never or Major Version Only)
public void ProcessOrder(Order order);
public void Process(int orderId);
public Order? GetOrder(string id);
internal class OrderProcessor { }
public void Process(Order order, CancellationToken ct = default);
public void Send(Message msg, Priority priority = Priority.Normal);
public void Process(Order order, ILogger logger);
Deprecation Pattern
[Obsolete("Obsolete since v1.5.0. Use ProcessAsync instead.")]
public void Process(Order order) { }
public Task ProcessAsync(Order order, CancellationToken ct = default);
API Approval Testing
Prevent accidental breaking changes with automated API surface testing.
Using ApiApprover + Verify
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.
PR Review Process
- PR includes changes to
*.verified.txt files
- Reviewers see exact API surface changes in diff
- Breaking changes are immediately visible
- Conscious decision required to approve
Wire Compatibility
For distributed systems, serialized data must be readable across versions.
Requirements
| 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.
Safely Evolving Wire Formats
Phase 1: Add read-side support (opt-in)
public sealed record HeartbeatV2(
Address From,
long SequenceNr,
long CreationTimeMs);
public object Deserialize(byte[] data, string manifest) => manifest switch
{
"Heartbeat" => DeserializeHeartbeatV1(data),
"HeartbeatV2" => DeserializeHeartbeatV2(data),
_ => throw new NotSupportedException()
};
Phase 2: Enable write-side (opt-out, next minor version)
akka.cluster.use-heartbeat-v2 = on
Phase 3: Make default (future version)
After install base has absorbed read-side code.
Schema-Based Serialization
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.
Encapsulation Patterns
Internal APIs
Mark non-public APIs explicitly:
[InternalApi]
public class ActorSystemImpl { }
namespace MyLibrary.Internal
{
public class InternalHelper { }
}
Document clearly:
Types in .Internal namespaces or marked with [InternalApi] may change between any releases without notice.
Sealing Classes
public sealed class OrderProcessor { }
public class OrderProcessor { }
Interface Segregation
public interface IOrderReader
{
Order? GetById(OrderId id);
}
public interface IOrderWriter
{
Task SaveAsync(Order order);
}
public interface IOrderRepository
{
Order? GetById(OrderId id);
Task SaveAsync(Order order);
}
Versioning Strategy
Semantic Versioning (Practical)
| 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 |
Key Principles
- No surprise breaks - Even major versions should be announced and planned
- Extensions anytime - New APIs can ship in any release
- Deprecate before remove -
[Obsolete] for at least one minor version
- Communicate timelines - Users need to plan upgrades
Chesterton's Fence
Before removing or changing something, understand why it exists.
Assume every public API is used by someone. If you want to change it:
- Socialize the proposal on GitHub
- Document migration path
- Provide deprecation period
- Ship in planned release
Pull Request Checklist
When reviewing PRs that touch public APIs:
Anti-Patterns
Breaking Changes Disguised as Fixes
public async Task<Order> GetOrderAsync(OrderId id)
{
}
[Obsolete("Use GetOrderAsync instead")]
public Order GetOrder(OrderId id) => GetOrderAsync(id).Result;
public async Task<Order> GetOrderAsync(OrderId id) { }
Silent Behavior Changes
public void Configure(bool enableCaching = true)
public void Configure(
bool enableCaching = false, // Original default preserved
bool enableNewCaching = true)
Polymorphic Serialization
{ "$type": "MyApp.Order, MyApp", "Id": 123 }
{ "type": "order", "id": 123 }
Resources