Implementing Business Workflows
Implementing Inventory Management
আপনি একটি free preview lesson দেখছেন।
এখন আমাদের Product capability বাস্তবে কাজ করছে।
Admin পারে:
POST /api/v1/products
PATCH /api/v1/products/{productId}
POST /api/v1/products/{productId}/deactivate
Customer-facing browse query-ও জানে একটি Product orderable হতে হলে:
Product.active = true
AND
Inventory.availableQuantity > 0
কিন্তু Inventory state নিজে এখনও application workflow হিসেবে implement করা হয়নি।
এই lesson-এ আমরা implement করব:
GET /api/v1/inventory
GET /api/v1/inventory/{productId}
PUT /api/v1/inventory/{productId}
এবং establish করব:
Inventory identity
→ ProductId
availableQuantity
→ non-negative whole number
admin update
→ set current quantity
Order creation
→ consume quantity
শেষের দুটো একই জিনিস নয়।
এই lesson-এর সবচেয়ে গুরুত্বপূর্ণ architectural distinction:
Admin Inventory management বলে “available quantity এখন কত হবে”, আর Order creation বলে “এই quantity safely consume করা যাবে কি না”।
প্রথমটি আমরা এখন implement করব।
দ্বিতীয়টির concurrency-safe implementation পরে Order workflow-এর সঙ্গে করব।
Inventory Is a Separate Capability
আমাদের Product:
Product
├── ProductId
├── name
├── price
└── active
Inventory:
Inventory
├── ProductId
└── availableQuantity
Product-এর মধ্যে:
int stock;
রাখিনি।
কারণ:
Product
→ catalog/business information
আর:
Inventory
→ currently available quantity
দুটি আলাদা responsibility।
একটি Product inactive হতে পারে কিন্তু physical Inventory থাকতে পারে।
একটি Product active হতে পারে কিন্তু Inventory zero হতে পারে।
Inventory Has No Separate Identity
আমাদের database schema:
CREATE TABLE inventory (
product_id UUID PRIMARY KEY,
available_quantity INTEGER NOT NULL,
CONSTRAINT inventory_product_fk
FOREIGN KEY (product_id)
REFERENCES products(id),
CONSTRAINT inventory_quantity_non_negative
CHECK (available_quantity >= 0)
);
Notice:
inventory.id
নেই।
Inventory-এর identity already determined by:
ProductId
কারণ v1-এ:
one Product
→ at most one Inventory row
আমাদের নেই:
warehouses
inventory locations
stock batches
তাই separate InventoryId কোনো domain value যোগ করবে না।
UUID Is Shared Across the Relationship
Suppose Product:
0b10cf5e-33d9-40d4-8594-aa3a6ecbc6a1
তাহলে Inventory row:
product_id
→ 0b10cf5e-33d9-40d4-8594-aa3a6ecbc6a1
একই identifier ব্যবহার করে।
Domain:
ProductId
Persistence:
UUID productId
PostgreSQL:
UUID
HTTP:
UUID textual representation
কোনো artificial conversion প্রয়োজন নেই।
Inventory Domain Model
একটি focused Inventory model হতে পারে:
package io.liveklass.ordermanagement.inventory.domain;
import io.liveklass.ordermanagement.product.domain.ProductId;
import java.util.Objects;
public final class Inventory {
private final ProductId productId;
private int availableQuantity;
private Inventory(
ProductId productId,
int availableQuantity
) {
this.productId =
Objects.requireNonNull(
productId,
"productId must not be null"
);
requireNonNegative(
availableQuantity
);
this.availableQuantity =
availableQuantity;
}
public static Inventory create(
ProductId productId,
int availableQuantity
) {
return new Inventory(
productId,
availableQuantity
);
}
public static Inventory reconstitute(
ProductId productId,
int availableQuantity
) {
return new Inventory(
productId,
availableQuantity
);
}
public ProductId productId() {
return productId;
}
public int availableQuantity() {
return availableQuantity;
}
public void setAvailableQuantity(
int availableQuantity
) {
requireNonNegative(
availableQuantity
);
this.availableQuantity =
availableQuantity;
}
public void decrease(
int quantity
) {
if (quantity <= 0) {
throw new IllegalArgumentException(
"Quantity must be positive"
);
}
if (quantity > availableQuantity) {
throw new IllegalStateException(
"Insufficient inventory"
);
}
availableQuantity -= quantity;
}
public void increase(
int quantity
) {
if (quantity <= 0) {
throw new IllegalArgumentException(
"Quantity must be positive"
);
}
availableQuantity += quantity;
}
private static void requireNonNegative(
int quantity
) {
if (quantity < 0) {
throw new IllegalArgumentException(
"Available quantity must not be negative"
);
}
}
}
এখানে তিনটি behaviour দেখা যাচ্ছে:
setAvailableQuantity()
admin operation-এর জন্য।
decrease()
Order creation-এর domain-level quantity invariant-এর জন্য।
increase()
Order cancellation-এর জন্য।
কিন্তু মনে রাখতে হবে:
decrease()in-memory invariant protect করে, concurrent database requests safely serialize করে না।
সেটি পরে persistence strategy দিয়ে solve করতে হবে।
Admin Inventory Semantics: Set, Not Add
আমাদের API:
PUT /api/v1/inventory/{productId}
Request:
{
"availableQuantity": 50
}
এর meaning:
Product-এর available Inventory এখন 50 units।
এটি নয়:
Existing quantity-এর সঙ্গে 50 যোগ করো।
অর্থাৎ:
current = 20
request = 50
result:
50
না যে:
70
Why Set Semantics?
Admin interface-এ explicit current quantity set করা সহজ এবং predictable।
For example, warehouse reconciliation-এর পরে admin জানে:
actual available quantity = 37
তখন:
PUT /inventory/{productId}
{
"availableQuantity": 37
}
exact desired state express করে।
আমরা এখন generic:
add 5
subtract 2
adjust -7
inventory ledger বানাচ্ছি না।
PUT Fits This Operation
PUT naturally fits because request expresses desired current state:
availableQuantity = 37
Repeated request:
PUT /inventory/{productId}
{
"availableQuantity": 37
}
leaves the same final state।
So operation is naturally:
idempotent in effect
SetInventoryRequest
HTTP DTO:
package io.liveklass.ordermanagement.inventory.handler;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
public record SetInventoryRequest(
@NotNull
@PositiveOrZero
Integer availableQuantity
) {
}
Why Integer rather than int?
Because JSON body:
{}
needs to be distinguishable from:
{
"availableQuantity": 0
}
With primitive int, missing value could become:
0
and accidentally appear valid।
Using:
Integer
allows:
@NotNull
to detect the missing field।
Zero Is Valid
This request is valid:
{
"availableQuantity": 0
}
because:
zero inventory
is a perfectly valid business state।
It simply means:
Product currently has no available units
If Product is active, customer browsing still excludes it because:
availableQuantity > 0
is required।
Negative Quantity Is Invalid
Request:
{
"availableQuantity": -1
}
fails:
400 VALIDATION_ERROR
Example:
{
"type": "https://api.liveklass.io/problems/validation-error",
"title": "Validation failed",
"status": 400,
"detail": "One or more fields are invalid.",
"instance": "/api/v1/inventory/0b10cf5e-33d9-40d4-8594-aa3a6ecbc6a1",
"code": "VALIDATION_ERROR",
"errors": [
{
"field": "availableQuantity",
"code": "INVALID_VALUE",
"message": "Available quantity must be greater than or equal to 0."
}
]
}
Validation happens before UseCase।
Domain এবং database-ও একই structural invariant reinforce করে।
Product Must Exist
Can admin set Inventory for:
random ProductId
that doesn't exist?
No।
Our schema foreign key would eventually reject it:
inventory.product_id
→ products.id
but this is an expected application condition।
We should not depend on a raw foreign-key violation to discover:
Product does not exist
SetInventoryUseCase should explicitly check Product existence।
Why ProductRepository Is Needed
Set Inventory workflow:
ProductId
↓
ProductRepository
↓
Product exists?
then:
InventoryRepository
↓
load existing Inventory
then:
create or update
This is cross-capability application coordination।
Exactly the kind of work a UseCase should do।
SetInventoryUseCase
Conceptually:
load Product
↓
if missing → PRODUCT_NOT_FOUND
↓
load Inventory
↓
if exists → set quantity
if absent → create Inventory
↓
persist
Implementation:
package io.liveklass.ordermanagement.inventory.usecase;
import io.liveklass.ordermanagement.inventory.domain.Inventory;
import io.liveklass.ordermanagement.inventory.repository.InventoryRepository;
import io.liveklass.ordermanagement.product.domain.ProductId;
import io.liveklass.ordermanagement.product.repository.ProductRepository;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class SetInventoryUseCase {
private final ProductRepository
productRepository;
private final InventoryRepository
inventoryRepository;
public SetInventoryUseCase(
ProductRepository productRepository,
InventoryRepository inventoryRepository
) {
this.productRepository =
productRepository;
this.inventoryRepository =
inventoryRepository;
}
@Transactional
public Inventory execute(
ProductId productId,
int availableQuantity
) {
productRepository
.findById(productId)
.orElseThrow(
ProductNotFoundException::new
);
Inventory inventory =
inventoryRepository
.findByProductId(
productId
)
.orElseGet(
() ->
Inventory.create(
productId,
availableQuantity
)
);
inventory.setAvailableQuantity(
availableQuantity
);
inventoryRepository.save(
inventory
);
return inventory;
}
}
There is one small redundancy:
For newly created Inventory:
Inventory.create(
productId,
availableQuantity
);
already sets the quantity।
Then:
setAvailableQuantity(...)
sets it again।
We can make the workflow clearer।
Cleaner Create-or-Update Logic
@Transactional
public Inventory execute(
ProductId productId,
int availableQuantity
) {
productRepository
.findById(productId)
.orElseThrow(
ProductNotFoundException::new
);
Inventory inventory =
inventoryRepository
.findByProductId(
productId
)
.map(
existing -> {
existing
.setAvailableQuantity(
availableQuantity
);
return existing;
}
)
.orElseGet(
() ->
Inventory.create(
productId,
availableQuantity
)
);
inventoryRepository.save(
inventory
);
return inventory;
}
Now semantics are explicit:
exists
→ update
missing
→ create
Is This an Upsert?
At application level, yes, conceptually:
set Inventory
works whether Inventory row already exists or not।
But we do not need to implement database-specific:
INSERT ... ON CONFLICT ...
yet।
Why?
This is admin inventory management, not a high-frequency concurrency-critical Order operation।
A normal:
find
→ create/update
→ save
flow is currently sufficient।
What About Two Admins Creating Inventory Simultaneously?
Because:
product_id
is the primary key, PostgreSQL prevents two Inventory rows for the same Product।
A rare concurrent first-time set could result in one operation encountering a uniqueness/persistence conflict depending on timing।
We do not introduce sophisticated concurrency handling for this admin workflow unless requirements demand it।
The critical concurrency problem is:
multiple customers consuming limited stock
which we handle separately।
Product Active State Does Not Block Inventory Administration
Suppose Product:
active = false
Can admin set:
availableQuantity = 50
?
Yes।
Current requirements do not say Inventory can only exist for active Products।
Inventory and Product lifecycle are separate।
An inactive Product may physically have stock।
The Product simply remains excluded from new customer Orders because:
active = false
InventoryResponse
Admin response:
package io.liveklass.ordermanagement.inventory.handler;
import io.liveklass.ordermanagement.inventory.domain.Inventory;
import java.util.UUID;
public record InventoryResponse(
UUID productId,
int availableQuantity
) {
public static InventoryResponse from(
Inventory inventory
) {
return new InventoryResponse(
inventory
.productId()
.value(),
inventory
.availableQuantity()
);
}
}
Example:
{
"productId": "0b10cf5e-33d9-40d4-8594-aa3a6ecbc6a1",
"availableQuantity": 50
}
Admin explicitly needs the quantity, unlike Customer Product browsing।
Set Inventory Handler
@PutMapping("/{productId}")
public ResponseEntity<InventoryResponse>
setInventory(
@PathVariable
UUID productId,
@Valid
@RequestBody
SetInventoryRequest request
) {
Inventory inventory =
setInventoryUseCase.execute(
new ProductId(
productId
),
request.availableQuantity()
);
return ResponseEntity.ok(
InventoryResponse.from(
inventory
)
);
}
Handler responsibilities:
parse UUID
validate request
create ProductId
invoke UseCase
map result
No direct repository access।
Why 200 OK?
The same PUT can:
create first Inventory state
or:
replace existing quantity
We could distinguish:
201 on first creation
200 on later update
but the client operation is fundamentally:
Set current Inventory for this Product.
For simplicity and consistency, our API returns:
200 OK
with the resulting Inventory representation।
We don't require clients to care whether the underlying row existed before।
Get Inventory by Product
Admin also needs:
GET /api/v1/inventory/{productId}
UseCase:
package io.liveklass.ordermanagement.inventory.usecase;
import io.liveklass.ordermanagement.inventory.domain.Inventory;
import io.liveklass.ordermanagement.inventory.repository.InventoryRepository;
import io.liveklass.ordermanagement.product.domain.ProductId;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class GetInventoryUseCase {
private final InventoryRepository
inventoryRepository;
public GetInventoryUseCase(
InventoryRepository inventoryRepository
) {
this.inventoryRepository =
inventoryRepository;
}
@Transactional(readOnly = true)
public Inventory execute(
ProductId productId
) {
return inventoryRepository
.findByProductId(
productId
)
.orElseThrow(
InventoryNotFoundException::new
);
}
}
Product Missing vs Inventory Missing
There is an interesting distinction।
Suppose:
Product does not exist
Then naturally:
Inventory does not exist
because foreign key prevents orphan Inventory।
But suppose Product exists and no Inventory row has ever been set।
Then:
Product exists
Inventory missing
is valid state in our model।
For:
GET /inventory/{productId}
we can return:
404 INVENTORY_NOT_FOUND
when no Inventory row exists।
This is more truthful than pretending:
missing row = quantity 0
because we deliberately did not establish that equivalence।
Why Not Treat Missing Inventory as Zero?
These states are currently distinct:
Inventory row absent
and:
Inventory row exists with quantity = 0
For customer orderability both result in:
not orderable
But administratively they can mean different things:
never initialized
versus:
explicitly initialized to zero
We should not collapse them without a requirement।
InventoryNotFound Application Error
Conceptually:
INVENTORY_NOT_FOUND
HTTP mapping:
404
Example:
{
"type": "https://api.liveklass.io/problems/inventory-not-found",
"title": "Inventory not found",
"status": 404,
"detail": "Inventory has not been configured for the requested product.",
"instance": "/api/v1/inventory/0b10cf5e-33d9-40d4-8594-aa3a6ecbc6a1",
"code": "INVENTORY_NOT_FOUND"
}
Again, UseCase does not construct this JSON।
Get Inventory Handler
@GetMapping("/{productId}")
public ResponseEntity<InventoryResponse>
getInventory(
@PathVariable
UUID productId
) {
Inventory inventory =
getInventoryUseCase.execute(
new ProductId(
productId
)
);
return ResponseEntity.ok(
InventoryResponse.from(
inventory
)
);
}
Listing Inventory
The API contract also includes:
GET /api/v1/inventory
An unbounded:
findAll()
would contradict everything we learned about database collection queries।
So the admin Inventory list should also be bounded।
We will reuse the same collection rules:
page
→ default 0
size
→ default 20
→ maximum 100
This is not introducing a new pagination framework।
It applies the already-established bounded collection discipline to another collection endpoint।
Inventory Summary
For admin list:
public record InventorySummary(
ProductId productId,
int availableQuantity
) {
}
We do not need a richer domain aggregate for a read-only collection result।
InventoryRepository
Application Repository can now become:
package io.liveklass.ordermanagement.inventory.repository;
import io.liveklass.ordermanagement.inventory.domain.Inventory;
import io.liveklass.ordermanagement.inventory.usecase.InventorySummary;
import io.liveklass.ordermanagement.product.domain.ProductId;
import io.liveklass.ordermanagement.shared.pagination.PageQuery;
import io.liveklass.ordermanagement.shared.pagination.PageResult;
import java.util.Optional;
public interface InventoryRepository {
Optional<Inventory> findByProductId(
ProductId productId
);
void save(
Inventory inventory
);
PageResult<InventorySummary> findAll(
PageQuery pageQuery
);
}
Here:
findAll(PageQuery)
is bounded।
It is not the dangerous:
load every Inventory row
kind of findAll()।
Still, a more explicit name such as:
findPage(PageQuery pageQuery)
may communicate the intent better।
Let's prefer:
PageResult<InventorySummary> findPage(
PageQuery pageQuery
);
Deterministic Inventory Ordering
Inventory is keyed by:
ProductId
but random UUID ordering is not especially meaningful to admins।
We could join Product and display/order by Product name, but that would expand the current requirement।
The inventory API currently only promises inventory state, not catalog metadata।
For a deterministic technical ordering, we can use:
product_id ASC
for now।
This is not claiming UUID order has business meaning।
It is simply a stable tie-free ordering for pagination।
If the admin UX later requires Product name in the Inventory list, the query contract can evolve deliberately।
InventoryEntity
Persistence entity:
package io.liveklass.ordermanagement.inventory.persistence;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.UUID;
@Entity
@Table(name = "inventory")
public class InventoryEntity {
@Id
@Column(
name = "product_id",
nullable = false
)
private UUID productId;
@Column(
name = "available_quantity",
nullable = false
)
private int availableQuantity;
protected InventoryEntity() {
}
public InventoryEntity(
UUID productId,
int availableQuantity
) {
this.productId =
productId;
this.availableQuantity =
availableQuantity;
}
public UUID getProductId() {
return productId;
}
public int getAvailableQuantity() {
return availableQuantity;
}
}
Again:
no generated Inventory ID
because:
ProductId
is the identifier।
No @OneToOne ProductEntity
We still deliberately avoid:
@OneToOne
@MapsId
private ProductEntity product;
for the current model।
Database already enforces:
inventory.product_id
REFERENCES products(id)
JPA association adds no application value for the workflows we currently have।
Inventory needs:
ProductId
not:
whole ProductEntity graph
Spring Data Repository
package io.liveklass.ordermanagement.inventory.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
interface InventoryJpaRepository
extends JpaRepository<
InventoryEntity,
UUID
> {
}
Its ID type is:
UUID
because product_id is the primary key।
Persistence Adapter
Conceptually:
@Repository
public class JpaInventoryRepository
implements InventoryRepository {
private final InventoryJpaRepository
repository;
public JpaInventoryRepository(
InventoryJpaRepository repository
) {
this.repository =
repository;
}
@Override
public Optional<Inventory>
findByProductId(
ProductId productId
) {
return repository
.findById(
productId.value()
)
.map(
this::toDomain
);
}
@Override
public void save(
Inventory inventory
) {
repository.save(
toEntity(
inventory
)
);
}
private InventoryEntity toEntity(
Inventory inventory
) {
return new InventoryEntity(
inventory
.productId()
.value(),
inventory
.availableQuantity()
);
}
private Inventory toDomain(
InventoryEntity entity
) {
return Inventory.reconstitute(
new ProductId(
entity.getProductId()
),
entity.getAvailableQuantity()
);
}
}
The adapter owns:
Domain ↔ JPA entity
conversion।
A Note About Assigned UUID IDs and JPA
Our entities use application-assigned UUIDs rather than database-generated IDs।
That means a new entity already has:
non-null ID
before persistence।
Spring Data/JPA entity-state detection can therefore require deliberate handling when deciding whether an entity should be treated as new or existing।
For this course, the important architectural lesson is:
Application Repository decides the persistence intent; UseCase should not depend on JPA's internal new/entity detection rules.
As our persistence implementation matures, the adapter can explicitly implement create/update behaviour where needed rather than letting assigned UUID identity leak into application logic।
We do not solve that with fake null IDs or database sequences।
Inventory List Persistence Query
For admin listing, we need:
bounded
deterministic
database-level pagination
The query is conceptually simple:
SELECT
product_id,
available_quantity
FROM inventory
ORDER BY product_id ASC
LIMIT ?
OFFSET ?;
No Product join is required for the current response contract।
ListInventoryUseCase
@Component
public class ListInventoryUseCase {
private final InventoryRepository
inventoryRepository;
public ListInventoryUseCase(
InventoryRepository inventoryRepository
) {
this.inventoryRepository =
inventoryRepository;
}
@Transactional(readOnly = true)
public PageResult<InventorySummary> execute(
PageQuery pageQuery
) {
return inventoryRepository
.findPage(
pageQuery
);
}
}
Again, the UseCase stays small because this is primarily a query operation।
Inventory Handler
The capability now conceptually exposes:
@RestController
@RequestMapping("/api/v1/inventory")
public class InventoryHandler {
private final SetInventoryUseCase
setInventoryUseCase;
private final GetInventoryUseCase
getInventoryUseCase;
private final ListInventoryUseCase
listInventoryUseCase;
// constructor...
@GetMapping
public PageResponse<InventoryResponse>
listInventory(...) {
...
}
@GetMapping("/{productId}")
public InventoryResponse getInventory(...) {
...
}
@PutMapping("/{productId}")
public InventoryResponse setInventory(...) {
...
}
}
We do not create:
InventoryService
between Handler and UseCases।
Admin Authorization Comes Later
These Inventory operations are admin capabilities:
view Inventory
set Inventory
Module 8 will enforce:
ADMIN
authorization।
We don't add temporary request fields such as:
{
"isAdmin": true
}
and we don't inject fake role checks into Inventory domain logic।
Product Browsing Updates Automatically
Once admin executes:
PUT /api/v1/inventory/{productId}
with:
{
"availableQuantity": 10
}
the customer Product browse query:
active = true
AND
availableQuantity > 0
will naturally include the Product if it is active।
No separate:
publish product
workflow is required।
Setting Inventory to Zero
Request:
{
"availableQuantity": 0
}
persists:
0
Then Product browse automatically excludes the Product।
No Product state changes։
Product remains:
active = true
but currently:
out of stock
Deactivated Product With Positive Inventory
Suppose:
active = false
availableQuantity = 50
Admin inventory endpoint still returns:
{
"productId": "...",
"availableQuantity": 50
}
Customer Product browse excludes the Product because:
active = false
This demonstrates clean separation between:
catalog lifecycle
and:
physical availability
Inventory Management vs Order Consumption
This distinction is critical।
Admin operation:
SetInventoryUseCase
means:
make current available quantity = X
Order operation will mean:
consume N units only if N units are still available
These should not share one generic method such as:
updateInventory(
ProductId,
int quantity
);
because the semantics are different।
Why setAvailableQuantity() Is Not Used by Create Order
Bad Create Order implementation:
Inventory inventory =
inventoryRepository
.findByProductId(id)
.orElseThrow();
inventory.setAvailableQuantity(
inventory.availableQuantity()
- requestedQuantity
);
This treats order consumption as arbitrary state assignment।
Better domain semantics:
inventory.decrease(
requestedQuantity
);
And for actual concurrent persistence, we may need an even more specialized Repository operation।
Concurrency Problem Preview
Suppose Inventory:
availableQuantity = 1
Two Customers create Orders simultaneously։
Request A reads:
1
Request B reads:
1
Both conclude:
enough inventory
Both decrease their in-memory state:
0
Both attempt persistence।
A non-negative database constraint does not necessarily detect that both Orders consumed the same logical unit।
Therefore:
find
→ check
→ save
is not yet our final Order-consumption strategy।
We Are Not Solving That in Admin PUT
This lesson should not suddenly introduce:
SELECT FOR UPDATE
optimistic @Version
atomic SQL decrement
inside normal Inventory administration।
Those techniques matter when multiple Order requests compete for finite stock।
We'll choose the persistence strategy in the dedicated Inventory-consumption lesson।
Cancellation Uses Different Semantics Again
Order cancellation will mean:
restore exactly the quantity consumed by the Order
Conceptually:
inventory.increase(
orderItem.quantity()
);
Again, not:
set quantity to some client-supplied number
So we have three clearly distinct actions:
Admin
→ set quantity
Order creation
→ consume quantity
Order cancellation
→ restore quantity
This clarity will matter when workflows become transactional।
No Inventory Ledger Yet
We do not create:
inventory_movements
stock_adjustments
inventory_events
because the current requirements only need current available quantity։
A ledger could provide:
audit history
reconciliation
movement reasons
but that is additional product scope।
We do not introduce it without need।
No Reservations
Likewise, Order creation directly consumes available Inventory।
We do not create:
inventory_reservations
reserved_quantity
reservation_expiry
Our accepted v1 model remains:
Order created
→ available Inventory decreases
Order cancelled
→ available Inventory restored
No Warehouse Model
Inventory is still:
one Product
→ one available quantity
There is no:
Tallinn warehouse
Riga warehouse
Dhaka warehouse
or routing between locations।
The schema intentionally matches this simpler business model।
Error Cases for Set Inventory
Expected application errors currently include:
PRODUCT_NOT_FOUND
because Inventory cannot be configured for an unknown Product।
Request validation handles:
missing availableQuantity
negative availableQuantity
There is no:
PRODUCT_INACTIVE
error because inactive Products may still have Inventory।
There is no:
INVENTORY_ALREADY_EXISTS
because PUT updates existing state naturally।
Error Cases for Get Inventory
Possible expected result:
INVENTORY_NOT_FOUND
when Product has no configured Inventory row।
Malformed UUID:
HTTP invalid request
Unexpected PostgreSQL failure:
system error
Do not collapse them into one generic:
Inventory failed
exception।
Testing Inventory Domain
Important domain tests:
create with zero
→ succeeds
create with positive quantity
→ succeeds
create with negative quantity
→ rejects
set quantity to zero
→ succeeds
set negative quantity
→ rejects
decrease valid quantity
→ succeeds
decrease more than available
→ rejects locally
increase positive quantity
→ succeeds
These tests require no Spring or PostgreSQL।
Testing SetInventoryUseCase
Scenario:
Product exists
Inventory absent
Expected:
Inventory created
Example:
Inventory inventory =
useCase.execute(
product.id(),
50
);
assertEquals(
50,
inventory.availableQuantity()
);
Updating Existing Inventory
Existing:
20
Then:
set 50
Expected:
50
not:
70
This test protects the admin operation semantics।
Unknown Product
If:
ProductRepository
→ empty
then:
SetInventoryUseCase
→ PRODUCT_NOT_FOUND
and:
InventoryRepository.save()
should never become the mechanism for discovering that expected condition।
Persistence Integration Tests
Repository tests against PostgreSQL should verify:
product_id UUID round-trip
quantity persists
quantity can be updated
negative quantity constraint rejects invalid persistence
Product FK prevents orphan Inventory
one Inventory row per Product
pagination is bounded
These are database concerns and deserve real PostgreSQL tests।
Product Must Exist Before Inventory in Integration Test
Because of:
FOREIGN KEY (product_id)
REFERENCES products(id)
test setup must create Product first।
This is not test inconvenience।
It proves the relational rule we intentionally designed।
API Tests
Useful HTTP scenarios:
PUT existing Product with quantity 50
→ 200
PUT same Product with quantity 0
→ 200
PUT quantity -1
→ 400 VALIDATION_ERROR
PUT unknown Product
→ 404 PRODUCT_NOT_FOUND
GET configured Inventory
→ 200
GET Product with no Inventory
→ 404 INVENTORY_NOT_FOUND
GET Inventory page
→ bounded response
Authorization tests are added in Module 8।
Cross-Capability Integration Test
One especially valuable test connects the capabilities we've built।
- Create Product:
active = true
- Product browse:
Product absent
because no Inventory exists।
- Set Inventory:
availableQuantity = 5
- Product browse:
Product appears
- Set Inventory:
availableQuantity = 0
- Product browse:
Product disappears
This test demonstrates that:
Product orderability
is correctly derived from Product + Inventory state।
Another Cross-Capability Test
- Product active.
- Inventory = 10.
- Product appears in customer browsing.
- Deactivate Product.
- Inventory remains 10.
- Product disappears from customer browsing.
- Admin Inventory still reports 10.
This proves:
Product lifecycle
and:
Inventory quantity
are independent facts।
Capability Structure
Our codebase now starts looking like:
product/
├── domain/
│ ├── Product.java
│ └── ProductId.java
├── handler/
├── usecase/
├── repository/
└── persistence/
inventory/
├── domain/
│ └── Inventory.java
├── handler/
│ ├── InventoryHandler.java
│ ├── InventoryResponse.java
│ └── SetInventoryRequest.java
├── usecase/
│ ├── SetInventoryUseCase.java
│ ├── GetInventoryUseCase.java
│ └── ListInventoryUseCase.java
├── repository/
│ └── InventoryRepository.java
└── persistence/
├── InventoryEntity.java
├── InventoryJpaRepository.java
└── JpaInventoryRepository.java
Still:
one Spring Boot application
one JVM
one PostgreSQL
These are capability boundaries, not microservices।
What We Deliberately Did Not Add
We did not add:
InventoryId
ProductEntity.inventory
InventoryEntity.product
warehouse
reservation
reservedQuantity
stock movement ledger
audit history
automatic Inventory creation with Product
automatic zero quantity for missing Inventory
inventory deletion
increment/decrement admin API
optimistic locking
SELECT FOR UPDATE
Redis locks
distributed locks
None belong in the current admin Inventory workflow।
Engineering Principle
The core principle:
Inventory is identified by ProductId because v1 has exactly one current Inventory state per Product. Do not invent a separate identity when the domain does not have one.
Another:
Admin Inventory management sets the desired current quantity. Order creation and cancellation represent different business operations—consume and restore—and should not be hidden behind one generic update method.
And:
Expected application conditions such as an unknown Product should be handled deliberately by the UseCase, while PostgreSQL foreign keys and CHECK constraints remain the final structural integrity boundary.
Summary
In this lesson, we implemented the Inventory capability around:
GET /api/v1/inventory
GET /api/v1/inventory/{productId}
PUT /api/v1/inventory/{productId}
We established that:
- Inventory uses ProductId as its identity.
product_idis a UUID primary key and foreign key.- There is no separate InventoryId.
- One Product can have at most one Inventory row.
availableQuantitymust be a non-negative whole number.- Zero Inventory is a valid state.
- Admin Inventory updates use set semantics rather than increment semantics.
PUTexpresses the desired current quantity and is idempotent in effect.- A Product must exist before Inventory can be configured.
SetInventoryUseCasecoordinates ProductRepository and InventoryRepository.- Missing Inventory and zero Inventory remain distinct states.
GET /inventory/{productId}can returnINVENTORY_NOT_FOUNDwhen no Inventory has been configured.- Inactive Products may still have positive Inventory.
- Setting Inventory does not change Product active state.
- Product browsing automatically reacts to Inventory changes.
- Customer Product browsing still does not expose exact stock quantity.
- Admin Inventory collections are bounded rather than implemented with an unbounded
findAll(). - Inventory persistence uses UUID
product_iddirectly. - No JPA Product ↔ Inventory association is required.
- Admin Inventory setting and Order-time Inventory consumption are different operations.
- A simple load/check/save workflow is not automatically concurrency-safe for Order consumption.
- We deliberately defer the concrete concurrency strategy to the Order workflow.
- Order creation will consume Inventory.
- Order cancellation will restore Inventory.
- We do not introduce reservations, warehouses, stock ledgers, distributed locks, or speculative concurrency infrastructure.
- PostgreSQL foreign keys and quantity constraints reinforce the application rules.
- Cross-capability tests can now prove that Product orderability is correctly derived from Product and Inventory state.
Next lesson:
Implementing Order Creation
Now Product and Inventory are real working dependencies, so we can finally implement the central workflow:
authenticated CustomerId
↓
requested items
↓
duplicate Product detection
↓
load Products
↓
validate Product orderability
↓
load/check Inventory
↓
capture current server-side prices
↓
construct Order + OrderItems
↓
consume Inventory
↓
persist everything atomically
This time the Order workflow will sit on top of capabilities we have actually built rather than assuming they already exist.