Implementing Business Workflows

Implementing Product Browsing

ReadingPreview

আপনি একটি 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 = true and availableQuantity > 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 ↔ Inventory relationship 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 ProductSummary rather 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 200 with empty items.
  • 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.