Lesson 40 of 159
Written lesson · 18 min

Designing Resources and Endpoints

The v1 resources and endpoints, derived from business capabilities rather than tables.

Preview

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:

CustomerAdmin
Browse orderable ProductsCreate Product
Create an OrderView Product information
View own Order historyUpdate Product
View own specific OrderDeactivate Product
Cancel own eligible OrderView Inventory
Pay own eligible OrderSet 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:

http
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 null also 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:

json
{
  "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.

http
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

http
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:

  1. Reject duplicate requested ProductIds.
  2. Load the Products and verify that they exist and are active.
  3. Capture each authoritative priceCents and construct the OrderItems.
  4. Consume Inventory atomically.
  5. Generate the OrderId, obtain createdAt and construct the UNPAID Order.
  6. 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:

http
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:

text
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

EndpointCUSTOMERADMIN
GET /api/v1/productsYesYes
GET /api/v1/products/{productId}Yes, according to Customer visibilityYes
POST /api/v1/productsNoYes
PATCH /api/v1/products/{productId}NoYes
POST /api/v1/products/{productId}/deactivateNoYes
GET /api/v1/inventoryNoYes
GET /api/v1/inventory/{productId}NoYes
PUT /api/v1/inventory/{productId}NoYes
POST /api/v1/ordersYesNot a normal Admin operation
GET /api/v1/ordersOwn OrdersAdmin reads use /api/v1/admin/orders
GET /api/v1/orders/{orderId}Own OrderAdmin reads use /api/v1/admin/orders/{orderId}
POST /api/v1/orders/{orderId}/cancelOwn eligible OrderNot in confirmed Admin scope
POST /api/v1/orders/{orderId}/payOwn eligible OrderNot 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:

json
{
  "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
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:

EndpointExpected failures
POST /api/v1/ordersInvalid request, duplicate Product, Product not found, Product inactive or not orderable, insufficient Inventory
POST /api/v1/orders/{orderId}/cancelOrder not found or not owned, Order already PAID, Order already CANCELLED
POST /api/v1/orders/{orderId}/payOrder 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:

  1. An Admin wants to make a Product free for a promotion.
  2. An Admin has received 20 more units of a Product whose Inventory shows 15.
  3. A Customer wants to see the lines of one of their Orders.
  4. Support asks to bring a deactivated Product back.
  5. A Customer wants to remove one line from an UNPAID Order.
  6. 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:

  1. GET /api/v1/customers/{customerId}/orders
  2. POST /api/v1/inventory/{productId}/add with {"amount": 20}
  3. PATCH /api/v1/products/{productId} with {"active": true}
  4. PUT /api/v1/inventory/{productId} with {"productId": "364ec915-01ab-4cf0-b19f-327ed160f42b", "availableQuantity": 5}
  5. POST /api/v1/orders/check-inventory
  6. GET /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:

bash
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 CustomerId always comes from authentication, never from a body, path or query parameter; Customer Order access is ownership-scoped, and missing and non-owned Orders both return 404 / ORDER_NOT_FOUND.
  • Products: bounded, deterministic browsing; creation that starts active and creates no Inventory; PATCH where omitted and null fields 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 idempotent PUT, and Order workflows use consumeIfAvailable(...), not the public API.
  • Create Order is one atomic workflow behind POST /api/v1/orders with server-owned IDs, prices, status, time and total; history is paginated by createdAt 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 soon

Checkpoint questions that ask you to commit to an answer before the lesson shows it.