Implementing Business Workflows
Implementing Product Browsing
আপনি একটি free preview lesson দেখছেন।
এখন আমাদের application-এ admin Product তৈরি করতে পারে:
POST /api/v1/products
Product update করতে পারে:
PATCH /api/v1/products/{productId}
এবং Product deactivate করতে পারে:
POST /api/v1/products/{productId}/deactivate
কিন্তু Customer-এর perspective থেকে এখনও Product catalog usable নয়।
Customer-এর প্রয়োজন:
GET /api/v1/products?page=0&size=20
এবং response-এ শুধুমাত্র সেই Products দেখতে হবে যেগুলো:
Product active
AND
available Inventory > 0
এটি গুরুত্বপূর্ণ কারণ:
Product exists
মানে necessarily:
Product can currently be ordered
নয়।
এই lesson-এ আমরা implement করব প্রথম customer-facing read workflow:
GET /api/v1/products
↓
ProductHandler
↓
ListOrderableProductsUseCase
↓
ProductRepository
↓
Product + Inventory query
↓
PostgreSQL
এখানে আমরা Product এবং Inventory-এর মধ্যে unnecessary JPA relationship তৈরি করব না।
Instead:
Relational query দিয়ে দুই capability-এর data efficiently combine করব।
What Makes a Product Orderable?
আমাদের current rule:
Product.active = true
AND
Inventory.availableQuantity > 0
Examples:
Product active = true
Inventory = 10
→ orderable
Product active = true
Inventory = 0
→ not orderable
Product active = false
Inventory = 10
→ not orderable
Product active = true
Inventory row does not exist
→ not orderable
Customer catalog therefore represents:
currently orderable Products
not:
every Product stored in products table
Product and Inventory Remain Separate
Our schema:
products
contains:
id
name
price
active
while:
inventory
contains:
product_id
available_quantity
This separation is intentional।
We do not add:
availableQuantity
inside Product just because Product browsing needs both pieces of information।
The browse query can combine them relationally।
No JPA Relationship Is Required
We previously decided not to create:
@OneToOne
private InventoryEntity inventory;
inside ProductEntity.
We still don't need it।
A database relationship:
inventory.product_id
→ products.id
does not require an object relationship:
ProductEntity.inventory
JPA can query both entities directly।
This keeps the entity graph smaller and prevents Product loading from accidentally pulling Inventory state.
The Customer-Facing Endpoint
Request:
GET /api/v1/products?page=0&size=20
Successful response:
{
"items": [
{
"id": "0b10cf5e-33d9-40d4-8594-aa3a6ecbc6a1",
"name": "Mechanical Keyboard",
"price": 89.90
},
{
"id": "8df9ce34-66a2-4f01-b90c-f4028a7e21c4",
"name": "USB-C Dock",
"price": 69.50
}
],
"page": 0,
"size": 20,
"totalItems": 2,
"totalPages": 1
}
Notice customer response does not need:
active
because every Product in this collection is already active.
We also do not expose:
availableQuantity
yet।
Our requirement is:
can this Product currently be ordered?
not:
show exact warehouse quantity to Customer
Why Not Expose Inventory Quantity?
Suppose:
availableQuantity = 7
The Customer needs to know:
Product is currently orderable
but we have not established a requirement to reveal:
7 units remaining
Exposing exact stock quantity becomes part of the public API contract।
That can later affect:
UI expectations
inventory privacy
caching
concurrency expectations
So we don't expose it without a requirement।
Pagination Rules
We already defined:
page
→ default 0
→ minimum 0
and:
size
→ default 20
→ minimum 1
→ maximum 100
Product filtering must happen:
before pagination
Correct:
active Products
↓
Inventory > 0
↓
sort
↓
LIMIT / OFFSET
Wrong:
first 20 Products
↓
remove inactive
↓
remove out-of-stock
The second approach produces incorrect pages।
We Need a Deterministic Product Order
Earlier we intentionally deferred Product collection ordering।
Now that we're implementing the query, we need to make that decision explicitly।
For the initial catalog we will use:
name ASC
id ASC
Meaning:
alphabetical Product name
with UUID:
id
as a deterministic tie-breaker।
Example:
Mechanical Keyboard
Mechanical Keyboard
USB-C Dock
If two Products have the same name, id determines their stable relative order।
Why Not Order by UUID Alone?
UUID provides:
unique identity
but application-generated random UUID does not communicate:
creation order
business priority
catalog relevance
So:
ORDER BY id
alone would produce a technically deterministic but essentially arbitrary user-facing catalog।
name ASC gives the collection a meaningful default order।
Why Not Order by created_at?
Our products table does not currently have:
created_at
because no requirement justified it।
We should not add a timestamp merely to support an invented:
newest Products first
catalog rule।
Product Summary
The application does not need a full Product aggregate for this read operation।
A small read model is enough:
package io.liveklass.ordermanagement.product.usecase;
import io.liveklass.ordermanagement.product.domain.ProductId;
import java.math.BigDecimal;
public record ProductSummary(
ProductId id,
String name,
BigDecimal price
) {
}
This is not a new domain Entity।
It is simply:
the data required by the Product browsing operation
Why Not Return Product?
We could load:
Product
for every row and then map it।
But the collection endpoint only needs:
id
name
price
It does not need to mutate Product or reconstruct Product behaviour।
Using a focused summary avoids turning every read query into domain-aggregate hydration।
This Is Not CQRS
Using:
ProductSummary
for a collection query does not mean we introduced:
CQRS
separate read database
query bus
event synchronization
We are simply querying PostgreSQL efficiently for the data this endpoint needs।
Same database।
Same application।
Same Repository boundary।
Application Repository
Our ProductRepository can now evolve based on a real application requirement:
package io.liveklass.ordermanagement.product.repository;
import io.liveklass.ordermanagement.product.domain.Product;
import io.liveklass.ordermanagement.product.domain.ProductId;
import io.liveklass.ordermanagement.product.usecase.ProductSummary;
import io.liveklass.ordermanagement.shared.pagination.PageQuery;
import io.liveklass.ordermanagement.shared.pagination.PageResult;
import java.util.Optional;
public interface ProductRepository {
void save(
Product product
);
Optional<Product> findById(
ProductId productId
);
PageResult<ProductSummary>
findOrderable(
PageQuery pageQuery
);
}
This method represents an application query:
find currently orderable Products
rather than generic persistence capability:
findAll()
findOrderable() Contains Business Meaning
Is it wrong for Repository method to use the term:
orderable
?
No।
The application already decided that customer browsing means:
active
AND
Inventory > 0
Repository is not inventing this rule।
It is efficiently implementing a query whose semantics were decided by the application।
PageQuery
We already introduced:
public record PageQuery(
int page,
int size
) {
}
This remains application-owned।
UseCase does not need:
Pageable
or:
PageRequest
because those belong to Spring Data infrastructure।
PageResult
Our application result:
public record PageResult<T>(
List<T> items,
int page,
int size,
long totalItems,
int totalPages
) {
}
allows Product browsing to remain independent from Spring's:
Page<T>
type।
UseCase
The UseCase is intentionally small:
package io.liveklass.ordermanagement.product.usecase;
import io.liveklass.ordermanagement.product.repository.ProductRepository;
import io.liveklass.ordermanagement.shared.pagination.PageQuery;
import io.liveklass.ordermanagement.shared.pagination.PageResult;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class ListOrderableProductsUseCase {
private final ProductRepository
productRepository;
public ListOrderableProductsUseCase(
ProductRepository productRepository
) {
this.productRepository =
productRepository;
}
@Transactional(readOnly = true)
public PageResult<ProductSummary> execute(
PageQuery pageQuery
) {
return productRepository
.findOrderable(
pageQuery
);
}
}
The UseCase expresses the application operation:
list orderable Products
No business loop।
No manual filtering।
No Inventory Repository calls one Product at a time।
Why Not Use Two Repositories Here?
A naive implementation might do:
ProductRepository
→ load Products
for each Product:
InventoryRepository
→ load Inventory
If the page contains:
20 Products
we could get:
1 Product query
20 Inventory queries
which is an N+1 pattern।
Even worse, we'd have trouble filtering before pagination।
The correct read operation is relational:
Product + Inventory
inside PostgreSQL।
One Query Is a Better Boundary
Conceptually:
SELECT
p.id,
p.name,
p.price
FROM products p
JOIN inventory i
ON i.product_id = p.id
WHERE p.active = TRUE
AND i.available_quantity > 0
ORDER BY
p.name ASC,
p.id ASC
LIMIT ?
OFFSET ?;
PostgreSQL is good at:
joins
filters
ordering
pagination
Use it।
ProductBrowseRow
Inside persistence, we can use a focused projection:
package io.liveklass.ordermanagement.product.persistence;
import java.math.BigDecimal;
import java.util.UUID;
public interface ProductBrowseRow {
UUID getId();
String getName();
BigDecimal getPrice();
}
This projection is:
persistence-only
It must not leak into:
UseCase
Handler
public API
Spring Data Query
Our Spring Data repository can define the relational query explicitly:
package io.liveklass.ordermanagement.product.persistence;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.UUID;
interface ProductJpaRepository
extends JpaRepository<
ProductEntity,
UUID
> {
@Query(
value = """
select
p.id as id,
p.name as name,
p.price as price
from ProductEntity p,
InventoryEntity i
where i.productId = p.id
and p.active = true
and i.availableQuantity > 0
order by
p.name asc,
p.id asc
""",
countQuery = """
select count(p.id)
from ProductEntity p,
InventoryEntity i
where i.productId = p.id
and p.active = true
and i.availableQuantity > 0
"""
)
Page<ProductBrowseRow>
findOrderable(
Pageable pageable
);
}
Notice:
no ProductEntity.inventory
association is required।
Why the Query Looks Like a Cross Join
JPQL:
from ProductEntity p,
InventoryEntity i
then:
where i.productId = p.id
expresses the join using the scalar IDs we deliberately mapped।
At the relational level the intent remains:
products
JOIN inventory
ON inventory.product_id = products.id
We don't distort the entity model merely to make JPQL object navigation prettier।
Why Explicit countQuery?
Our public response includes:
totalItems
totalPages
So Spring Data Page needs total-count information।
The data query includes:
ORDER BY
but count query does not need ordering।
An explicit count query makes the intended count dataset clear:
active Product
AND
Inventory > 0
The count describes exactly the same logical collection as the page query।
Inventory's Shared Primary Key Helps
Our schema:
inventory.product_id
is both:
PRIMARY KEY
and:
FOREIGN KEY → products.id
Therefore one Product can have at most:
one Inventory row
This matters for pagination।
The join cannot multiply a Product into multiple rows through multiple Inventory records।
So:
COUNT(p.id)
correctly counts orderable Products।
If Inventory Were One-to-Many
If future architecture had:
Product
→ many warehouses
then a Product might join multiple Inventory rows।
Now:
COUNT(p.id)
could overcount Products।
But our current model explicitly has:
one Inventory state per Product
No warehouse support exists।
We design for the application we actually have।
Persistence Adapter
The application-facing adapter:
package io.liveklass.ordermanagement.product.persistence;
import io.liveklass.ordermanagement.product.domain.Product;
import io.liveklass.ordermanagement.product.domain.ProductId;
import io.liveklass.ordermanagement.product.repository.ProductRepository;
import io.liveklass.ordermanagement.product.usecase.ProductSummary;
import io.liveklass.ordermanagement.shared.pagination.PageQuery;
import io.liveklass.ordermanagement.shared.pagination.PageResult;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class JpaProductRepository
implements ProductRepository {
private final ProductJpaRepository
repository;
public JpaProductRepository(
ProductJpaRepository repository
) {
this.repository =
repository;
}
@Override
public void save(
Product product
) {
repository.save(
toEntity(product)
);
}
@Override
public Optional<Product> findById(
ProductId productId
) {
return repository
.findById(
productId.value()
)
.map(
this::toDomain
);
}
@Override
public PageResult<ProductSummary>
findOrderable(
PageQuery pageQuery
) {
Page<ProductBrowseRow> page =
repository.findOrderable(
PageRequest.of(
pageQuery.page(),
pageQuery.size()
)
);
List<ProductSummary> items =
page.getContent()
.stream()
.map(
this::toSummary
)
.toList();
return new PageResult<>(
items,
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages()
);
}
private ProductSummary toSummary(
ProductBrowseRow row
) {
return new ProductSummary(
new ProductId(
row.getId()
),
row.getName(),
row.getPrice()
);
}
// Existing toEntity() and toDomain()
// remain unchanged.
}
Spring's:
Page
PageRequest
stay inside persistence।
Why Not Put Sort in PageRequest?
We could write:
PageRequest.of(
page,
size,
Sort.by(...)
);
But our repository query already has a fixed business-facing order:
name ASC
id ASC
Keeping it in the persistence query makes the collection ordering explicit and prevents arbitrary external sorting from being introduced accidentally।
Handler
Our existing ProductHandler can now add:
@GetMapping
public PageResponse<ProductSummaryResponse>
listProducts(
@RequestParam(
defaultValue = "0"
)
@Min(0)
int page,
@RequestParam(
defaultValue = "20"
)
@Min(1)
@Max(100)
int size
) {
PageResult<ProductSummary> result =
listOrderableProductsUseCase
.execute(
new PageQuery(
page,
size
)
);
return PageResponse.from(
result,
ProductSummaryResponse::from
);
}
The exact reusable PageResponse implementation can remain simple।
ProductSummaryResponse
package io.liveklass.ordermanagement.product.handler;
import io.liveklass.ordermanagement.product.usecase.ProductSummary;
import java.math.BigDecimal;
import java.util.UUID;
public record ProductSummaryResponse(
UUID id,
String name,
BigDecimal price
) {
public static ProductSummaryResponse from(
ProductSummary product
) {
return new ProductSummaryResponse(
product.id().value(),
product.name(),
product.price()
);
}
}
Customer does not receive:
active
or:
availableQuantity
because those fields are not required for this read contract।
PageResponse
A small transport model might be:
package io.liveklass.ordermanagement.shared.handler;
import io.liveklass.ordermanagement.shared.pagination.PageResult;
import java.util.List;
import java.util.function.Function;
public record PageResponse<T>(
List<T> items,
int page,
int size,
long totalItems,
int totalPages
) {
public static <S, T>
PageResponse<T> from(
PageResult<S> result,
Function<S, T> mapper
) {
return new PageResponse<>(
result.items()
.stream()
.map(mapper)
.toList(),
result.page(),
result.size(),
result.totalItems(),
result.totalPages()
);
}
}
This is one of the few concepts genuinely shared across multiple HTTP capabilities।
So a small:
shared/pagination
or:
shared/handler
location is justified।
We still avoid turning shared into a dumping ground।
Invalid Pagination
Request:
GET /api/v1/products?page=-1
should produce:
400
VALIDATION_ERROR
Likewise:
GET /api/v1/products?size=101
should fail।
We do not silently transform:
101
→ 100
The client violated the contract and should receive an explicit validation response।
Empty Catalog
Suppose no Product satisfies:
active = true
Inventory > 0
Response:
200 OK
{
"items": [],
"page": 0,
"size": 20,
"totalItems": 0,
"totalPages": 0
}
Not:
404
The Product collection exists।
It is simply empty।
Product Without Inventory
Suppose:
Product active = true
but no:
inventory
row exists।
Because our query uses:
Product + Inventory
matching ProductId, that Product is excluded।
This is exactly what we want։
Product creation does not automatically make the Product orderable।
Zero Inventory
Suppose:
availableQuantity = 0
Then:
i.availableQuantity > 0
is false।
Product does not appear in customer browsing।
No additional Product mutation is needed।
Inventory Becomes Positive
Later admin changes Inventory:
0
→ 10
If Product is still:
active = true
the next browse query naturally includes it।
No:
Product.available
flag needs synchronization।
This demonstrates why derived state is powerful when the underlying query is straightforward।
Product Becomes Out of Stock
Suppose Product initially appears:
Inventory = 5
Orders consume all five units।
Inventory becomes:
0
Next Product browse query automatically excludes it।
No second catalog update is required।
Deactivation
Suppose:
Inventory = 100
but admin calls:
POST /products/{id}/deactivate
Now:
active = false
Next browse query excludes it।
Again, no duplicate availability state needs synchronization।
Browse Price Is Current Price
Product response shows:
Product.price
which is the current catalog price।
But this is not a guaranteed quote for a future Order।
Timeline:
10:00
Customer browses Product
price = 89.90
10:05
Admin changes Product
price = 99.90
10:06
Customer creates Order
Order creation captures:
99.90
because the backend loads the current Product price at successful Order creation।
Never Trust Browse Price During Order Creation
A future Create Order request still contains only:
{
"items": [
{
"productId": "0b10cf5e-33d9-40d4-8594-aa3a6ecbc6a1",
"quantity": 1
}
]
}
Not:
{
"productId": "...",
"quantity": 1,
"price": 89.90
}
Product browsing is informational।
Order pricing remains server-authoritative।
What About Product Detail?
Our API design also contains:
GET /api/v1/products/{productId}
There is an important access distinction:
CUSTOMER
→ should see an orderable Product
while:
ADMIN
→ may need to inspect an inactive Product
The exact role-sensitive behaviour depends on authentication/authorization context, which we introduce in Module 8।
So this lesson implements the unambiguous customer collection:
GET /api/v1/products
The single-resource endpoint remains part of the API contract and will be completed once caller role/visibility can be represented properly।
We do not invent an awkward temporary rule now and rewrite it later।
Why Not Return All Products Until Security Exists?
Because that would make this endpoint semantically wrong even before authentication is wired।
Customer browsing means:
currently orderable Products
We can implement that correctly now without requiring a logged-in identity।
Role-dependent Product detail is different and can wait।
Does Product Browsing Need Authentication?
The product requirement currently defines customer browsing but does not require us to make the catalog either:
public
or:
authenticated-only
as a separate rule here।
Module 8 will wire endpoint access policies।
The UseCase itself does not care how HTTP authentication is performed։
Read Transaction
We used:
@Transactional(readOnly = true)
on:
ListOrderableProductsUseCase
This communicates that the operation is read-only database work।
It is not a security mechanism and not a guarantee that arbitrary code physically cannot mutate anything।
The important architectural point remains:
the transaction boundary follows the UseCase
Index Review
Our query filters:
p.active = true
and:
i.availableQuantity > 0
and orders by:
p.name
p.id
Should we immediately add:
products(active, name, id)
or:
inventory(available_quantity)
indexes?
No।
Previous lesson established:
indexes follow evidence
Existing primary keys already support:
Product ↔ Inventory join
and our local dataset is small।
We first implement the correct query।
If production measurements later show this browse query needs additional indexing, we use:
EXPLAIN
EXPLAIN ANALYZE
and create a Flyway migration based on evidence।
Why No Product Name Index Yet?
We now sort by:
name ASC
but that alone does not justify an index immediately।
Whether an index helps depends on:
table size
filter selectivity
query plan
how many Products are active/in stock
pagination depth
Do not turn a newly selected sort into automatic schema complexity।
Count Query Cost
Because our page response includes:
totalItems
totalPages
one browse request generally involves:
page query
+
count query
At current scale this is an accepted trade-off।
If exact catalog counts later become expensive, changing pagination semantics is an API decision—not something Repository should silently approximate।
No Inventory N+1
This is worth emphasizing.
Bad implementation:
load Product page
for each Product:
load Inventory
Correct implementation:
one relational Product + Inventory page query
The number of Products on the page should not determine the number of Inventory database calls।
No ProductEntity.inventory
Likewise, we did not fix the N+1 problem by changing to:
@OneToOne(fetch = FetchType.EAGER)
That would make Inventory loading implicit every time Product is loaded—even in operations that do not need it।
Instead:
query asks for Inventory
only when the operation needs Inventory
This is more deliberate।
Repository Query vs Domain Model
The Domain model says:
Product
and:
Inventory
are separate concepts։
The browse query says:
for this read operation,
combine their persisted state
Both statements can be true simultaneously।
Your relational query model does not have to look identical to your domain object graph।
Testing Product Browsing
Important integration scenarios:
active Product + positive Inventory
→ included
active Product + zero Inventory
→ excluded
inactive Product + positive Inventory
→ excluded
active Product + no Inventory row
→ excluded
inactive Product + zero Inventory
→ excluded
Ordering Test
Create:
USB-C Dock
Mechanical Keyboard
Mechanical Keyboard
Browse results should order:
Mechanical Keyboard
Mechanical Keyboard
USB-C Dock
and the two equal names should have deterministic UUID ordering:
id ASC
Do not write tests that assume insertion order।
Pagination Test
Suppose:
25 orderable Products
Request:
page = 0
size = 20
Expected:
items = 20
totalItems = 25
totalPages = 2
Then:
page = 1
size = 20
returns:
items = 5
Filter Before Pagination Test
This test is especially important।
Create:
15 inactive Products
10 active Products with Inventory > 0
Then request:
page = 0
size = 10
Expected:
10 orderable Products
not:
some smaller number because inactive rows were paginated first
This proves filtering happens in PostgreSQL before pagination।
No Inventory Quantity in API Test
Even though persistence query reads:
availableQuantity
for filtering, customer response should not accidentally expose it।
Transport tests should verify the public response remains:
id
name
price
only।
Testing Repository Instead of Mocking the Query
A Product browse query is relational persistence behaviour।
Its correctness depends on:
JOIN
filters
count
ordering
pagination
Therefore it deserves a PostgreSQL integration test।
Mocking:
ProductJpaRepository
cannot prove the query itself works।
UseCase Test Remains Simple
ListOrderableProductsUseCase contains almost no logic beyond delegating the application query।
A unit test can verify it returns Repository results, but the high-value tests live at:
Repository integration
and:
HTTP boundary
Don't create dozens of low-value mocks just to increase test count।
Capability Flow
Our Product capability now looks like:
ProductHandler
├── POST /products
│ ↓
│ CreateProductUseCase
│
├── PATCH /products/{id}
│ ↓
│ UpdateProductUseCase
│
├── POST /products/{id}/deactivate
│ ↓
│ DeactivateProductUseCase
│
└── GET /products
↓
ListOrderableProductsUseCase
All persistence still goes through:
ProductRepository
with operation-specific methods only where justified।
Current Product State vs Customer Catalog
An important distinction:
products table
may contain:
100 Products
but customer catalog may contain only:
63
because:
15 inactive
22 out of stock
The customer catalog is a query-derived view of current business state।
It does not need its own:
catalog_products
table।
No Cached Catalog Yet
We do not introduce:
Redis
search engine
materialized catalog table
just because Product browsing exists।
PostgreSQL can serve this straightforward relational query।
Additional infrastructure should follow demonstrated needs։
Product Browsing and Future Search
We have not implemented:
search=keyboard
category=...
priceMin=...
priceMax=...
because those requirements do not exist।
Adding generic filtering now would create:
API complexity
query complexity
index requirements
without product value।
Product Browsing and Arbitrary Sort
Likewise we do not expose:
?sort=price,desc
yet।
Our v1 collection has one deterministic default:
name ASC
id ASC
If later requirements call for:
price sorting
newest sorting
search relevance
we can extend the API deliberately।
What We Deliberately Did Not Add
We did not add:
Product ↔ Inventory JPA association
EAGER Inventory loading
Inventory quantity in customer response
available column on products
catalog table
Redis
search engine
arbitrary sorting
generic filtering framework
Product browse cache
category / SKU / brand filtering
None are required for the current Product browsing operation।
Engineering Principle
The core principle:
Customer-facing collections should represent the actual business-visible dataset before pagination is applied. For Product browsing, that means filtering by both Product state and Inventory state inside PostgreSQL.
Another:
A read operation may join multiple relational concepts without forcing those concepts into a permanent JPA object relationship. Query shape should follow the use case.
And:
Use projections for collection reads when the endpoint needs only a small subset of persisted state; rich domain reconstruction is not mandatory for every query.
Summary
In this lesson, we implemented:
GET /api/v1/products?page=0&size=20
and established that:
- Customer Product browsing returns only currently orderable Products.
- A Product is orderable when
active = trueandavailableQuantity > 0. - A Product with no Inventory row is not orderable.
- Product and Inventory remain separate domain/persistence concepts.
- We do not add a JPA
Product ↔ Inventoryrelationship merely for browsing. - PostgreSQL joins Product and Inventory directly for the read operation.
- Customer browsing does not expose exact Inventory quantity.
- Product browsing uses database-level pagination.
- Filtering happens before pagination.
- The initial deterministic Product order is
name ASC, id ASC. - UUID acts only as a unique tie-breaker, not chronology.
- Product browsing uses a focused
ProductSummaryrather than reconstructing full Product aggregates. - This focused read model does not introduce CQRS.
ProductRepository.findOrderable(PageQuery)expresses a real application query.- Spring Data
Pageable,Page, and persistence projections stay inside infrastructure. - The page-data query and count query use the same orderability filters.
- Inventory's shared primary key guarantees at most one Inventory row per Product.
- The browse implementation avoids an Inventory N+1 query pattern.
- We do not solve N+1 by making Inventory an eager association.
- Browse price is current Product price and is not a guaranteed future Order quote.
- Order creation will reload current Product state and capture server-side price.
- Empty Product collections return
200with emptyitems. - Invalid page/size values produce
VALIDATION_ERROR. - We do not add speculative Product browse indexes without measurement.
- We do not add search, arbitrary filters, arbitrary sorting, caching, Redis, or a catalog table.
GET /products/{productId}remains part of the API contract, but its role-sensitive CUSTOMER vs ADMIN visibility is intentionally completed when authorization context is introduced.
Next lesson:
Implementing Inventory Management
There we will make Inventory a working capability through:
GET /api/v1/inventory
GET /api/v1/inventory/{productId}
PUT /api/v1/inventory/{productId}
and implement Product existence checks, UUID-based shared identity, non-negative quantity rules, create-or-update semantics, admin-facing inventory reads, and the boundary between ordinary Inventory administration and the concurrency-safe Inventory consumption required by Order creation.