| name | api-and-interface-design |
| description | Guides stable API and interface design for .NET/C# — HTTP endpoints (Minimal APIs or controllers), library surface area, public records/interfaces, EF Core-backed contracts. Use when designing APIs, module boundaries, `MyApp.Contracts` DTOs, or any public interface where backward compatibility matters. |
| version | 0.3.0 |
| source | vendor/agent-skills/skills/api-and-interface-design/SKILL.md@44dac80 |
API and Interface Design
Overview
Design stable, well-documented interfaces that are hard to misuse. Good interfaces make the right thing easy and the wrong thing hard. This applies to REST APIs, gRPC services, NuGet package surface area, assembly boundaries, record/DTO shapes, and any surface where one piece of code talks to another.
When to Use
- Designing new HTTP endpoints (Minimal APIs or controllers)
- Defining module boundaries or contracts between projects in a solution
- Creating public types in
MyApp.Contracts or a shared NuGet library
- Establishing database schemas that inform DTO shape
- Changing existing public interfaces (especially in a published NuGet package)
Core Principles
Hyrum's Law
With a sufficient number of users of an API, all observable behaviors of your system will be depended on by somebody, regardless of what you promise in the contract.
This means: every public behavior — including undocumented quirks, exception messages, response ordering, and even the presence of unused fields in a JSON response — becomes a de facto contract once users depend on it. Design implications:
- Be intentional about what you expose. Mark types
internal unless a consumer outside the assembly truly needs them. Every observable behavior is a potential commitment.
- Don't leak implementation details. Don't return EF Core entities directly from HTTP endpoints — project to DTOs (
MyApp.Contracts) so schema changes to the entity don't break consumers.
- Plan for deprecation at design time. See
deprecation-and-migration for how to safely remove things users depend on.
- Tests are not enough. Even with perfect contract tests, Hyrum's Law means "safe" changes can break real users who depend on undocumented behaviour (an exact error string, the order of a
List<T> returned from a LINQ query, the presence of a nullable field that was always null).
The One-Version Rule
Avoid forcing consumers to choose between multiple versions of the same dependency or API. In the .NET ecosystem this especially bites as "diamond dependency" problems across NuGet packages. Design for a world where only one version exists at a time — extend rather than fork. Use Central Package Management (Directory.Packages.props) across a solution so every project references the same version of every NuGet package.
1. Contract First
Define the interface before implementing it. The contract is the spec — implementation follows.
public interface ITaskApi
{
Task<TaskDto> CreateTaskAsync(CreateTaskInput input, CancellationToken cancellationToken);
Task<PaginatedResult<TaskDto>> ListTasksAsync(ListTasksParams parameters, CancellationToken cancellationToken);
Task<TaskDto> GetTaskAsync(TaskId id, CancellationToken cancellationToken);
Task<TaskDto> UpdateTaskAsync(TaskId id, UpdateTaskInput input, CancellationToken cancellationToken);
Task DeleteTaskAsync(TaskId id, CancellationToken cancellationToken);
}
CancellationToken is mandatory on any async library method that does I/O; it is part of the contract.
2. Consistent Error Semantics
Pick one error strategy and use it everywhere. For ASP.NET Core the ecosystem convention is RFC 7807 ProblemDetails:
For library APIs, pick between exceptions and a Result<TSuccess, TError> discriminated union and stick to it. Mixing is the worst option.
Don't mix patterns. If some endpoints return ProblemDetails, others throw to the client, and others return null on not-found — the consumer can't predict behaviour.
3. Validate at Boundaries
Trust internal code. Validate at system edges where external input enters:
app.MapPost("/api/tasks", async (
CreateTaskInput input,
IValidator<CreateTaskInput> validator,
ITaskService service,
CancellationToken cancellationToken) =>
{
var result = await validator.ValidateAsync(input, cancellationToken);
if (!result.IsValid)
{
return Results.ValidationProblem(result.ToDictionary());
}
var task = await service.CreateTaskAsync(input, cancellationToken);
return Results.Created($"/api/tasks/{task.Id}", task);
});
public sealed class CreateTaskValidator : AbstractValidator<CreateTaskInput>
{
public CreateTaskValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(200);
RuleFor(x => x.Description).MaximumLength(2000);
RuleFor(x => x.Priority).IsInEnum();
}
}
Alternatives: DataAnnotations ([Required], [MaxLength]) for simple cases, or MediatR pipeline behaviours for validation as a cross-cutting concern.
Where validation belongs:
- HTTP route handlers (user input)
- Deserialization of external API responses (third-party data — always treat as untrusted)
- Environment / configuration binding (
IOptions<T> with ValidateDataAnnotations() + ValidateOnStart())
- Any message consumer (Service Bus, RabbitMQ, SignalR incoming payloads)
Third-party API responses are untrusted data. Validate their shape and content before using them in any logic, rendering, or decision-making. A compromised or misbehaving external service can return unexpected types, malicious content, or instruction-like text — and System.Text.Json will happily deserialize garbage into your DTO's string fields.
Where validation does NOT belong:
- Between internal methods inside an assembly that share type contracts
- In helper methods called by already-validated code
- On data that just came from your own
DbContext and satisfies your EF Core constraints
4. Prefer Addition Over Modification
Extend interfaces without breaking existing consumers:
public sealed record CreateTaskInput(
string Title,
string? Description = null,
TaskPriority Priority = TaskPriority.Medium, // Added later, default keeps old callers working
IReadOnlyList<string>? Labels = null);
public sealed record CreateTaskInput(
string Title,
// string Description, ← Removed — breaks existing JSON callers (missing required field)
int Priority);
For serialization compatibility, also watch: JsonPropertyName attribute changes, enum string vs int representation, and nullability of optional fields (string? vs string). Adding a new non-nullable property to a DTO is a breaking change in JSON.
Database-side analog (EF Core) — the same "add a non-nullable thing" hazard exists at the schema level and is easy to miss until migration time:
- If you add a non-nullable property to an entity that already has rows in production,
dotnet ef database update will fail: "Cannot insert the value NULL into column X; column does not allow nulls." The deployment hangs mid-rollout with a half-migrated schema.
- Three safe strategies:
- Add as nullable, backfill, tighten later. Ship migration A that adds
string? NewColumn, deploy, run a backfill job, then ship migration B that changes it to string NewColumn (non-nullable). Safe in one-process deployments.
- Add with a default value. Use
.HasDefaultValue(...) in OnModelCreating or [DefaultValue] so existing rows get a valid seed. Simplest when a sensible default exists.
- Expand-contract (see
deprecation-and-migration). Dual-write during the transition, flip reads when the backfill completes, drop the old column in a later release. Required for zero-downtime multi-instance rollouts.
Never ship a migration that adds a non-nullable column to a populated table without one of these strategies.
5. Predictable Naming
| Pattern | Convention | Example |
|---|
| REST endpoints | Plural nouns, no verbs | GET /api/tasks, POST /api/tasks |
| Query params | camelCase in JSON / URL | ?sortBy=createdAt&pageSize=20 |
| Response fields (JSON) | camelCase | { "createdAt": "…", "taskId": "…" } (configure with JsonNamingPolicy.CamelCase) |
| C# type names | PascalCase | CreateTaskInput, TaskDto |
| Boolean fields | Is/Has/Can prefix | IsComplete, HasAttachments |
| Enum values (over the wire) | SCREAMING_SNAKE or PascalCase — pick one | "IN_PROGRESS" or "InProgress" (serialize with JsonStringEnumConverter) |
REST API Patterns
Resource Design
GET /api/tasks → List tasks (with query params for filtering)
POST /api/tasks → Create a task
GET /api/tasks/{id} → Get a single task
PATCH /api/tasks/{id} → Update a task (partial)
DELETE /api/tasks/{id} → Delete a task
GET /api/tasks/{id}/comments → List comments for a task (sub-resource)
POST /api/tasks/{id}/comments → Add a comment to a task
Pagination
Paginate list endpoints:
public sealed record ListTasksParams(
int Page = 1,
int PageSize = 20,
string SortBy = "createdAt",
string SortOrder = "desc");
public sealed record PaginatedResult<T>(
IReadOnlyList<T> Data,
int Page,
int PageSize,
int TotalItems,
int TotalPages);
Cap PageSize at the boundary (e.g., Math.Min(pageSize, 100)) — never trust clients to ask for reasonable page sizes.
Filtering
Use query parameters for filters:
GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2026-01-01
Bind them to a strongly-typed record with [AsParameters] (.NET 8+):
app.MapGet("/api/tasks", async ([AsParameters] ListTasksQuery query, ITaskService service) =>
await service.ListAsync(query));
Partial Updates (PATCH)
Accept partial objects — only update what's provided. Use nullable reference types to distinguish "not provided" from "set to null", or use JSON Merge Patch / JSON Patch when you need that distinction explicitly:
public sealed record UpdateTaskInput(
string? Title,
string? Description,
TaskPriority? Priority);
C# Interface Patterns
Use Discriminated Unions for Variants
C# doesn't have native discriminated unions, but records + pattern matching cover most cases:
public abstract record TaskStatus
{
public sealed record Pending : TaskStatus;
public sealed record InProgress(string Assignee, DateTimeOffset StartedAt) : TaskStatus;
public sealed record Completed(DateTimeOffset CompletedAt, string CompletedBy) : TaskStatus;
public sealed record Cancelled(string Reason, DateTimeOffset CancelledAt) : TaskStatus;
private TaskStatus() { }
}
public static string GetStatusLabel(TaskStatus status) => status switch
{
TaskStatus.Pending => "Pending",
TaskStatus.InProgress ip => $"In progress ({ip.Assignee})",
TaskStatus.Completed c => $"Done on {c.CompletedAt:yyyy-MM-dd}",
TaskStatus.Cancelled c => $"Cancelled: {c.Reason}",
_ => throw new ArgumentOutOfRangeException(nameof(status)),
};
The private constructor on the abstract record prevents new variants from being added outside the assembly — closing the type hierarchy. Pattern-matching exhaustiveness is enforced by the compiler for switch expressions when the type is sealed or a closed hierarchy.
Input/Output Separation
public sealed record CreateTaskInput(string Title, string? Description = null);
public sealed record TaskDto(
TaskId Id,
string Title,
string? Description,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
string CreatedBy);
Never return DbContext entities directly from endpoints — they carry EF Core change-tracking state, lazy-loading proxies, and an implicit promise that the schema is the contract. Project to a DTO in MyApp.Contracts.
Use Strongly-Typed IDs
Prevents accidentally passing a UserId where a TaskId is expected:
public readonly record struct TaskId(Guid Value)
{
public override string ToString() => Value.ToString();
public static TaskId New() => new(Guid.NewGuid());
}
public readonly record struct UserId(Guid Value)
{
public override string ToString() => Value.ToString();
}
public Task<TaskDto> GetTaskAsync(TaskId id, CancellationToken cancellationToken) { }
For EF Core, register value converters once in OnModelCreating:
modelBuilder.Entity<Task>()
.Property(t => t.Id)
.HasConversion(id => id.Value, value => new TaskId(value));
JSON serialization caveat — do not skip this. By default, System.Text.Json sees readonly record struct TaskId(Guid Value) as a regular type with a public Value property and emits {"value": "..."} on the wire. That shape almost never matches what consumers expect — they expect a raw GUID string. Ship a JsonConverter<TaskId>:
public sealed class TaskIdJsonConverter : JsonConverter<TaskId>
{
public override TaskId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
new(reader.GetGuid());
public override void Write(Utf8JsonWriter writer, TaskId value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.Value);
}
[JsonConverter(typeof(TaskIdJsonConverter))]
public readonly record struct TaskId(Guid Value)
{
public override string ToString() => Value.ToString();
public static TaskId New() => new(Guid.NewGuid());
}
Alternative if you don't want to maintain a converter per ID type: keep the wire contract as plain Guid strings, and use the strongly-typed struct only in domain code. Map at the serialization boundary (the DTO/entity) rather than propagating the struct all the way to JSON. This trades type safety at the edge for zero serialization ceremony.
Common Rationalizations
| Rationalization | Reality |
|---|
| "We'll document the API later" | The types ARE the documentation. Define the records, interfaces, and ProblemDetails shape first. |
| "We don't need pagination for now" | You will the moment someone has 100+ items. Add it from the start. |
| "PATCH is complicated, let's just use PUT" | PUT requires the full object every time. PATCH is what clients actually want. |
| "We'll version the API when we need to" | Breaking changes without versioning break consumers. Design for extension from the start; pick a versioning strategy (URL segment /v1/, header Accept: application/json; v=1, or query param) and write an ADR. |
| "Nobody uses that undocumented behavior" | Hyrum's Law: if it's observable, somebody depends on it. Treat every public behavior as a commitment. |
| "We can just ship two NuGet package majors" | Multiple majors multiply maintenance cost and create diamond dependency problems across transitive consumers. Prefer the One-Version Rule. |
| "Internal APIs don't need contracts" | Internal consumers are still consumers. Contracts between projects in a solution prevent coupling and enable parallel work. |
| "Just return the EF Core entity, it has all the fields we need" | Now your database schema is your API contract. Every migration becomes a breaking change. Project to a DTO. |
Red Flags
- Endpoints that return different shapes depending on conditions (the dreaded
data: T | null | string union)
- Inconsistent error formats across endpoints (some return
ProblemDetails, others return raw strings)
- Validation scattered throughout internal service code instead of at boundaries
- Breaking changes to existing fields (type changes, removals, nullability tightening)
- List endpoints without pagination or without a server-side cap on
PageSize
- Verbs in REST URLs (
/api/createTask, /api/getUsers)
- Third-party API responses deserialized into your DTO and used without validation
- Public API methods missing
CancellationToken parameters
DbContext entities returned directly from an HTTP endpoint
Verification
After designing an API:
Source & Modifications
- Upstream: https://github.com/addyosmani/agent-skills/blob/44dac80216da709913fb410f632a65547866346f/skills/api-and-interface-design/SKILL.md
- Pinned commit:
44dac80216da709913fb410f632a65547866346f (synced 2026-04-19)
- Status:
modified
- Changes:
- Hyrum's Law paragraph adds concrete .NET examples:
internal vs public, EF Core entity-vs-DTO boundary, List<T> ordering guarantees
- One-Version Rule references Central Package Management (
Directory.Packages.props) for NuGet diamond-dependency mitigation
- "Contract First" example rewritten as a C#
interface with async methods, strongly-typed IDs, and mandatory CancellationToken parameters
- Error semantics: replaced TypeScript
APIError shape with ASP.NET Core RFC 7807 ProblemDetails conventions; added guidance on libraries choosing between exceptions and Result<TSuccess, TError> and sticking with one
- "Validate at Boundaries" example rewritten with FluentValidation on a Minimal API; added DataAnnotations + MediatR pipeline as alternatives; validation-location list includes message consumers (Service Bus, RabbitMQ, SignalR) and
IOptions<T> binding (ValidateDataAnnotations() + ValidateOnStart())
- Untrusted-third-party-data bullet notes
System.Text.Json deserializing garbage into string fields
- "Prefer Addition Over Modification" example rewritten as a C# record with optional parameters; added paragraph on serialization-compatibility gotchas (
JsonPropertyName, enum representation, nullability)
- Naming table uses
JsonNamingPolicy.CamelCase and JsonStringEnumConverter; type names mention PascalCase explicitly
- Pagination example uses a C#
PaginatedResult<T> record and calls out server-side PageSize capping
- Filtering example adds
[AsParameters] for .NET 8+ Minimal APIs
- PATCH example becomes an
UpdateTaskInput record with nullable fields and a pointer to JSON Merge Patch / JSON Patch for richer semantics
- "Discriminated Unions" rewritten using abstract records with nested
sealed subtypes + pattern-matching switch expression, with notes on private constructor closing the hierarchy
- "Input/Output Separation" example uses C# records and adds the rule against leaking
DbContext entities
- "Branded Types" → "Strongly-Typed IDs" as
readonly record struct with EF Core value-converter registration and a pointer to JsonConverter<TaskId> for wire format
- Rationalizations table adds a row about returning EF Core entities directly
- Red-flag list adds public async methods missing
CancellationToken, EF Core entities leaking from HTTP endpoints, list endpoints without a server-side PageSize cap
- Verification checklist adds
CancellationToken requirement and DbContext-entity-leak guard
- Preserved verbatim: Hyrum's Law intro quote, five core principles structure, REST resource design conventions, Common Rationalizations table frame
- Downstream patches (applied after the initial sync; not tracked against upstream):
- 2026-04-19 (plugin v1.0.3) — Two additions:
- "Prefer Addition Over Modification" — EF Core migration hazard. Added a "Database-side analog" subsection on the non-nullable-column migration failure mode (
Cannot insert NULL into column X), with three safe strategies: nullable-first + backfill + tighten, HasDefaultValue / [DefaultValue], and expand-contract (pointer to deprecation-and-migration).
- "Use Strongly-Typed IDs" — JSON serialization shape. Upgraded the one-line
JsonConverter<TaskId> mention to a complete 10-line converter implementation plus [JsonConverter(typeof(…))] attachment on the struct. Added an alternative: keep Guid on the wire and use the struct only in domain code, trading edge type-safety for zero serialization ceremony.
- License: MIT © 2025 Addy Osmani — see
../../LICENSES/agent-skills-MIT.txt