Building REST APIs

Designing Resources and Endpoints

LuettavaEsikatselu

Katselet ilmaista esikatseluoppituntia.

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:

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 OrdersAll/broader Order access
GET /api/v1/orders/{orderId}Own OrderBroader Order access
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 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 active assignment.
  • 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}.
  • SetInventoryUseCase represents 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 customerId query 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 Location headers together with 201 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.