IPA-101: Resource-Oriented Design
Resource-oriented design is a pattern for specifying APIs based on several high-level design principles:
- The fundamental building blocks of an API are individually named resources (nouns) and the relationships and hierarchy that exist between those resources
- A small number of standard methods (verbs) provide the semantics
for most common operations
- Custom methods are available in situations where the standard methods do not fit.
- Stateless protocol: Each interaction between the client and the server are independent, and both the client and server have clear roles
Guidance
When designing an API, consider the following (roughly in logical order):
- The resources (nouns) the API will provide
- The relationships and hierarchies between those resources
- The schema of each resource
- The methods (verbs) each resource provides rely as much as possible on the standard verbs
Resources
A resource-oriented API should generally be modeled as a resource hierarchy
- Each node is either a simple resource or a collection of resources
- A collection contains resources of the same type
- A resource usually has fields
paths:
/projects/{projectId}/tasks:
get:
operationId: listTasks
/projects/{projectId}/tasks/{taskId}:
get:
operationId: getTask
/projects/{projectId}/tasks/{taskId}/comments:
get:
operationId: listCommentsWhy:The paths nest the way the data actually nests. A
taskscollection holds individual tasks, and each task owns its owncomments. Because the ownership is right there in the URL, the hierarchy can be walked from a parent to its children without external documentation of how things connect.paths:
/fetchTaskDetails:
get:
operationId: fetchTaskDetails
/doCommentLookup:
get:
operationId: doCommentLookupWhy:These are flat RPC calls. Every path is a verb, not a noun, so nothing indicates what resources exist or how those resources relate. The path from a task to its comments can't be followed, because the structure describes actions instead of the things acted on.
paths:
/orgs/{orgId}/billing/invoices:
get:
operationId: listInvoices
/orgs/{orgId}/billing/invoices/{invoiceId}:
get:
operationId: getInvoice
/orgs/{orgId}/billing/payments:
get:
operationId: listPaymentsWhy:billingis not a resource — it has no identifier, no Get, and no List of its own. It is a static label dropped into the middle of the path to group related sub-paths by category.- The segments no longer alternate between collection nouns and resource IDs, so
the hierarchy breaks:
orgs/{orgId}looks like a resource, thenbillinglooks like a collection, but there is nobilling/{billingId}to follow. - Making it a real collection (e.g.
/orgs/{orgId}/billingAccounts/{billingAccountId}/invoices) or removing it entirely (e.g./orgs/{orgId}/invoices) fixes the hierarchy; a bare category word should not stand in for a resource.
List all entries under
pathsand build a segment tree: split each path on/and nest the segments as nodes. For example,/projects/{projectId}/tasks/{taskId}becomesprojects→{projectId}→tasks→{taskId}.Walk each node in the tree and check the alternating pattern.
- If the segment is a path parameter (
{id}-style), its parent must be a plain collection noun. - If it is a plain word, it must either have a path-parameter child somewhere beneath it in the tree, or have operations of its own.
A plain segment with neither — like
billingin/orgs/{orgId}/billing/invoices— is a bare grouping label with no identity as a resource. Flag it.- If the segment is a path parameter (
For each collection/resource pair in the tree, verify the relationship reflects real ownership or containment: a
taskbelongs to aproject, acommentbelongs to atask. If the nesting is arbitrary or purely organisational, flag it.For each collection node, confirm all its resource children are of the same type. A collection should be homogeneous — mixing resource types under one collection segment is a violation.
Flag any path that is flat, verb-shaped, contains a bare grouping segment, or where the nesting does not reflect a real containment relationship.
Resources may have any number of sub-resources.
The schema for a resource must be the same across all methods related to the resource
paths:
/users:
get:
operationId: listUsers
responses:
"200":
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/User"
post:
operationId: createUser
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/User"
responses:
"201":
content:
application/json:
schema:
$ref: "#/components/schemas/User"
/users/{userId}:
get:
operationId: getUser
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/User"
components:
schemas:
User:
type: object
properties:
id:
type: string
name:
type: string
status:
type: string
createdAt:
type: string
format: date-timeWhy:Every method points at the same
#/components/schemas/User. A field means the same thing, has the same type, and keeps the same name no matter which operation returns it or accepts it. OneUsermodel serves Get, List, and Create.paths:
/users:
get:
operationId: listUsers
responses:
"200":
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/UserListItem"
/users/{userId}:
get:
operationId: getUser
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/UserDetail"
components:
schemas:
UserListItem:
type: object
properties:
userId:
type: string
fullName:
type: string
UserDetail:
type: object
properties:
id:
type: string
name:
type: string
status:
type: stringWhy:List and Get return two different schemas for one resource, even renaming the shared fields:
userId/fullNamehere,id/namethere. One shape must then be mapped onto the other. A resource should be one type, not two.Every field accepted in a request body must also appear in the response body for the same resource
Omitting request fields from responses breaks declarative clients and forces manual reconciliation in downstream tooling.
- The only exceptions are sensitive fields, which may be omitted from every response (write-only) or may appear only in the Create response (create-response-only).
- For the per-method expression of this rule, see Create and Update.
paths:
/users:
post:
operationId: createUser
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/User"
responses:
"201":
content:
application/json:
schema:
$ref: "#/components/schemas/User"
components:
schemas:
User:
type: object
properties:
id:
type: string
readOnly: true
name:
type: string
timezone:
type: stringWhy:nameandtimezoneare accepted on create and returned on read, so a declarative client can confirm the values it submitted and reconcile state without a separate source of truth.paths:
/users:
post:
operationId: createUser
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/UserCreate"
responses:
"201":
content:
application/json:
schema:
$ref: "#/components/schemas/User"
components:
schemas:
UserCreate:
type: object
properties:
name:
type: string
timezone:
type: string
User:
type: object
properties:
id:
type: string
name:
type: stringWhy:timezoneis accepted on create but never appears in any response, and it is not a sensitive field. The client cannot read back the value it set, so a declarative tool sees permanent drift it can never reconcile.Collect the fields accepted across the resource's request bodies (Create and Update).
For each field, confirm it appears in the resource's Get response schema.
Flag any request field absent from the response, unless it is a sensitive field marked write-only or create-response-only.
Resources declared by clients must not be silently removed by the server as a result of time-based expiry, inactivity, or other server-side lifecycle events
- If such a lifecycle transition is part of the resource's domain model, it
must be represented as a terminal status on the resource (e.g.
EXPIRED,REVOKED,REJECTED). - The resource must remain readable through the Get and List methods until the client explicitly deletes it.
components:
schemas:
AccessGrant:
type: object
properties:
id:
type: string
status:
type: string
enum:
- ACTIVE
- EXPIRED
- REVOKEDWhy:When a grant lapses, the server moves
statustoEXPIREDand keeps the resource readable. The client observes the transition through Get and List and deletes the resource on its own terms.components:
schemas:
AccessGrant:
type: object
properties:
id:
type: string
status:
type: string
enum:
- ACTIVEWhy:The model has no terminal status, so an expired grant can only be represented by removal. The resource disappears from Get and List without a client delete, and declarative clients see unexplained drift.
Identify resources with server-side lifecycle transitions such as expiry, inactivity, or automatic cleanup.
Confirm each transition is modeled as a terminal status value rather than removal of the resource.
Confirm the resource stays readable through Get and List until the client deletes it, and flag any resource the server removes on its own.
- If such a lifecycle transition is part of the resource's domain model, it
must be represented as a terminal status on the resource (e.g.
An API should not be expected to reflect the database schema behind it. An API that is identical to the underlying database schema is an antipattern, as it tightly couples the surface to the underlying system
components:
schemas:
User:
type: object
properties:
id:
type: string
name:
type: string
email:
type: string
status:
type: string
enum:
- ACTIVE
- SUSPENDED
createdAt:
type: string
format: date-timeWhy:This schema exposes only what a consumer needs: API-level field names and a
statusenum that means something in the domain. Storage details like join tables, surrogate keys, soft-delete flags, and version counters stay hidden. That lets the persistence layer be reworked without breaking any clients.components:
schemas:
User:
type: object
properties:
_id:
type: string
description: MongoDB ObjectId
full_name:
type: string
email_addr:
type: string
deletedAt:
type: string
format: date-time
description:
Soft-delete timestamp; null if the document has not been deleted
tenantId:
type: string
description: ObjectId reference to the owning tenant document
createdAt:
type: integer
description: Unix epoch millisecondsWhy:This is a MongoDB document copied straight onto the wire.
_idis the internal ObjectId the database assigns;tenantIdis a raw ObjectId reference to another collection.
None of these are domain concepts — these are storage mechanics. Exposing these fields makes the collection layout the API contract, so a schema migration breaks any downstream tooling or clients.
For each resource schema under
components.schemas, list its property names and types.Check the field names. API conventions look like
camelCasedomain names (createdAt). Storage-coupled naming looks like_id,__v,deletedAtused as a soft-delete sentinel, or raw ObjectId references (tenantIdtyped as a string with no domain meaning).Look for fields that only exist to serve persistence: MongoDB internal identifiers (
_id), ORM version keys (__v), soft-delete timestamps (deletedAtused as a null/non-null flag), and raw cross-collection ObjectId references. The presence of such fields marks the schema as a document projection, not a domain contract.Decide whether the exposed fields were chosen for the consumer, or whether every field of a collection document was dumped verbatim.
Report any resource whose schema mirrors a database table. Cite the storage-coupled fields and the naming as evidence.
Methods
A typical resource-oriented API exposes a large number of resources with a small number of methods on each resource and should not be confused with the HTTP methods. The methods here described related to the operations available on a resource.
The methods can be:
The following table illustrates the relationship between resources and the standard methods:
| Standard method | Request | Response |
|---|---|---|
| Create | Contains the future resource | Is the resource |
| Get | None | Is the resource |
| Update | Contains the resource or parts of the resource | Is the current resource |
| Delete | None | None |
| List | None | Are the resources |
A resource must support at minimum Get
paths:
/users/{userId}:
get:
operationId: getUser
responses:
"200":
description: The requested user.
content:
application/json:
schema:
$ref: "#/components/schemas/User"
/projects/{projectId}/tasks/{taskId}:
get:
operationId: getTask
responses:
"200":
description: The requested task.
content:
application/json:
schema:
$ref: "#/components/schemas/Task"Why:Both resources,
userandtask, have an identifier and a Get that returns the resource itself. Get is the cheapest call available for reading a single resource's current state.paths:
/orders:
post:
operationId: createOrder
responses:
"201":
description: The created order.
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
/orders/{orderId}:
delete:
operationId: deleteOrder
responses:
"204":
description: The order was deleted.Why:An
ordercan be created and deleted, but never read back. There is no GET on/orders/{orderId}. So an order's identifier still can't be used to see its current state, inspect the resource, or confirm that a mutation worked. A resource that can't be read is write-only, and that defeats the read-oriented model.A resource must support List except for singleton resources where more than one resource is not possible
paths:
/projects/{projectId}/tasks:
get:
operationId: listTasks
summary: List the tasks in a project
parameters:
- name: projectId
in: path
required: true
schema:
type: string
responses:
"200":
description: A page of tasks.
content:
application/json:
schema:
type: object
properties:
results:
type: array
items:
$ref: "#/components/schemas/Task"
/projects/{projectId}/tasks/{taskId}:
get:
operationId: getTask
parameters:
- name: projectId
in: path
required: true
schema:
type: string
- name: taskId
in: path
required: true
schema:
type: string
responses:
"200":
description: A single task.
content:
application/json:
schema:
$ref: "#/components/schemas/Task"Why:The collection path
/projects/{projectId}/taskshas aGETthat lists the tasks in the project. A project can have many tasks, and thetaskIdvalues usually aren't known up front. List is what surfaces which tasks exist in the first place.paths:
/projects/{projectId}/tasks/{taskId}:
get:
operationId: getTask
parameters:
- name: projectId
in: path
required: true
schema:
type: string
- name: taskId
in: path
required: true
schema:
type: string
responses:
"200":
description: A single task.
content:
application/json:
schema:
$ref: "#/components/schemas/Task"
delete:
operationId: deleteTask
parameters:
- name: projectId
in: path
required: true
schema:
type: string
- name: taskId
in: path
required: true
schema:
type: string
responses:
"204":
description: Task deleted.Why:A project holds many tasks, but the spec only defines item-level operations on
/projects/{projectId}/tasks/{taskId}. There's noGETon the collection path/projects/{projectId}/tasks. So without an already-knowntaskId, there is no way to find out which tasks exist, and the IDs have to be tracked somewhere outside the API.APIs should prefer standard methods over custom methods - Custom methods help define functionality that does not cleanly map to any of the standard methods
paths:
/articles/{articleId}:
patch:
operationId: updateArticle
summary: Update an article
parameters:
- name: articleId
in: path
required: true
schema:
type: string
requestBody:
content:
application/merge-patch+json:
schema:
type: object
properties:
title: { type: string }
status: { type: string }
responses:
"200":
description: The updated article.Why:Editing an article's fields is a partial update of an existing resource, and that is exactly what the standard Update method covers. Written as
PATCH /articles/{articleId}, it carries the idempotency and response behavior clients already expect, plus SDKs and declarative clients recognize it as the resource's Update. A custom verb provides none of that.paths:
/articles/{articleId}:
patch:
operationId: updateArticle
summary: Update an article
parameters:
- name: articleId
in: path
required: true
schema:
type: string
requestBody:
content:
application/merge-patch+json:
schema:
type: object
properties:
title: { type: string }
status: { type: string }
responses:
"200":
description: The updated article.
/articles/{articleId}:publish:
post:
operationId: publishArticle
summary: Publish an article
parameters:
- name: articleId
in: path
required: true
schema:
type: string
responses:
"200":
description: The published article.Why:Publishing is a state transition, not a field edit: it validates the draft, stamps a publish time, and makes the article live. No standard method captures that, so a custom
:publishis justified. The key is that it sits alongside the standardPATCHUpdate, not in place of it — ordinary field edits still go through Update, and the custom method covers only the behavior Update can't express. The escape hatch is used without weakening the standard surface.paths:
/articles/{articleId}:setTitle:
post:
operationId: setArticleTitle
summary: Set the title of an article
parameters:
- name: articleId
in: path
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
type: object
properties:
title: { type: string }
responses:
"200":
description: The updated article.Why:Setting one field is still a partial update, so the standard Update method handles it. A custom
:setTitleverb leads to one custom method per field, and tooling that knows the standard methods can't see what the call does. The custom-method escape hatch should be reserved for behavior that has no standard equivalent.Look at the operation and pin down its shape: the HTTP verb, and whether the path ends in a resource identifier, a collection, or a custom
:verbsuffix.If the path uses a
:verbsuffix (e.g./articles/{articleId}:publish), it's a custom method. Otherwise it already has a standard method shape and complies.For a custom method, read its summary, request body, and response to work out what it does: what state it reads or changes, and on which resource.
Ask whether that intent maps onto a standard method: retrieving one resource (Get), retrieving a collection (List), creating a resource (Create), modifying an existing resource's fields (Update), or removing a resource (Delete).
If the intent fits a standard method, flag it. The standard method should be used instead of the custom one.
The custom method is fine only when no standard method captures the behavior — say, a stateful transition or action that isn't just reading, creating, replacing, patching, or deleting the resource.
Read-Only Resources
Read-only resources are resources that cannot be modified by API consumers.
Read-only resources must have Get and List methods
paths:
/regions:
get:
operationId: listRegions
summary: List regions
responses:
"200":
description: A page of regions.
/regions/{regionId}:
get:
operationId: getRegion
summary: Get a region
parameters:
- name: regionId
in: path
required: true
schema:
type: string
responses:
"200":
description: The requested region.Why:Consumers can't create or change regions, but still need to find out which ones exist (List) and look one up by id (Get). Without both, the resource is there but not really usable.
paths:
/regions:
get:
operationId: listRegions
summary: List regions
responses:
"200":
description: A page of regions.Why:The collection can be listed, but there's no Get on
/regions/{regionId}. So a known region id has no way to fetch just that one region. List alone isn't enough; a read-only resource needs Get too.Read-only resources may have custom methods as appropriate
All response schema properties for read-only resources must be marked as read-only
- In OpenAPI, this means all properties must have
readOnly: true - All fields in read-only resources are server-owned. For guidance on server-owned fields, see IPA-111
components:
schemas:
AuditEvent:
type: object
properties:
id:
type: string
readOnly: true
action:
type: string
readOnly: true
actorId:
type: string
readOnly: true
createdAt:
type: string
format: date-time
readOnly: trueWhy:AuditEventis read-only, so the server owns every field. WithreadOnly: trueon each one, generated clients leave every field out of request payloads. Nothing here is writable, and the schema says so.components:
schemas:
AuditEvent:
type: object
properties:
id:
type: string
readOnly: true
action:
type: string
actorId:
type: string
readOnly: true
createdAt:
type: string
format: date-timeWhy:actionandcreatedAtare missingreadOnly: true, so the schema reads as if those fields are writable. Neither is — the server owns every field on a read-only resource. A missing marker on any field produces fake writable fields in the generated clients and docs.Establish that the resource is genuinely read-only by intent: the server produces and owns all of the resource's state, and consumers supply none of it.
- The structural tell is a resource with Get (and List) but no Create, Update, or Delete.
- If any field is consumer-supplied, the resource is writable — this guideline doesn't apply, and per-field ownership is governed by IPA-111 instead.
Find the Get (and List item) response schema under
components.schemasand follow any$ref,allOf, or composition through to the full property list.Walk every property, including the ones inside nested objects and array items.
Each property must declare
readOnly: true— on a read-only resource every field is server-owned, so none can be writable.Flag any property on a read-only resource that's missing
readOnly: true.
- In OpenAPI, this means all properties must have
Unsupported operations on read-only resources should return
405 Not Allowed- Some declarative-friendly clients require all standard methods to be implemented, but documented unsupported methods are a detriment to generated documentation and code
paths:
/articles/{articleId}:
parameters:
- name: articleId
in: path
required: true
schema:
type: string
get:
operationId: getArticle
responses:
"200":
description: The requested article.Why:The read-only
articledocuments onlyget— the unsupporteddeleteis kept out of the spec entirely, as the rule against documenting unsupported operations requires. The405is a runtime concern, not a documented one: aDELETE /articles/{articleId}call still resolves to405 Not Allowedat the server, marking the method as recognized but not permitted rather than404(resource missing) or403(forbidden). The behavior lives in the implementation while the contract stays clean.paths:
/articles/{articleId}:
parameters:
- name: articleId
in: path
required: true
schema:
type: string
get:
operationId: getArticle
responses:
"200":
description: The requested article.
delete:
operationId: deleteArticle
responses:
"404":
description: Article not found.Why:A
404here muddles two different things: the resource doesn't exist, versus the method isn't allowed on it. A404reads as a missing record, prompting a retry with another id. The method is recognized but disallowed, so the honest code is405.Identify each read-only resource: only Get (and List) are documented, with no Create, Update, or Delete.
In the implementation behind that path (routing or controller code), check what an unsupported mutation —
POST,PUT,PATCH, orDELETE— returns.Flag any unsupported mutation that resolves to anything other than
405 Not Allowed(for example404,403, or a success code).
Unsupported operations on read-only resources must not be documented, so that generated documentation and client code do not surface methods the API does not actually support.
paths:
/articles/{articleId}:
get:
operationId: getArticle
responses:
"200":
description: The requested article.
/articles:
get:
operationId: listArticles
responses:
"200":
description: A page of articles.Why:This read-only
articleresource lists only what it actually serves:getArticleandlistArticles. There are no mutation operations in the spec, so doc generators and SDK builders produce only the methods the API honors. No generated call targets an endpoint the server will reject.paths:
/articles/{articleId}:
get:
operationId: getArticle
responses:
"200":
description: The requested article.
delete:
operationId: deleteArticle
responses:
"405":
description: Articles are read-only and cannot be deleted.
/articles:
get:
operationId: listArticles
responses:
"200":
description: A page of articles.Why:Here
deleteexists only to return405 Not Allowed. That advertises a capability the server doesn't have. Generated docs and SDKs will still expose adeleteArticlemethod, and clients will write code against an operation that always fails.