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.Contractsor 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
internalunless 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-migrationfor 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 alwaysnull).
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.
// Define the contract first, in MyApp.Contracts (framework-lean assembly)
public interface ITaskApi
{
// Creates a task and returns the created task with server-generated fields.
Task<TaskDto> CreateTaskAsync(CreateTaskInput input, CancellationToken cancellationToken);
// Returns paginated tasks matching filters.
Task<PaginatedResult<TaskDto>> ListTasksAsync(ListTasksParams parameters, CancellationToken cancellationToken);
// Returns a single task or throws NotFoundException.
Task<TaskDto> GetTaskAsync(TaskId id, CancellationToken cancellationToken);
// Partial update — only provided fields change.
Task<TaskDto> UpdateTaskAsync(TaskId id, UpdateTaskInput input, CancellationToken cancellationToken);
// Idempotent delete — succeeds even if already deleted.
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:
// REST: HTTP status codes + ProblemDetails body (RFC 7807)
// Every error response follows the same shape; ASP.NET Core produces this by default
// when you throw validation failures or call Results.Problem(...).
//
// Status code mapping (standard):
// 400 → Client sent invalid data (malformed JSON, wrong type)
// 401 → Not authenticated
// 403 → Authenticated but not authorized
// 404 → Resource not found
// 409 → Conflict (duplicate, concurrency token mismatch)
// 422 → Validation failed (semantically invalid input)
// 500 → Server error (never expose internal details; production ASP.NET Core
// strips exception details from ProblemDetails for you — keep it that way)
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:
// Validate at the Minimal API boundary with FluentValidation
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>withValidateDataAnnotations()+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.Jsonwill 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
DbContextand satisfies your EF Core constraints
4. Prefer Addition Over Modification
Extend interfaces without breaking existing consumers:
// Good: Add optional fields with safe defaults
public sealed record CreateTaskInput(
string Title,
string? Description = null,
TaskPriority Priority = TaskPriority.Medium, // Added later, default keeps old callers working
IReadOnlyList<string>? Labels = null); // Added later, optional
// Bad: Change existing field types or remove fields
public sealed record CreateTaskInput(
string Title,
// string Description, ← Removed — breaks existing JSON callers (missing required field)
int Priority); // ← Changed from enum/string — breaks existing serialized payloads
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 updatewill 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 tostring NewColumn(non-nullable). Safe in one-process deployments. - Add with a default value. Use
.HasDefaultValue(...)inOnModelCreatingor[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.
- Add as nullable, backfill, tighten later. Ship migration A that adds
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);
// Request
// GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
//
// Response
// { "data": [...], "page": 1, "pageSize": 20, "totalItems": 142, "totalPages": 8 }
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:
// Only title changes, everything else preserved
// PATCH /api/tasks/123
// { "title": "Updated title" }
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() { } // Prevent external subclassing
}
// Consumer gets exhaustive pattern matching
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
// Input: what the caller provides
public sealed record CreateTaskInput(string Title, string? Description = null);
// Output: what the system returns (includes server-generated fields)
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();
}
// Compiler enforces the distinction — no more mix-ups
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);
}
// Attach to the type so any DTO using TaskId picks it up automatically:
[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 | stringunion) - 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
CancellationTokenparameters DbContextentities returned directly from an HTTP endpoint
Verification
After designing an API:
- Every endpoint has typed input and output DTOs in
MyApp.Contracts - Error responses follow a single consistent format (ProblemDetails for HTTP)
- Validation happens at system boundaries only (FluentValidation, DataAnnotations, or MediatR pipeline)
- List endpoints support pagination with a server-enforced
PageSizecap - New fields are additive and have safe defaults (backward compatible in JSON)
- Naming follows consistent conventions (camelCase JSON, PascalCase C#)
- Public async methods accept
CancellationToken - XML doc comments / OpenAPI metadata are committed alongside the implementation
- No
DbContextentity types leak across an assembly boundary
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:
internalvspublic, 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#
interfacewith async methods, strongly-typed IDs, and mandatoryCancellationTokenparameters - Error semantics: replaced TypeScript
APIErrorshape with ASP.NET Core RFC 7807 ProblemDetails conventions; added guidance on libraries choosing between exceptions andResult<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.Jsondeserializing 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.CamelCaseandJsonStringEnumConverter; type names mention PascalCase explicitly - Pagination example uses a C#
PaginatedResult<T>record and calls out server-sidePageSizecapping - Filtering example adds
[AsParameters]for .NET 8+ Minimal APIs - PATCH example becomes an
UpdateTaskInputrecord with nullable fields and a pointer to JSON Merge Patch / JSON Patch for richer semantics - "Discriminated Unions" rewritten using abstract records with nested
sealedsubtypes + pattern-matchingswitchexpression, with notes on private constructor closing the hierarchy - "Input/Output Separation" example uses C# records and adds the rule against leaking
DbContextentities - "Branded Types" → "Strongly-Typed IDs" as
readonly record structwith EF Core value-converter registration and a pointer toJsonConverter<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-sidePageSizecap - Verification checklist adds
CancellationTokenrequirement andDbContext-entity-leak guard - Preserved verbatim: Hyrum's Law intro quote, five core principles structure, REST resource design conventions, Common Rationalizations table frame
- Hyrum's Law paragraph adds concrete .NET examples:
- 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 todeprecation-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: keepGuidon the wire and use the struct only in domain code, trading edge type-safety for zero serialization ceremony.
- "Prefer Addition Over Modification" — EF Core migration hazard. Added a "Database-side analog" subsection on the non-nullable-column migration failure mode (
- 2026-04-19 (plugin v1.0.3) — Two additions:
- License: MIT © 2025 Addy Osmani — see
../../LICENSES/agent-skills-MIT.txt