Building REST APIs
Designing Resources and Endpoints
In the previous lesson, we studied HTTP as an application-level behavioural contract.
Now we can use that foundation to design the actual HTTP API for our Order Management Backend.
Our main capabilities are:
Product
Inventory
Order
Payment
Customer identity is also important, but there is a critical distinction:
This backend does not own Customer account lifecycle.
Therefore we should not automatically create CRUD resources such as:
/products
/customers
/orders
/payments
simply because those nouns appear in the business domain.
Instead, we ask:
Which state does this application own?
Which behaviours does it expose?
Which resources can clients legitimately interact with?
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 is:
products table
→ /products
inventory table
→ /inventory
orders table
→ /orders
order_items table
→ /order-items
This assumes every database table deserves a public HTTP resource.
That is not true.
Consider:
OrderItem
OrderItems will eventually be persisted as separate database rows.
But Customers do not independently:
create OrderItem
update OrderItem
delete OrderItem
An OrderItem belongs to:
Order
and its lifecycle is owned by the Order.
Therefore we do not need a top-level:
/api/v1/order-items
CRUD API.
Persistence structure does not automatically determine public API structure.
Start From Actual User Operations
Our confirmed Customer operations are:
browse orderable Products
create an Order
view own Order history
view own specific Order
cancel own eligible Order
pay own eligible Order
Our confirmed Admin operations are:
create Product
view Product information
update Product
deactivate Product
view Inventory
set current Inventory quantity
view Orders
These capabilities should drive the API.
Initial Resource Model
The primary v1 resources are:
/api/v1/products
/api/v1/inventory
/api/v1/orders
Payment is currently primarily an operation against an existing Order.
Customer identity comes from authentication.
Therefore v1 intentionally does not expose:
/api/v1/customers
or generic:
/api/v1/payments
CRUD resources.
No Customer CRUD API
Earlier generic planning might have suggested endpoints such as:
POST /api/v1/customers
GET /api/v1/customers/{customerId}
But 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
Customer account persistence
Therefore:
NO Customer CRUD API
is the correct v1 design.
This is not a missing feature.
It is an intentional system boundary.
Authentication Supplies Customer Identity
For Customer-facing operations:
HTTP Request
↓
Authentication
↓
CustomerId
↓
Handler
↓
UseCase
For example:
POST /api/v1/orders
does not accept:
{
"customerId": "someone-else"
}
Customer ownership comes from:
authenticated identity
rather than request-controlled input.
This keeps ownership server-controlled.
Product as an API Resource
Product is application-owned state.
Therefore:
/api/v1/products
is a natural API resource.
We have two broad Product contexts:
Customer browsing
Admin management
They can share the same Product vocabulary while authorization and response semantics remain appropriate to the caller.
Browse Products
Canonical endpoint:
GET /api/v1/products
For Customer browsing, this means:
Return Products currently exposed for ordering.
Our accepted visibility rule is:
Product.active = true
AND
Inventory.availableQuantity > 0
This is a read operation, so:
GET
fits naturally.
GET /products Does Not Mean SELECT *
The endpoint does not mean:
SELECT * FROM products
It means:
Return the Product collection defined by the API contract.
Internally, the query may combine:
Product
+
Inventory
and filter before pagination.
The HTTP resource is not a direct reflection of one database table.
Product Browsing Is Bounded
Our collection APIs must be paginated.
Canonical pagination semantics are:
page
→ zero-based
→ default 0
size
→ default 20
→ minimum 1
→ maximum 100
So a request may look like:
GET /api/v1/products?page=0&size=20
We do not design:
return every Product forever
even if the development database is currently small.
Product Browse Ordering
Our accepted deterministic ordering is:
name ASC
then
id ASC
Stable ordering matters when pagination is involved.
Without deterministic ordering, page boundaries can become unpredictable.
Get One Product
Canonical endpoint:
GET /api/v1/products/{productId}
At the HTTP boundary:
productId
is represented as a UUID string.
For example:
GET /api/v1/products/59ba787c-6d83-4a21-9bae-19c3d0469bd5
The Handler parses:
String
→ UUID
→ ProductId
before entering the application layer.
Customer-facing Product visibility should follow the Product API contract rather than simply exposing whatever row exists in the database.
Administrative access may expose management state according to authorization requirements.
The Repository implementation must not accidentally define public visibility rules.
Create Product
Admin operation:
Create Product
Canonical endpoint:
POST /api/v1/products
Request:
{
"name": "Mechanical Keyboard",
"priceCents": 10000
}
The client controls:
name
priceCents
The server controls:
ProductId
active state
A new Product is created with:
active = true
and an application-generated UUID-backed ProductId.
Successful creation naturally returns:
201 Created
with the created Product representation.
Product Creation Does Not Create Inventory Automatically
Creating a Product does not imply:
Inventory.availableQuantity = 0
or:
Inventory.availableQuantity = 100
or any other invented default workflow.
Product and Inventory are separate capabilities.
Our established model says:
Product creation
does not automatically create Inventory.
If Inventory needs to exist, the Admin explicitly manages it through the Inventory capability.
Product Update
Admin can partially update:
name
priceCents
Canonical endpoint:
PATCH /api/v1/products/{productId}
Example:
{
"name": "Mechanical Keyboard Pro",
"priceCents": 12000
}
This maps conceptually to:
product.rename(...);
product.changePrice(...);
The HTTP contract is partial modification rather than full replacement.
Canonical PATCH Semantics
Our accepted Product PATCH behavior is:
field omitted
→ unchanged
field explicitly null
→ unchanged
{}
→ accepted no-op
The mutable fields are currently:
name
priceCents
active is not a generic patch field.
Lifecycle changes use explicit operations.
Product Active State Is Not Arbitrarily Patchable
Avoid:
{
"active": false
}
and especially:
{
"active": true
}
as generic Product update semantics.
Our Domain deliberately exposes:
product.deactivate();
and does not expose:
product.activate();
in v1.
The API should preserve that lifecycle decision.
Product Deactivation
Canonical endpoint:
POST /api/v1/products/{productId}/deactivate
Conceptually:
DeactivateProductHandler
↓
DeactivateProductUseCase
↓
product.deactivate()
This endpoint says:
Perform the Product deactivation business operation.
That is clearer than allowing arbitrary active-state assignment.
Why Not DELETE Product?
Because our business operation is:
deactivate
not:
physically delete
Using:
DELETE /api/v1/products/{productId}
could imply that the Product ceases to exist.
But existing Orders may continue referencing:
ProductId
and historical Order data must remain meaningful.
Therefore explicit deactivation better matches the Domain.
No Reactivation Endpoint
Current requirements do not include Product reactivation.
Therefore we do not introduce:
POST /api/v1/products/{productId}/activate
simply because deactivation exists.
The API models confirmed behaviour.
Future requirements can extend it deliberately.
Inventory as an API Resource
Inventory is application-owned state.
Its identity is naturally:
ProductId
There is no:
InventoryId
in our Domain.
Admin can:
view Inventory
set available quantity
Customers do not directly manage Inventory.
Order workflows modify Inventory internally.
Get One Inventory
Canonical endpoint:
GET /api/v1/inventory/{productId}
A response may look like:
{
"productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
"availableQuantity": 15
}
This matches our model:
one Inventory state
per ProductId
If no Inventory state exists for that Product:
404
is appropriate.
Remember:
missing Inventory
≠
Inventory with quantity 0
Those are different states.
Why Not Nest Inventory Under Product?
A URI such as:
GET /api/v1/products/{productId}/inventory
would also be conceptually defensible.
But we have chosen:
/api/v1/inventory
as a first-class capability.
The canonical convention for this course is therefore:
GET /api/v1/inventory/{productId}
Use one convention consistently rather than teaching multiple interchangeable designs.
Inventory Listing
Admin collection endpoint:
GET /api/v1/inventory
Inventory listing is an Admin management capability.
Customers receive orderable Product information through the Product browsing API rather than using Inventory-management endpoints.
Canonical Inventory ordering is:
productId ASC
and collection reads remain bounded.
Set Inventory
Our accepted v1 Admin operation is:
Set the exact current available quantity.
Canonical endpoint:
PUT /api/v1/inventory/{productId}
Request:
{
"availableQuantity": 20
}
Conceptually:
SetInventoryHandler
↓
SetInventoryUseCase
The Product must exist.
The Product may be active or inactive.
The Inventory quantity must satisfy:
availableQuantity >= 0
Why PUT Fits Inventory
The operation means:
Make current availableQuantity = 20.
If the same request is repeated:
{
"availableQuantity": 20
}
the intended final state remains:
20
This makes the operation naturally idempotent in effect.
Why Not an Increment Endpoint?
Avoid defining the Admin API as:
POST /api/v1/inventory/{productId}/add
with:
{
"amount": 20
}
unless a real requirement asks for delta-based Inventory adjustments.
Repeated network retries could produce:
10
→ 30
→ 50
Our requirement is simpler:
set exact current quantity
That is easier to reason about and safer to retry.
Order Workflows Do Not Call the Public Inventory API
When Create Order consumes Inventory, our application does not perform an internal HTTP request such as:
PUT /api/v1/inventory/{productId}
Our architecture remains:
CreateOrderUseCase
↓
InventoryRepository
Specifically, Order creation uses the atomic persistence operation:
consumeIfAvailable(
ProductId,
quantity
)
established in ADR-005.
The public Inventory API is for Admin interaction.
Internal application workflows use application/persistence boundaries directly.
Orders as the Central Customer Resource
Order is the central Customer-owned resource.
Canonical collection:
/api/v1/orders
The HTTP method determines the operation.
Create Order
Canonical endpoint:
POST /api/v1/orders
Request:
{
"items": [
{
"productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
"quantity": 2
},
{
"productId": "364ec915-01ab-4cf0-b19f-327ed160f42b",
"quantity": 1
}
]
}
Authenticated context supplies:
CustomerId
The request does not contain it.
Server-Owned Order Creation State
The server determines:
CustomerId
→ authenticated caller
OrderId
→ generated UUID
unitPriceCents
→ current authoritative Product price
status
→ UNPAID
createdAt
→ application Clock
totalCents
→ derived from OrderItems
The client controls only:
ProductId
quantity
Successful creation returns:
201 Created
Create Order Is One Business Operation
Conceptually:
CreateOrderHandler
↓
CreateOrderUseCase
The UseCase coordinates:
reject duplicate requested ProductIds
load Products
verify Products exist
verify Products are active
capture authoritative priceCents
construct OrderItems
consume Inventory atomically
generate OrderId
obtain createdAt
construct UNPAID Order
persist Order and OrderItems
commit atomically
The API exposes one operation:
Create this Order.
It does not expose the implementation steps separately.
Atomic Inventory Consumption
The concurrency-sensitive Inventory step is not:
load Inventory
check quantity
decrease object
save
Our canonical operation is:
InventoryRepository.consumeIfAvailable(...)
backed by a conditional database update.
If any requested Product cannot be consumed:
INSUFFICIENT_INVENTORY
is raised and the whole Create Order transaction rolls back.
The endpoint remains one atomic business operation from the client's perspective.
Do Not Expose Internal Create Order Steps
Avoid:
POST /api/v1/orders/check-products
POST /api/v1/orders/check-inventory
POST /api/v1/orders/calculate-total
POST /api/v1/orders/save
These are implementation steps.
They would also make it much harder to preserve:
all-or-nothing Order creation
The server owns the complete workflow.
Order History
Canonical endpoint:
GET /api/v1/orders
For an authenticated Customer, this means:
Return Orders belonging to the authenticated CustomerId.
We do not require:
GET /api/v1/customers/{customerId}/orders
because a normal Customer should not choose whose Order history to query.
Never Let the Customer Choose Ownership Scope
Avoid Customer-facing requests such as:
GET /api/v1/orders?customerId=someone-else
Instead:
Authentication
↓
CustomerId
↓
GetOrderHistoryUseCase
↓
ownership-scoped Repository query
Customer ownership is derived by the server.
Order History Pagination
Our canonical Order-history paging is:
page
→ zero-based
→ default 0
size
→ default 20
→ max 100
Ordering is:
createdAt DESC
then
OrderId DESC
The UUID is a deterministic tie-breaker.
The Repository should also return exact total information for our current pagination contract.
Get One Order
Canonical endpoint:
GET /api/v1/orders/{orderId}
For a Customer, the operation means:
Return this Order if it belongs to me.
The application should use ownership-aware access, conceptually:
findOwnedBy(
orderId,
customerId
)
rather than loading an arbitrary Order and exposing it accidentally.
Another Customer's Order
Our canonical behavior is already settled.
If:
Order does not exist
or:
Order exists
but belongs to another Customer
the Customer-facing response is the same:
404
ORDER_NOT_FOUND
This avoids leaking whether another Customer's protected resource exists.
We do not expose:
403
for cross-Customer Order ownership checks.
Admin Order Viewing
Our confirmed Admin scope includes:
view all Orders
The Customer scope is:
view own Orders
These can operate over the same:
/orders
resource vocabulary while application authorization and query scope differ.
We do not need to encode:
/admin
or:
/customer
into every resource URI unless the API contracts later diverge enough to justify separate surfaces.
Order Cancellation
Cancellation is a business transition.
We do not allow:
PATCH /api/v1/orders/{orderId}
with:
{
"status": "CANCELLED"
}
because that makes lifecycle state appear client-controlled.
Canonical endpoint:
POST /api/v1/orders/{orderId}/cancel
Conceptually:
CancelOrderHandler
↓
CancelOrderUseCase
↓
order.cancel()
Cancellation Is More Than One Field Change
The complete workflow includes:
authenticated ownership
ownership-aware locked Order load
lifecycle validation
Order transition
Inventory restoration
atomic local transaction
So:
cancel
is a meaningful business operation.
The explicit command endpoint accurately communicates that.
Cancellation Retry Semantics
At first glance, cancellation might appear naturally idempotent because the final state is:
CANCELLED
But our Domain deliberately rejects repeated cancellation:
CANCELLED → CANCELLED
→ invalid operation
This is important because Inventory must be restored only once.
Therefore the API should not casually promise:
repeated cancellation request
always succeeds identically
without a deliberate retry/idempotency mechanism.
A repeated request after successful cancellation may produce:
409
ORDER_NOT_CANCELLABLE
while the persisted state remains correctly cancelled.
Pay Order
Payment is another explicit Order operation.
Canonical endpoint:
POST /api/v1/orders/{orderId}/pay
Meaning:
Attempt Payment for this Customer-owned Order.
It does not mean:
set status = PAID
Payment Flow
Conceptually:
PayOrderHandler
↓
PayOrderUseCase
↓
load Customer-owned Order
↓
verify payment eligibility
↓
PaymentService
↓
external provider
After confirmed provider success and current-state revalidation:
order.markPaid();
is applied.
The HTTP operation represents the Payment workflow rather than exposing internal status mutation.
Never Allow PATCH status=PAID
Avoid:
PATCH /api/v1/orders/{orderId}
with:
{
"status": "PAID"
}
That would bypass or misrepresent:
PaymentService
provider result
provider idempotency
payment eligibility
pay-vs-cancel concurrency
confirmed success
The client expresses:
pay this Order
not:
assign PAID
Why Payment Is Not Yet a Top-Level CRUD Resource
Payment may eventually gain independently exposed concepts such as:
Payment attempts
Payment history
provider references
Payment state
reconciliation status
If those become real requirements, a Payment resource may become appropriate.
Current scope is simpler.
Payment is primarily an operation attached to Order:
POST /api/v1/orders/{orderId}/pay
So we do not introduce speculative:
POST /api/v1/payments
PUT /api/v1/payments/{paymentId}
DELETE /api/v1/payments/{paymentId}
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
And intentionally:
NO /api/v1/customers CRUD
NO /api/v1/order-items CRUD
NO generic /api/v1/payments CRUD
This is the canonical API direction for the current course.
Authorization Matrix
Conceptually:
| 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 | All/broader Order access |
GET /api/v1/orders/{orderId} | Own Order | Broader Order access |
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 simply because Admin exists.
Confirmed Admin responsibilities are:
manage Products
manage Inventory
view Orders
Customer responsibilities include:
create Order
view own Orders
cancel own eligible Order
pay own eligible Order
Do Not Encode Roles Into Every URI
We could create:
/api/v1/admin/products
/api/v1/customer/orders
But this is not required merely because different roles exist.
Resources can remain:
/products
/inventory
/orders
while Spring Security and UseCases enforce access.
If Admin contracts eventually diverge substantially, separate Admin endpoints can be introduced deliberately.
OrderItem Is a Nested Representation
An Order response may contain:
{
"id": "d2e7b44e-463f-4bbc-b16f-b1c80bc80442",
"status": "UNPAID",
"items": [
{
"productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
"quantity": 2,
"unitPriceCents": 2000,
"totalCents": 4000
}
],
"totalCents": 4000
}
This does not imply:
GET /api/v1/order-items/{orderItemId}
OrderItems are part of the Order representation.
No OrderItem Mutation API
Our Order is not a Cart.
Therefore v1 does not expose:
POST /api/v1/orders/{orderId}/items
PATCH /api/v1/orders/{orderId}/items/{itemId}
DELETE /api/v1/orders/{orderId}/items/{itemId}
Order composition becomes immutable after successful creation.
No Order Total Mutation
Avoid:
PUT /api/v1/orders/{orderId}/total
There is no independently mutable Order total.
The server derives:
totalCents
from immutable OrderItems.
No Ownership Reassignment Endpoint
Avoid:
PATCH /api/v1/orders/{orderId}
with:
{
"customerId": "another-customer"
}
Order ownership is established from authenticated identity during creation.
There is no ownership-transfer workflow in v1.
Resource IDs
For Product and Order creation:
ProductId
→ application-generated UUID
OrderId
→ application-generated UUID
At the HTTP boundary, UUIDs are represented as strings.
For example:
{
"id": "59ba787c-6d83-4a21-9bae-19c3d0469bd5"
}
Clients do not need to know about internal JPA representation.
But the API contract does deliberately expose UUID-string identifiers.
Location Header
A successful resource creation may naturally include:
Location: /api/v1/orders/d2e7b44e-463f-4bbc-b16f-b1c80bc80442
with:
201 Created
This identifies the newly created resource.
Whether every creation Handler uses Location consistently is an implementation-contract decision we can finalize while building the HTTP layer.
Request Bodies Represent Client Intent
Create Product
{
"name": "Keyboard",
"priceCents": 10000
}
Update Product
{
"name": "Mechanical Keyboard",
"priceCents": 12000
}
Set Inventory
{
"availableQuantity": 10
}
Create Order
{
"items": [
{
"productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
"quantity": 2
}
]
}
Each request contains only values that caller is legitimately allowed to control.
Avoid Generic Update DTOs
Avoid one giant request model such as:
{
"id": "...",
"type": "...",
"status": "...",
"active": true,
"operation": "..."
}
used across many endpoints.
Such contracts blur:
what operation is happening
which fields are allowed
who owns each value
which invariants apply
Prefer endpoint-specific request models.
Endpoint-Specific DTOs
Examples include:
CreateProductRequest
UpdateProductRequest
SetInventoryRequest
CreateOrderRequest
Command endpoints such as:
deactivate
cancel
may require no body when:
resource identity
+
authenticated identity
already provide everything needed.
Do Not Send {} Just Because the Method Is POST
For:
POST /api/v1/orders/{orderId}/cancel
if no additional Customer input is required, we do not need:
{}
merely because the method is POST.
A body should carry meaningful request data.
Payment Request Input Is Deferred
The eventual Payment provider may require things such as:
payment method token
provider-specific reference
approved Payment input
But those details are not established yet.
Therefore we keep:
POST /api/v1/orders/{orderId}/pay
while deferring the exact request DTO until the Payment contract is known.
Do not invent provider fields prematurely.
Product Browse Response
Our canonical Customer Product browse representation currently needs:
id
name
priceCents
The browse query itself guarantees:
active = true
Inventory > 0
Whether exact:
availableQuantity
should also be public is a Product/API decision.
We should not expose the raw Inventory count merely because the backend knows it.
If the Product only needs:
available for ordering
then the filtered Product collection may be enough.
Do Not Leak Internal Data
Customer Product responses do not need:
database version
internal audit state
JPA metadata
administrative implementation fields
Order responses do not need:
CustomerId
provider secrets
raw Payment-provider response
security principal information
internal persistence details
DTOs intentionally define the public representation.
Collections Must Stay Bounded
These endpoints:
GET /api/v1/products
GET /api/v1/orders
GET /api/v1/inventory
must not grow into:
return every row forever
Collection bounds are part of reliable API design.
Our pagination contract will be implemented consistently rather than added independently by each Handler.
Add Filters Only When Requirements Need Them
Do not prematurely create:
20 optional query parameters
for Products or Orders.
Add filtering capabilities when real Product requirements appear.
A small predictable API is easier to maintain than a speculative query language.
Endpoint → Handler → UseCase
Our endpoint design aligns naturally with our internal architecture.
POST /api/v1/orders
↓
CreateOrderHandler
↓
CreateOrderUseCase
POST /api/v1/orders/{orderId}/cancel
↓
CancelOrderHandler
↓
CancelOrderUseCase
POST /api/v1/orders/{orderId}/pay
↓
PayOrderHandler
↓
PayOrderUseCase
PUT /api/v1/inventory/{productId}
↓
SetInventoryHandler
↓
SetInventoryUseCase
The HTTP API and application operations tell the same story.
One Handler Class Per Endpoint Is Not Mandatory
Our architecture says:
Handler
→ transport/application entry boundary
It does not require:
one Java class
per HTTP mapping
A Spring @RestController may group closely related Product mappings pragmatically.
What matters is that meaningful operations continue to follow:
Handler
↓
UseCase
↓
Repository
rather than moving business workflow into the HTTP class.
Reads Can Be Small UseCases
For example:
GetOrderHistoryUseCase
↓
OrderRepository
may remain relatively small.
That is fine.
The application boundary still matters because the query owns:
Customer ownership scope
pagination
business ordering semantics
Small does not mean unnecessary.
Avoid CRUD-Generated API Design
If we generated endpoints mechanically from tables, we could end up with:
POST /order-items
DELETE /orders
PATCH /inventory-row
POST /customers
None of those accurately reflects the confirmed Product behaviour.
Our API instead models:
what Customers and Admins
are actually allowed to do
That is a much stronger design basis.
Endpoint Naming
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
/getOrder
The HTTP method already communicates those standard operations.
Avoid Deep Resource Nesting
We do not need:
/customers/{customerId}/orders/{orderId}/items/{itemId}
because:
Customer scope
→ authentication
OrderItem
→ not independently managed
A simpler:
/api/v1/orders/{orderId}
is enough.
Ownership is enforced server-side.
URI Does Not Need to Mirror Foreign Keys
Persistence may contain:
Order.customer_id
OrderItem.product_id
But the API URI hierarchy does not need to reproduce the relational model.
HTTP resources are an external product model.
They are not a graphical representation of database relationships.
Command Endpoint Responses
For successful:
POST /api/v1/orders/{orderId}/cancel
possible contracts include:
200 + updated Order representation
or:
204 No Content
Both are valid HTTP designs.
For this course, returning the resulting Order representation is useful for important state transitions because the client immediately sees:
status = CANCELLED
So 200 with the updated Order is a sensible default unless implementation requirements give us a reason to choose otherwise.
Payment Response
Likewise, successful:
POST /api/v1/orders/{orderId}/pay
may return the resulting Order:
status = PAID
If the future Payment contract requires additional public information, we can introduce an application-facing Payment result.
We do not return raw provider DTOs.
External Payment Types Stay Behind PaymentService
Avoid exposing:
{
"providerInternalStatus": "CAPTURED_X7",
"rawGatewayCode": "...",
"debugResponse": "..."
}
unless those fields become an intentional public Product contract.
Provider protocol belongs behind:
PaymentService
Application-facing responses should use application concepts.
Endpoint Error Semantics Matter
Design each endpoint with its failure cases, not only its happy path.
For:
POST /api/v1/orders
expected failures include:
invalid request
duplicate Product
Product not found
Product inactive/not orderable
insufficient Inventory
For:
POST /api/v1/orders/{orderId}/cancel
expected failures include:
Order not found/not owned
Order already PAID
Order already CANCELLED
For:
POST /api/v1/orders/{orderId}/pay
expected failures include:
Order not found/not owned
Order already PAID
Order CANCELLED
Payment operation failure
These need consistent mapping into the API error contract.
Canonical Error Direction
Our established mapping includes:
invalid request
→ 400
authentication required
→ 401
authenticated but insufficient authority
→ 403
Product not found
→ 404
Order missing/not owned
→ 404
Product not orderable
→ 409
insufficient Inventory
→ 409
Order not cancellable
→ 409
Order not payable
→ 409
unexpected system failure
→ 500
Structured responses use our Problem Details contract under:
https://api.tarkev.com/problems/...
The detailed error-mapping implementation comes later.
Product Endpoint Review
Canonical:
GET /api/v1/products
GET /api/v1/products/{productId}
POST /api/v1/products
PATCH /api/v1/products/{productId}
POST /api/v1/products/{productId}/deactivate
No:
DELETE /api/v1/products/{productId}
because physical deletion is not the operation.
No:
POST /api/v1/products/{productId}/activate
because reactivation is not a v1 requirement.
Inventory Endpoint Review
Canonical:
GET /api/v1/inventory
GET /api/v1/inventory/{productId}
PUT /api/v1/inventory/{productId}
The Admin mutation means:
set exact current available quantity
not:
increase by arbitrary delta
Order creation and cancellation update Inventory internally through application/persistence workflows rather than calling these HTTP endpoints.
Order Endpoint Review
Canonical:
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
No:
generic Order PATCH
client-controlled status
item editing
total modification
ownership reassignment
because those contradict the Domain model.
Customer Endpoint Review
Canonical:
NO Customer CRUD endpoints.
Customer identity comes from authenticated external identity.
This is a deliberate architectural decision.
OrderItem Endpoint Review
Canonical:
NO independent OrderItem CRUD.
OrderItems exist inside:
Order creation
Order representation
Order lifecycle
and are not independently managed resources.
Payment Endpoint Review
Canonical:
POST /api/v1/orders/{orderId}/pay
No generic Payment CRUD exists until actual Product requirements justify Payment as an independently managed public resource.
Keep the V1 Surface Small
Our API supports the confirmed Product behavior without introducing:
Customer account subsystem
OrderItem CRUD
generic Payment CRUD
Product deletion
Product reactivation
Inventory adjustment-history API
Cart editing
refund APIs
Inventory reservation APIs
A small coherent API is better than exposing every internal capability mechanically.
Endpoint Design Checklist
For every proposed endpoint, ask:
Which business capability owns this operation?
Does our application actually own this resource?
Is the operation a read or mutation?
Does the HTTP method communicate that correctly?
What values does the client legitimately control?
Which values must remain server-controlled?
Does this expose a meaningful Domain operation
or merely a database-field mutation?
Is the resource independently managed?
Does authenticated identity determine ownership?
What happens if the request is retried?
Is the operation naturally idempotent?
What expected failures exist?
Could the endpoint leak another Customer's
resource existence?
Does this endpoint create a feature
that is not in the requirements?
Common Mistakes
CRUD for Every Domain Class
OrderItem does not need public CRUD simply because it has a Java type and database rows.
/customers Because This Is Commerce
The backend references Customer identity but does not own Customer accounts.
PATCH status=PAID
Payment is a business workflow, not arbitrary state mutation.
DELETE /products
Our confirmed lifecycle operation is deactivation.
Client-Supplied CustomerId
Authenticated identity determines Order ownership.
Client-Supplied Price or Total
Server-side Product price is authoritative and Order total is derived.
Public Inventory API Used Internally
Application workflows use Domain/Repository boundaries, not their own public HTTP API.
Exposing Payment Provider DTOs
Provider protocol stays behind PaymentService.
Unlimited Collection Reads
Product, Inventory, and Order collections must remain bounded.
Building Future Features Early
No refunds, Cart editing, Product reactivation, Customer profile APIs, Inventory reservations, or Payment CRUD until a requirement exists.
Our V1 API Contract
The working API surface is now:
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
And intentionally:
NO /api/v1/customers CRUD
NO /api/v1/order-items CRUD
NO generic /api/v1/payments CRUD
This API surface now matches our Domain and application architecture.
Engineering Principle
The core principle is:
Expose the resources and operations the application actually owns—not every noun, database table, or Java class in the implementation.
Another:
Use normal HTTP resource operations for resource state clients legitimately manage, and explicit command endpoints for meaningful business transitions such as deactivation, cancellation, and Payment.
And:
Authenticated ownership, authoritative Product prices, Order totals, generated identities, and lifecycle transitions remain under server control.
Summary
In this lesson, we established that:
- API resources should come from owned business capabilities rather than database tables.
- Product, Inventory, and Order are meaningful public resources in v1.
- Customer identity is important but Customer account lifecycle is externally owned.
- No Customer CRUD API is needed solely to support ordering.
- CustomerId comes from authenticated context rather than the request body.
- OrderItem is an owned part of Order and does not need independent CRUD.
- Generic Payment CRUD is not justified by the current scope.
- Product browsing uses
GET /api/v1/products. - Customer-visible Product browsing requires active Product plus positive Inventory.
- Product collection reads are bounded and deterministic.
- Product creation uses
POST /api/v1/products. - Product creation accepts
priceCents, not floating-point price. - ProductId is generated by the application as a UUID.
- New Products start active.
- Product creation does not automatically create Inventory.
- Product partial update uses
PATCH /api/v1/products/{productId}. - Omitted or null Product PATCH fields leave current values unchanged.
- Empty Product PATCH is an accepted no-op.
- Product lifecycle is not controlled through generic
activeassignment. - Product deactivation uses
POST /api/v1/products/{productId}/deactivate. - Product deletion is not part of v1.
- Product reactivation is not part of v1.
- Inventory is identified by ProductId.
- Inventory listing uses
GET /api/v1/inventory. - One Inventory state uses
GET /api/v1/inventory/{productId}. - Missing Inventory and zero Inventory remain different concepts.
- Admin exact-quantity replacement uses
PUT /api/v1/inventory/{productId}. SetInventoryUseCaserepresents the Admin mutation.- The exact-set Inventory operation is naturally idempotent.
- Order creation/cancellation do not call the public Inventory HTTP API internally.
- Order-time Inventory consumption uses
InventoryRepository.consumeIfAvailable(...). - Order creation uses
POST /api/v1/orders. - CustomerId, OrderId, prices, status, createdAt, and total remain server-controlled.
- OrderId is generated as an application-owned UUID.
- Create Order remains one atomic business workflow.
- Internal Product checks, Inventory consumption, price capture, and persistence are not separate public endpoints.
- Customer Order history uses
GET /api/v1/orders. - Customer ownership scope comes from authentication rather than
customerIdquery parameters. - Order-history collection reads are paginated.
- Order-history ordering is
createdAt DESC, OrderId DESC. - Specific Order read uses
GET /api/v1/orders/{orderId}. - Customer Order access is ownership-scoped.
- Missing and non-owned Customer Orders both produce
404 / ORDER_NOT_FOUND. - Admin may view broader Order scope according to Security requirements.
- Customer cancellation uses
POST /api/v1/orders/{orderId}/cancel. - Cancellation is a business command rather than client-controlled status mutation.
- Repeated cancellation is not automatically promised as retry-idempotent because our Domain rejects repeated cancellation and Inventory must be restored once.
- Customer Payment uses
POST /api/v1/orders/{orderId}/pay. - Payment is a workflow, not
PATCH status=PAID. - Provider-specific Payment request input remains deferred until the provider/product contract is known.
- Raw Payment-provider DTOs do not leak through our public API.
- OrderItems appear inside Order representations rather than as independent resources.
- Successful Order composition cannot be edited through Item CRUD endpoints.
- Order total has no mutation endpoint.
- Order ownership has no reassignment endpoint.
- UUID-based ProductId and OrderId are represented as strings over HTTP.
- Creation may use
Locationheaders together with201 Created. - Endpoint-specific DTOs are preferable to ambiguous generic mutation DTOs.
- Command POSTs do not require empty JSON bodies when there is no meaningful additional input.
- Collection endpoints must remain bounded.
- Filters should be added only when real Product requirements require them.
- Endpoint design should align with meaningful Handler and UseCase boundaries.
- Handler responsibility does not require one Java class per endpoint.
- Read UseCases may remain simple while still owning application query semantics.
- URI design should not mirror relational foreign-key structure.
- API routes should use Domain/resource vocabulary rather than Java class names.
- Role prefixes are not required merely because Customer and Admin authorization differs.
- Expected endpoint failures are part of the API design, not an afterthought.
- The v1 API intentionally remains small and avoids speculative Customer, Payment, Cart, refund, reservation, and reactivation features.
Next lesson:
Controllers in Spring Boot
There, we will turn this contract into Spring Boot HTTP Handlers using @RestController, request mappings, @PathVariable, @RequestBody, response types, and constructor-injected UseCases—while keeping the HTTP layer thin and preserving our Handler → UseCase → Repository architecture.
