Implementing Business Workflows

Implementing Inventory Management

ReadingPreview

আপনি একটি 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।

  1. Create Product:
active = true
  1. Product browse:
Product absent

because no Inventory exists।

  1. Set Inventory:
availableQuantity = 5
  1. Product browse:
Product appears
  1. Set Inventory:
availableQuantity = 0
  1. Product browse:
Product disappears

This test demonstrates that:

Product orderability

is correctly derived from Product + Inventory state।


Another Cross-Capability Test

  1. Product active.
  2. Inventory = 10.
  3. Product appears in customer browsing.
  4. Deactivate Product.
  5. Inventory remains 10.
  6. Product disappears from customer browsing.
  7. 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_id is a UUID primary key and foreign key.
  • There is no separate InventoryId.
  • One Product can have at most one Inventory row.
  • availableQuantity must be a non-negative whole number.
  • Zero Inventory is a valid state.
  • Admin Inventory updates use set semantics rather than increment semantics.
  • PUT expresses the desired current quantity and is idempotent in effect.
  • A Product must exist before Inventory can be configured.
  • SetInventoryUseCase coordinates ProductRepository and InventoryRepository.
  • Missing Inventory and zero Inventory remain distinct states.
  • GET /inventory/{productId} can return INVENTORY_NOT_FOUND when 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_id directly.
  • 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.