Designing Resources and Endpoints
The v1 resources and endpoints, derived from business capabilities rather than tables.
You are viewing a free preview lesson.
In the previous lesson, we studied HTTP as a behavioural contract. Now we use that foundation to design the actual HTTP API of our Order Management Backend. Its main capabilities are Product, Inventory, Order and Payment. Customer identity matters too, but with a critical distinction:
This backend does not own Customer account lifecycle.
So we should not create CRUD resources such as /products, /customers, /orders and /payments simply because those nouns appear in the business domain. Instead, we ask which state this application owns, which behaviours it exposes, which resources clients can legitimately interact with and which operations are internal implementation details. The goal of this lesson is:
Design clear and consistent HTTP resources from actual business capabilities without exposing internal implementation details or inventing subsystems that the application does not own.
Start From Capabilities, Not Database Tables
A common API-design mistake maps tables to resources: products becomes /products, inventory becomes /inventory, orders becomes /orders and order_items becomes /order-items. This assumes every table deserves a public HTTP resource, and that is not true.
Consider OrderItem. OrderItems will be persisted as separate rows, but Customers never create, update or delete an OrderItem on its own. An OrderItem belongs to its Order, and the Order owns its lifecycle. So we do not need a top-level /api/v1/order-items CRUD API. Persistence structure does not determine public API structure.
Start From Actual User Operations
The confirmed operations drive the API:
| Customer | Admin |
|---|---|
| Browse orderable Products | Create Product |
| Create an Order | View Product information |
| View own Order history | Update Product |
| View own specific Order | Deactivate Product |
| Cancel own eligible Order | View Inventory |
| Pay own eligible Order | Set current Inventory quantity |
| View Orders |
Initial Resource Model
The primary v1 resources are /api/v1/products, /api/v1/inventory and /api/v1/orders. Payment is currently an operation against an existing Order, and Customer identity comes from authentication. Therefore v1 intentionally exposes neither /api/v1/customers nor a generic /api/v1/payments CRUD resource.
No Customer CRUD API
Earlier generic planning might have suggested POST /api/v1/customers and GET /api/v1/customers/{customerId}. Our architecture has since established something stronger: Customer authentication and identity lifecycle are externally owned. This application does not own registration, passwords, account creation, profile lifecycle or Customer account persistence. Having no Customer CRUD API is therefore the correct v1 design. It is not a missing feature; it is an intentional system boundary.
For Customer-facing operations, authentication supplies the identity: the request is authenticated, Spring Security yields the CustomerId, and the handler passes it to the use case. POST /api/v1/orders therefore does not accept {"customerId": "someone-else"}. Ownership comes from the authenticated identity rather than from request input, which keeps it server-controlled.
Products
Product is application-owned state, so /api/v1/products is a natural resource. It serves two contexts, Customer browsing and Admin management, which share the same Product vocabulary while authorization and response semantics fit the caller.
Browse Products
GET /api/v1/products is a read, so GET fits naturally. For Customer browsing it means "return the Products currently exposed for ordering", and our accepted visibility rule is that the Product is active and its Inventory has an availableQuantity greater than 0.
The endpoint does not mean SELECT * FROM products. It returns the Product collection defined by the API contract: internally, the query may combine Product and Inventory and filter before paginating. The HTTP resource is not a direct reflection of one database table.
Product browsing is bounded, as in GET /api/v1/products?page=0&size=20, with a deterministic order (name, then id). We do not design "return every Product forever", even while the development database is small; 5.08 defines the pagination contract for every collection.
Get One Product
GET /api/v1/products/{productId} reads one Product, for example GET /api/v1/products/59ba787c-6d83-4a21-9bae-19c3d0469bd5. At the HTTP boundary productId is a UUID string, and the handler parses it into a UUID and then a ProductId before entering the application layer.
Customer-facing visibility follows the Product API contract rather than exposing whatever row exists in the database, while Admin access may show management state according to authorization requirements. The repository implementation must not accidentally define public visibility rules.
Create Product
The Admin operation "Create Product" is POST /api/v1/products:
POST /api/v1/products
Content-Type: application/json
{
"name": "Mechanical Keyboard",
"priceCents": 10000
}
The client controls name and priceCents (minor units, never a floating-point price). The server controls the rest: a new Product is created active, with an application-generated UUID-backed ProductId. Successful creation returns 201 Created with the created Product.
Creating a Product does not create Inventory, neither with availableQuantity 0, nor 100, nor any other invented default. Product and Inventory are separate capabilities, and our established model says that Product creation does not create Inventory. If Inventory needs to exist, the Admin manages it explicitly through the Inventory capability.
Update Product
An Admin can partially update name and priceCents with PATCH /api/v1/products/{productId}, for example {"name": "Mechanical Keyboard Pro", "priceCents": 12000}. This maps to product.rename(...) and product.changePrice(...), and the contract is partial modification, not full replacement. Our accepted PATCH semantics are:
- A field that is omitted leaves the current value unchanged.
- A field that is explicitly
nullalso leaves it unchanged. {}is an accepted no-op.
active is not a patch field. Neither {"active": false} nor, especially, {"active": true} is generic Product update semantics. Our domain deliberately exposes product.deactivate() and no product.activate() in v1, and the API preserves that lifecycle decision: lifecycle changes use explicit operations.
Deactivation, Not Deletion
Deactivation is POST /api/v1/products/{productId}/deactivate. Conceptually, ProductHandler calls DeactivateProductUseCase, which calls product.deactivate(). The endpoint says "perform the Product deactivation business operation", which is clearer than allowing arbitrary active-state assignment.
Why not DELETE /api/v1/products/{productId}? Because our business operation is deactivation, not physical deletion. DELETE could imply that the Product ceases to exist, yet existing Orders keep referencing its ProductId and historical Order data must stay meaningful.
Current requirements also do not include reactivation, so we do not introduce POST /api/v1/products/{productId}/activate merely because deactivation exists. The API models confirmed behaviour; future requirements can extend it deliberately.
Inventory
Inventory is application-owned state, and its identity is the ProductId: there is no InventoryId in our domain. Admins view Inventory and set its available quantity. Customers do not manage Inventory directly; Order workflows change it internally.
Get One Inventory and the Listing
GET /api/v1/inventory/{productId} returns the one Inventory state for a Product:
{
"productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
"availableQuantity": 15
}
If no Inventory state exists for that Product, 404 is appropriate. Missing Inventory and Inventory with quantity 0 are different states.
A URI such as GET /api/v1/products/{productId}/inventory would also be defensible. We have chosen /api/v1/inventory as a first-class capability, so the canonical convention for this course is GET /api/v1/inventory/{productId}. Use one convention consistently rather than offering several interchangeable designs.
The Admin collection endpoint is GET /api/v1/inventory, ordered by productId ASC and bounded like every collection. Inventory listing is an Admin management capability; Customers see orderable Products through the Product browsing API instead.
Set Inventory
The accepted v1 Admin operation is:
Set the exact current available quantity.
PUT /api/v1/inventory/{productId}
Content-Type: application/json
{
"availableQuantity": 20
}
InventoryHandler calls SetInventoryUseCase. The Product must exist, it may be active or inactive, and availableQuantity must be 0 or more. As the previous lesson showed, "make the current quantity 20" leaves 20 however often it is repeated, so the operation is naturally idempotent in effect and PUT fits.
Avoid an increment endpoint such as POST /api/v1/inventory/{productId}/add with {"amount": 20} unless a real requirement asks for delta-based adjustments. Retries could take the quantity from 10 to 30 to 50. Our requirement is simpler, set the exact current quantity, and that is easier to reason about and safer to retry.
Order Workflows Do Not Call the Public Inventory API
When Create Order consumes Inventory, the application does not send itself PUT /api/v1/inventory/{productId}: CreateOrderUseCase works through InventoryRepository and its atomic consumeIfAvailable(...) (ADR-005). The public Inventory API is for Admin interaction.
Orders
Order is the central Customer-owned resource, with the collection /api/v1/orders. The HTTP method determines the operation.
Create Order
POST /api/v1/orders
Content-Type: application/json
{
"items": [
{
"productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
"quantity": 2
},
{
"productId": "364ec915-01ab-4cf0-b19f-327ed160f42b",
"quantity": 1
}
]
}
The client controls only the ProductIds and quantities. The authenticated context supplies the CustomerId, and the server determines the OrderId, the authoritative unitPriceCents, the UNPAID status, createdAt and the derived totalCents, as the previous lesson laid out. Successful creation returns 201 Created.
Create Order is one business operation. OrderHandler calls CreateOrderUseCase, which coordinates the whole workflow:
- Reject duplicate requested
ProductIds. - Load the Products and verify that they exist and are active.
- Capture each authoritative
priceCentsand construct the OrderItems. - Consume Inventory atomically.
- Generate the
OrderId, obtaincreatedAtand construct theUNPAIDOrder. - Persist the Order and its OrderItems and commit atomically.
The API exposes one operation, "create this Order", not the steps: if any requested Product cannot be consumed, the whole creation rolls back, so the client sees one atomic business operation. Avoid exposing internal steps as endpoints:
POST /api/v1/orders/check-products
POST /api/v1/orders/check-inventory
POST /api/v1/orders/calculate-total
POST /api/v1/orders/save
These implementation steps would make all-or-nothing creation much harder to preserve; the server owns the complete workflow.
Order History
GET /api/v1/orders means, for an authenticated Customer, "return the Orders belonging to my CustomerId". We do not need GET /api/v1/customers/{customerId}/orders, because a normal Customer should not choose whose history to query, and we never accept GET /api/v1/orders?customerId=someone-else: ownership is derived by the server. History is paginated like every collection, newest first (createdAt DESC, then OrderId DESC).
Get One Order
GET /api/v1/orders/{orderId} means, for a Customer, "return this Order if it belongs to me". The application uses ownership-aware access such as findOwnedById(orderId, customerId) rather than loading an arbitrary Order and exposing it by accident, and someone else's Order gets the same 404 ORDER_NOT_FOUND as a missing one (5.05).
Admin Order Viewing
The confirmed Admin scope includes viewing all Orders, while the Customer scope is their own Orders. The Customer routes must answer 404 for another Customer's Order (8.03), and one method and path carries one security rule, so Admin reads get a path of their own when they are built: module 14 adds GET /api/v1/admin/orders/{orderId} for Support.
Cancel Order
Cancellation is a business transition. We do not allow PATCH /api/v1/orders/{orderId} with {"status": "CANCELLED"}, because it makes lifecycle state appear client-controlled. The canonical endpoint is POST /api/v1/orders/{orderId}/cancel: OrderHandler calls CancelOrderUseCase, which calls order.cancel().
Cancellation is more than one field change: it validates the lifecycle and restores Inventory in one transaction (7.11–7.12), and the explicit command endpoint communicates that.
Cancellation looks naturally idempotent, since the final state is CANCELLED, but our domain deliberately rejects CANCELLED → CANCELLED because Inventory must be restored exactly once. So the API does not promise that a repeated cancellation always succeeds identically. Without a deliberate retry mechanism, a repeated request after a successful cancellation may return 409 with ORDER_NOT_CANCELLABLE, while the persisted state remains correctly cancelled.
Pay Order
Payment is another explicit Order operation. POST /api/v1/orders/{orderId}/pay means "attempt Payment for this Customer-owned Order", not "set the status to PAID": OrderHandler calls PayOrderUseCase, which goes through PaymentService to the external provider and applies order.markPaid() only after confirmed success (Module 9 designs that workflow). A PATCH with {"status": "PAID"} would bypass all of it.
Payment may one day expose its own concepts (attempts, history, provider references), and then a Payment resource may become appropriate. Today we add no speculative POST /api/v1/payments. The provider may also need input such as a payment method token, but that is not established yet, so the pay request DTO waits for the Payment contract.
Current Canonical API Surface
Our v1 API is:
PRODUCTS
GET /api/v1/products
GET /api/v1/products/{productId}
POST /api/v1/products
PATCH /api/v1/products/{productId}
POST /api/v1/products/{productId}/deactivate
INVENTORY
GET /api/v1/inventory
GET /api/v1/inventory/{productId}
PUT /api/v1/inventory/{productId}
ORDERS
POST /api/v1/orders
GET /api/v1/orders
GET /api/v1/orders/{orderId}
POST /api/v1/orders/{orderId}/cancel
POST /api/v1/orders/{orderId}/pay
Intentionally, there is no /api/v1/customers CRUD, no /api/v1/order-items CRUD and no generic /api/v1/payments CRUD.
Authorization Matrix
| Endpoint | CUSTOMER | ADMIN |
|---|---|---|
GET /api/v1/products | Yes | Yes |
GET /api/v1/products/{productId} | Yes, according to Customer visibility | Yes |
POST /api/v1/products | No | Yes |
PATCH /api/v1/products/{productId} | No | Yes |
POST /api/v1/products/{productId}/deactivate | No | Yes |
GET /api/v1/inventory | No | Yes |
GET /api/v1/inventory/{productId} | No | Yes |
PUT /api/v1/inventory/{productId} | No | Yes |
POST /api/v1/orders | Yes | Not a normal Admin operation |
GET /api/v1/orders | Own Orders | Admin reads use /api/v1/admin/orders |
GET /api/v1/orders/{orderId} | Own Order | Admin reads use /api/v1/admin/orders/{orderId} |
POST /api/v1/orders/{orderId}/cancel | Own eligible Order | Not in confirmed Admin scope |
POST /api/v1/orders/{orderId}/pay | Own eligible Order | Not in confirmed Admin scope |
We do not invent Admin cancellation or Payment authority because Admins exist: Admins manage Products and Inventory and view Orders. Roles also need not appear in URIs. Different roles alone do not justify /api/v1/admin/products or /api/v1/customer/orders; the resources stay /products, /inventory and /orders while Spring Security and use cases enforce access, and separate Admin endpoints come only when a requirement needs a different contract or a different rule on the same path, as Admin Order viewing does (above).
OrderItem Is Part of the Order
An Order response contains its items:
{
"id": "d2e7b44e-463f-4bbc-b16f-b1c80bc80442",
"status": "UNPAID",
"items": [
{
"productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
"quantity": 2,
"unitPriceCents": 2000,
"totalCents": 4000
}
],
"totalCents": 4000
}
That does not imply GET /api/v1/order-items/{orderItemId}; OrderItems are part of the Order representation. Our Order is also not a cart, so v1 exposes no POST /api/v1/orders/{orderId}/items, PATCH /api/v1/orders/{orderId}/items/{itemId} or DELETE /api/v1/orders/{orderId}/items/{itemId}: Order composition is immutable after successful creation.
Two more mutation endpoints stay out for the same reason. PUT /api/v1/orders/{orderId}/total has nothing to change, because the server derives totalCents from the immutable OrderItems. A PATCH /api/v1/orders/{orderId} with {"customerId": "another-customer"} has no workflow behind it: ownership is established from the authenticated identity at creation, and v1 has no ownership transfer.
Resource IDs and the Location Header
ProductId and OrderId are application-generated UUIDs, represented over HTTP as strings such as {"id": "59ba787c-6d83-4a21-9bae-19c3d0469bd5"}. Clients do not need to know the internal JPA representation, but the API contract does deliberately expose UUID-string identifiers.
A successful creation may also identify the new resource with a Location header:
HTTP/1.1 201 Created
Location: /api/v1/orders/d2e7b44e-463f-4bbc-b16f-b1c80bc80442
Whether every creation handler sets Location consistently is an implementation-contract decision we can finalize while building the HTTP layer.
Request Bodies and Responses
Each body we have seen (Create Product, Update Product, Set Inventory, Create Order) contains only values the caller may legitimately control, in an endpoint-specific request model rather than one giant shared one (5.04 designs them). Commands such as deactivate and cancel need no body when the resource and authenticated identity provide everything; we do not send {} merely because the method is POST.
For a successful cancel or pay, the client benefits from seeing the new status immediately, so both return 200 with the updated Order. Raw provider fields such as rawGatewayCode never appear in responses: provider protocol stays behind PaymentService.
Each resource has one handler (ProductHandler, InventoryHandler, OrderHandler), and each operation reaches its own use case: POST /api/v1/orders reaches CreateOrderUseCase, the cancel and pay commands reach CancelOrderUseCase and PayOrderUseCase, and PUT /api/v1/inventory/{productId} reaches SetInventoryUseCase. The HTTP API and the application operations tell the same story.
Endpoint Naming and Nesting
Use nouns for normal resources (/products, /inventory, /orders). Use explicit action suffixes when a meaningful business command does not map cleanly to generic field replacement: /products/{productId}/deactivate, /orders/{orderId}/cancel, /orders/{orderId}/pay. Avoid redundant names such as /getProducts, /createProduct or /getOrder; the HTTP method already communicates those standard operations.
Avoid deep nesting such as /customers/{customerId}/orders/{orderId}/items/{itemId}: Customer scope comes from authentication and OrderItems are not independently managed, so /api/v1/orders/{orderId} is enough. The URI hierarchy also need not reproduce foreign keys such as orders.customer_id; HTTP resources are an external product model, not a picture of database relationships. Endpoints generated mechanically from tables (POST /order-items, PATCH /inventory-row, POST /customers) reflect no confirmed behaviour; ours model what Customers and Admins may actually do.
Endpoint Error Semantics
Design each endpoint with its failure cases, not only its happy path:
| Endpoint | Expected failures |
|---|---|
POST /api/v1/orders | Invalid request, duplicate Product, Product not found, Product inactive or not orderable, insufficient Inventory |
POST /api/v1/orders/{orderId}/cancel | Order not found or not owned, Order already PAID, Order already CANCELLED |
POST /api/v1/orders/{orderId}/pay | Order not found or not owned, Order already PAID, Order CANCELLED, Payment operation failure |
Lessons 5.05 and 5.07 map each of these to a status and a stable error code.
Practice Exercise 1: Design the Endpoint
Solutions: lesson 5.13.
For each request from the team, name the v1 endpoint that serves it, or say why v1 has none:
- An Admin wants to make a Product free for a promotion.
- An Admin has received 20 more units of a Product whose Inventory shows 15.
- A Customer wants to see the lines of one of their Orders.
- Support asks to bring a deactivated Product back.
- A Customer wants to remove one line from an
UNPAIDOrder. - The frontend wants the Customer's display name for the page header.
Practice Exercise 2: Find the Problems
A teammate's draft contains these endpoints. For each, name the problem and the endpoint our design uses instead, if any:
GET /api/v1/customers/{customerId}/ordersPOST /api/v1/inventory/{productId}/addwith{"amount": 20}PATCH /api/v1/products/{productId}with{"active": true}PUT /api/v1/inventory/{productId}with{"productId": "364ec915-01ab-4cf0-b19f-327ed160f42b", "availableQuantity": 5}POST /api/v1/orders/check-inventoryGET /api/v1/order-items/{orderItemId}
Practice Exercise 3: Which Endpoints Can Your Project Serve?
Each endpoint reaches one use case. List the use cases your project already has:
find src/main/java -name '*UseCase.java'
For each of the 13 endpoints in the canonical surface above, name the handler and the use case that will serve it, and mark the ones your project could serve today.
Knowledge Check
Question 1
OrderItems are stored as rows of their own. Why is there still no /api/v1/order-items resource?
Question 2
GET /api/v1/inventory/{productId} can answer 404 or 200 with "availableQuantity": 0. What is the difference for the client?
Question 3
Why does Create Order not expose its steps, such as checking Inventory or calculating the total, as endpoints of their own?
Question 4
The final state after cancelling twice is CANCELLED either way. Why may the second request still answer 409?
Question 5
Admin Product management shares /api/v1/products with Customers, but Admin Order reads get /api/v1/admin/orders/{orderId}. Why the difference?
Knowledge Check Answers
Answer 1
Customers never create, change or delete an OrderItem on its own: it belongs to its Order, which owns its lifecycle. Public resources come from what clients can do, not from tables.
Answer 2
404 means no Inventory state exists for that Product; 0 means the Inventory exists and is empty. They are different states, and an Admin acts differently on each.
Answer 3
Creation is one atomic business operation: it either commits the Order and consumes the Inventory, or changes nothing. Separate steps would let clients run part of the workflow, and the server could no longer keep it all-or-nothing.
Answer 4
The domain rejects CANCELLED → CANCELLED so that Inventory is restored exactly once. The API therefore does not promise an identical answer for a repeated cancellation, even though the stored state is correct.
Answer 5
One method and path carries one security rule. The Admin Product operations are methods only Admins may call, so a role rule on each is enough. The Customer GET /api/v1/orders/{orderId} must hide other Customers' Orders behind 404, while an Admin may read any Order: two rules for one method and path, so Admin reads get a path of their own.
Engineering Principle
Expose the resources and operations the application actually owns, not every noun, database table or Java class in the implementation.
Use normal HTTP resource operations for state that clients legitimately manage, and explicit command endpoints for meaningful business transitions. A small coherent API is better than exposing every internal capability mechanically.
Summary
- API resources come from owned business capabilities, not database tables: Product, Inventory and Order are the v1 resources, while Customer accounts are externally owned, OrderItem belongs to Order and Payment is an Order operation, so none of them gets CRUD.
- The
CustomerIdalways comes from authentication, never from a body, path or query parameter; Customer Order access is ownership-scoped, and missing and non-owned Orders both return404 / ORDER_NOT_FOUND. - Products: bounded, deterministic browsing; creation that starts active and creates no Inventory; PATCH where omitted and
nullfields change nothing; deactivation instead of deletion, and no reactivation. - Inventory is identified by
ProductId; missing and zero Inventory differ; Admins set exact quantities with an idempotentPUT, and Order workflows useconsumeIfAvailable(...), not the public API. - Create Order is one atomic workflow behind
POST /api/v1/orderswith server-owned IDs, prices, status, time and total; history is paginated bycreatedAt DESC, OrderId DESC. - Cancellation and payment are explicit commands; repeated cancellation is not promised to be idempotent, provider input is deferred, and raw provider DTOs never leak.
- Endpoint-specific DTOs, bodiless commands, UUID-string IDs and bounded collections keep the contract precise.
- Endpoints align with handlers and use cases, avoid role prefixes, deep nesting and foreign-key mirroring, design failures up front, and keep v1 small.
In the next lesson, Controllers in Spring Boot, we turn this contract into Spring Boot handlers with @RestController, request mappings, @PathVariable, @RequestBody, response types and constructor-injected use cases, while keeping the HTTP layer thin and preserving our handler → use case → repository architecture.
Think first
Coming soonCheckpoint questions that ask you to commit to an answer before the lesson shows it.