Software Engineering
REST API Design in ASP.NET Core: A Practical Checklist
A dependable API is not defined by plural nouns and the right attributes. Its contract must remain predictable under retries, concurrent writes, partial failure and independently deployed clients.
Design the protocol your clients must live with
ASP.NET Core makes an endpoint cheap to create. A route, a handler and a serialisable return value are enough to produce working HTTP. The difficult work begins after another team ships a client against it. Route shapes become permanent, defaults acquire meaning, retries duplicate operations and an “obvious” response change breaks a mobile application that cannot be upgraded immediately.
REST is useful when it gives clients a consistent model of resources and standard HTTP behaviour. It is not a test of whether every business verb can be disguised as a noun. An API should make the common operation unsurprising, the exceptional operation explicit and recovery possible without knowledge of its implementation.
Before defining routes, write down the consumers, their release cadence and what they must do after each class of failure. An internal browser deployed with the API can tolerate different evolution constraints from a partner integration or device that remains in the field for years. The contract includes behaviour, ordering, timing assumptions and error semantics-not only the JSON schema.
The checklist at a glance
| Area | Question that exposes weak design |
|---|---|
| Resources | Does the URI identify a stable concept, or mirror today's database and service layout? |
| Methods | What happens when this request is repeated after the response is lost? |
| Responses | Can a client distinguish correction, retry, conflict and permanent denial? |
| Concurrency | How is a stale write detected instead of silently overwriting newer data? |
| Collections | Is ordering deterministic while records are inserted and deleted? |
| Security | Is access checked against this resource and tenant, not merely a valid token? |
| Evolution | Can old and new clients overlap during deployment? |
| Operations | Are limits, correlation, cancellation and dependency failures part of the contract? |
Model resources around consumer meaning
Use URIs for concepts clients recognise: /orders/{orderId}, not /order-service/v2/order-table/{rowId}. Service names, database tables and deployment boundaries change more often than business concepts. A route that leaks them turns internal restructuring into an external breaking change.
Nested routes are useful when the parent supplies essential identity or scope, such as /accounts/{accountId}/orders. Deep nesting is usually a warning. /organisations/{x}/regions/{y}/accounts/{z}/orders/{n}/lines/{m} forces every client to know a hierarchy that may not be stable. If an order line has an independent identity, a flatter canonical URI plus filters or links is often clearer.
Do not create one endpoint per screen. Screens change and compose data differently; resources should remain coherent across clients. Equally, do not make clients perform twenty calls to reconstruct one common business view. A purpose-built read model or query endpoint can be honest REST without pretending it is a stored entity.
Commands that do not fit CRUD deserve explicit treatment. Cancelling an order is a state transition with rules, audit significance and possible side effects. POST /orders/{id}/cancellation or POST /orders/{id}:cancel is clearer than a partial update whose magic status value triggers a workflow. Consistency and documented semantics matter more than winning an argument about perfectly noun-shaped routes.
HTTP methods describe retry semantics, not controller names
GET and HEAD should be safe: calling them must not create an order, mark a notification read or advance a workflow. Analytics and cache bookkeeping are incidental effects; business state changes are not. Crawlers, prefetchers and retries all assume safe methods are safe.
PUT replaces the state at a known URI and is idempotent: repeating the same request should produce the same intended state. PATCH applies a partial change, but its idempotency depends on the patch operation. “Set quantity to five” can be idempotent; “increase quantity by one” is not. DELETE is idempotent in intended effect even if the first call returns 204 and a later call returns 404.
POST is not inherently unreliable; it simply makes no general idempotency promise. For operations that clients must safely retry-payments, bookings, submission of long-running jobs-accept an idempotency key. Store the key with a fingerprint of the request and the resulting outcome. Reusing a key with different input should be rejected, not interpreted as another operation.
The difficult case is a lost response after a successful write. A client cannot know whether retrying will duplicate work. An idempotency key turns that transport ambiguity into a lookup. It must be scoped to the caller, retained for a documented period and committed atomically with the operation or an authoritative record of it.
Status codes should tell clients what to do next
A narrow, consistent status vocabulary is better than displaying knowledge of every HTTP code. Use 200 for a representation, 201 when a resource was created and identify it with Location, 202 when work has been accepted but is not complete, and 204 for a successful response with no body. A 202 response needs a status resource or callback mechanism; otherwise it merely hides an unknown outcome.
Keep failure classes distinct. A 400-class validation response means the client can change the request. A 401 is an authentication challenge; 403 means the established caller lacks authority. A 404 may deliberately hide a protected resource. A 409 describes conflict with current state, including concurrency or uniqueness. A 429 means the caller should reduce its rate, ideally with usable retry information.
Do not return 200 with { "success": false }. It defeats HTTP-aware retries, monitoring, caching and generated clients. Do not convert dependency outages into validation failures. A client should not spend hours correcting an address because the postcode service timed out.
Problem Details supplies a standard error envelope, not the error taxonomy. Add stable machine-readable codes, safe messages and a correlation identifier. Field paths help forms, but clients should branch on codes rather than English wording. Exception text, stack traces, SQL constraint names and sensitive submitted values belong nowhere in a public response.
Validation, authorisation and conflict are separate decisions
Model binding answers whether the representation can become an input model. Request validation checks whether that command is coherent. Domain logic protects rules across every entry point. Authorisation asks whether this subject may perform this action on this resource. Persistence settles uniqueness and concurrent writes. Combining these into a single “validate” step creates the wrong status codes and often leaks protected state.
Authorise resource access before returning detailed state-dependent failures. “Invoice is already paid” reveals that the invoice exists. Multi-tenant APIs must scope both lookup and policy evaluation to the active tenant; accepting a tenant identifier in a route or body is not proof of membership.
A uniqueness pre-check improves the normal error message but cannot prevent two simultaneous requests passing it. Back the rule with a database constraint and translate its specific violation. Likewise, use an ETag, version field or other concurrency token to reject stale updates. Last-write-wins is a concurrency policy, not the absence of one, and it is dangerous when users edit valuable business records.
Pagination without stable ordering loses and duplicates records
Skip/Take or offset/limit pagination is simple and useful for small, relatively static result sets. It becomes slower at deep offsets and unstable under concurrent changes. Insertions before the current offset can duplicate an item on the next page; deletions can cause an item to be skipped.
Cursor or keyset pagination continues after the last ordered value. It scales better and behaves more predictably under inserts, but it requires a deterministic unique order. Ordering by creation time alone is insufficient when two records share a timestamp; add a stable tie-breaker such as the identifier. The cursor should be opaque so its encoding can evolve.
Filters and ordering are part of the contract. State whether comparisons are case-sensitive, which time zone timestamps use, and whether the view is a live traversal or a snapshot. Returning a total count can be expensive or misleading under concurrent change; include it only when the product needs it and the data store can provide it affordably.
Never allow an unbounded collection response. Set a default and maximum page size. A maximum protects memory, database load, response time and downstream cost; it is an operational control as much as an API design preference.
Caching starts with representation identity
HTTP caching is safer when the API can say what representation a response describes and when it changes. ETags support conditional reads with If-None-Match and can also protect writes with If-Match. These are related uses: one saves transfer, the other prevents stale modification.
Be careful with authenticated and tenant-specific responses. A shared cache must vary on every dimension that changes the representation, or those responses should be private or not stored. Changing a cache header is easier than proving a cross-tenant data leak cannot occur. Do not cache errors or personalised responses by accident at a reverse proxy.
Cacheability also depends on method semantics. An endpoint labelled GET but performing a command may be replayed or served from cache at exactly the wrong time. Correct HTTP behaviour is not aesthetic; infrastructure relies on it.
Rate-limit expensive work, not merely request counts
One export request may cost more than a thousand lookups. Partition limits by authenticated client or tenant where appropriate, and choose a limiter that matches the pressure: request rate, burst capacity or concurrent expensive operations. IP-only partitioning behaves poorly behind shared networks and can create unbounded partitions if arbitrary input creates limiter state.
Return 429 with practical retry guidance, but protect dependencies with their own concurrency and timeout controls. A gateway request limit does not stop ten accepted requests from exhausting a database connection pool. Load-test the limiter and observe rejected, queued and completed work; a queue can turn overload into timeouts rather than solving it.
OpenAPI describes the contract; it does not design it
ASP.NET Core can generate OpenAPI documents from Minimal APIs and controller-based APIs, including build-time generation in current versions. Generation is valuable, but annotations reflect the code they can see. They do not automatically document idempotency behaviour, cursor stability, eventual consistency, authorisation rules or which error codes a client must handle.
Give operations stable identifiers, describe every meaningful response and include representative examples. Generate the document in CI and compare it with the approved contract. A schema diff can catch removed properties and changed types, but behavioural compatibility still needs review and consumer tests.
Generated clients amplify both consistency and mistakes. If each endpoint invents a different pagination or error envelope, the generated SDK merely makes the inconsistency strongly typed. Design common wire conventions centrally while keeping business behaviour specific to each resource.
Compatibility is broader than URL versioning
Additive JSON changes are often safe for tolerant clients, but required properties, enum values and defaults need care. Adding an enum member can break a client that exhaustively switches over known values. Changing omitted data to explicit null, altering sort order or tightening validation can break behaviour without changing a schema.
Version when a real behavioural contract must diverge, not whenever code changes. URI, header and media-type versioning all have trade-offs; none removes the need to run versions in parallel, measure use and publish a migration path. Prefer one implementation with compatibility seams over copying the entire API into permanent v1 and v2 codebases.
Deployments create a versioning window even without a public version number. Old application instances, cached clients and queued requests overlap new code. Database changes should be expand-and-contract, and new servers should accept the previous client contract until the fleet and consumers have moved.
Observability must follow the API operation
Log the operation name, authenticated subject identifier where appropriate, tenant, status, duration, trace identifier and stable error code. Do not log credentials, tokens or complete bodies. Route templates are better metric dimensions than raw paths; using order identifiers as labels creates unbounded cardinality.
Propagate trace context to dependencies and separate client failures from service failures in dashboards. A rise in 400 responses may indicate a bad client release; a rise in 409 may reveal contention; a rise in 499-style client cancellation or aborted requests may show latency users no longer tolerate. One generic “API error rate” hides all three.
Cancellation should flow from the HTTP request to database and downstream calls, but reaching cancellation does not undo a committed side effect. For long-running operations, return a durable job resource rather than holding a connection open and hoping both sides agree about the outcome.
The production review checklist
- Resource names express consumer concepts rather than database or service topology.
- Safe and idempotent methods retain their HTTP meaning.
- Retry-sensitive POST operations support scoped, durable idempotency keys.
- Status codes and Problem Details tell clients whether to correct, retry, authenticate or reconcile.
- Resource and tenant authorisation is evaluated independently of input validation.
- Database constraints and concurrency tokens settle races.
- Every collection has deterministic ordering and a bounded page size.
- Cursors, filters, timestamps and null behaviour have documented semantics.
- Caches cannot mix authenticated or tenant-specific representations.
- Rate limits reflect expensive resources and are verified under load.
- OpenAPI is generated and checked, with behavioural rules documented alongside it.
- Old clients and old server instances can overlap new deployments safely.
- Telemetry uses route templates and stable error codes without leaking sensitive data.
The strongest API is not the most theoretically RESTful. It is the one a client can use correctly during retries, partial outages, concurrent change and a server deployment the client team did not know was happening.
Related C3 Software guides
Frequently asked questions
Should REST API routes always use nouns?
Use stable resource concepts as the default, but do not hide important workflows behind magic field updates. An explicit command or subordinate action resource is clearer when the operation has distinct rules and side effects.
When should an ASP.NET Core API use an idempotency key?
Use one when a non-idempotent operation may be retried after an uncertain outcome and duplication would be harmful, such as payment, booking or job submission.
Should pagination use offsets or cursors?
Offsets are convenient for small, stable sets and page-number navigation. Use cursor or keyset pagination for large or frequently changing sets where deep-query performance and stable traversal matter.
Is generated OpenAPI documentation enough?
No. It describes observable shapes and endpoint metadata, but behavioural rules such as idempotency retention, ordering, eventual consistency and error recovery still require deliberate documentation and tests.
Design an API clients can depend on
C3 Software Limited has built and supported business software in the UK since 2001. If an API must serve independently deployed applications or external partners, talk to C3 Software about a contract review or implementation plan.