Implementing Business Workflows

Implementing Product Updates and Deactivation

ReadingPreview

আপনি একটি free preview lesson দেখছেন।

আগের lesson-এ আমরা প্রথম complete vertical slice implement করেছি:

POST /api/v1/products
    ↓
ProductHandler
    ↓
CreateProductUseCase
    ↓
Product
    ↓
ProductRepository
    ↓
PostgreSQL

এখন Product-এর lifecycle একটু এগিয়ে নেব।

Admin-এর দুটি আলাদা operation দরকার:

PATCH /api/v1/products/{productId}

এবং:

POST /api/v1/products/{productId}/deactivate

প্রথমটি Product-এর editable information পরিবর্তন করবে।

দ্বিতীয়টি Product-কে নতুন Order-এর জন্য unavailable করবে।

এই distinction গুরুত্বপূর্ণ।

আমরা generic:

PATCH active=false

ব্যবহার করছি না।

কারণ:

change Product information

এবং:

deactivate Product

দুটি আলাদা business intention।

এই lesson-এর goal:

Product update এবং Product deactivation-কে focused UseCases হিসেবে implement করা, Domain-এর behaviour ব্যবহার করা, এবং Product lifecycle-কে generic CRUD mutation-এ পরিণত না করা।


Product Lifecycle So Far

একটি Product create হলে:

Product
├── id
├── name
├── price
└── active = true

তারপর admin করতে পারে:

rename Product

change current price

deactivate Product

কিন্তু করতে পারে না:

physically delete Product

কারণ historical Orders Product identity reference করতে পারে।


Our API

Product update:

PATCH /api/v1/products/{productId}

Example:

PATCH /api/v1/products/8cb143da-7556-485f-a042-044efa9a8f82
Content-Type: application/json
{
  "name": "Mechanical Keyboard Pro",
  "price": 99.90
}

Successful response:

200 OK
{
  "id": "8cb143da-7556-485f-a042-044efa9a8f82",
  "name": "Mechanical Keyboard Pro",
  "price": 99.90,
  "active": true
}

Partial Update

Because this is:

PATCH

the client does not need to send every Product field.

Change only price:

{
  "price": 94.90
}

Change only name:

{
  "name": "Mechanical Keyboard V2"
}

Both are valid.


What PATCH Cannot Change

The update request does not contain:

id

because Product identity is immutable.

It also does not contain:

active

because lifecycle has a separate operation:

POST /api/v1/products/{productId}/deactivate

This keeps the API expressive.


Why Not Generic Product Mutation?

Imagine:

{
  "active": false
}

inside the normal update endpoint.

Then Product lifecycle becomes just another writable field.

Eventually we might see:

{
  "id": "...",
  "active": true,
  "price": 10,
  "whatever": "..."
}

where the API behaves like:

Here is a database row. Change whatever you want.

That is not how we want to model business operations.

Instead:

PATCH Product
→ edit Product information
deactivate command
→ change Product lifecycle

Domain Behaviour Already Exists

Our Product has:

product.rename(...);

product.changePrice(...);

product.deactivate();

This means UseCases do not need to implement Product invariants manually.

Bad:

if (
        command.price()
                .compareTo(
                        BigDecimal.ZERO
                ) < 0
) {
    throw ...
}

product.setPrice(
        command.price()
);

Better:

product.changePrice(
        command.price()
);

Product owns the rule:

price >= 0

Update Request DTO

Because PATCH fields are optional, both values may be absent.

A request model can be:

package io.liveklass.ordermanagement.product.handler;

import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;

import java.math.BigDecimal;

public record UpdateProductRequest(

        @Pattern(
                regexp = "(?s).*\\S.*",
                message =
                        "Name must not be blank."
        )
        String name,

        @PositiveOrZero
        BigDecimal price
) {
}

Why no:

@NotBlank

on name?

Because with PATCH:

name omitted

is valid.

@NotBlank would make null invalid.

We need:

null
→ no change

while:

"   "
→ invalid

PATCH Null Semantics

For our current v1 contract:

field omitted

and:

field explicitly null

both effectively mean:

do not change this field

Example:

{
  "name": null,
  "price": 99.90
}

means:

leave name unchanged

change price

This is a deliberate simplification.

We do not currently need an operation where:

name = null

means:

clear Product name

because Product name is required anyway.


Empty PATCH

This request:

{}

does not violate any current business rule.

It simply results in:

no Product changes

We do not invent an:

EMPTY_PATCH_NOT_ALLOWED

error without a requirement.

A no-op request is acceptable for our current contract.


UpdateProductCommand

Application input:

package io.liveklass.ordermanagement.product.usecase;

import io.liveklass.ordermanagement.product.domain.ProductId;

import java.math.BigDecimal;

public record UpdateProductCommand(
        ProductId productId,
        String name,
        BigDecimal price
) {
}

The command contains:

ProductId

because the target Product comes from the URI.

It does not contain:

active

because update and deactivation are different UseCases.


UpdateProductUseCase

The workflow:

load Product
    ↓
apply requested name change
    ↓
apply requested price change
    ↓
persist Product

Implementation:

package io.liveklass.ordermanagement.product.usecase;

import io.liveklass.ordermanagement.product.domain.Product;
import io.liveklass.ordermanagement.product.repository.ProductRepository;

import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
public class UpdateProductUseCase {

    private final ProductRepository
            productRepository;

    public UpdateProductUseCase(
            ProductRepository productRepository
    ) {
        this.productRepository =
                productRepository;
    }

    @Transactional
    public Product execute(
            UpdateProductCommand command
    ) {
        Product product =
                productRepository
                        .findById(
                                command.productId()
                        )
                        .orElseThrow(
                                ProductNotFoundException::new
                        );

        boolean changed =
                false;

        if (
                command.name() != null
        ) {
            product.rename(
                    command.name()
            );

            changed = true;
        }

        if (
                command.price() != null
        ) {
            product.changePrice(
                    command.price()
            );

            changed = true;
        }

        if (changed) {
            productRepository.save(
                    product
            );
        }

        return product;
    }
}

This is a focused application workflow.


Why Check changed?

For:

{}

there is no reason to perform an unnecessary persistence write.

So:

nothing changed
→ return existing Product

No business error is necessary.

This is not essential for correctness, but it keeps the operation clear.


Product Not Found

If:

ProductId

does not exist:

ProductRepository.findById(...)
→ Optional.empty()

The UseCase should produce an application-level:

PRODUCT_NOT_FOUND

failure.

Conceptually:

public final class ProductNotFoundException
        extends RuntimeException {
}

We do not need a giant exception hierarchy.

The important meaning is:

requested Product does not exist

HTTP Mapping

Our centralized error boundary later maps this to:

404 Not Found

with:

{
  "type": "https://api.liveklass.io/problems/product-not-found",
  "title": "Product not found",
  "status": 404,
  "detail": "The requested product was not found.",
  "instance": "/api/v1/products/8cb143da-7556-485f-a042-044efa9a8f82",
  "code": "PRODUCT_NOT_FOUND"
}

The UseCase itself does not create this JSON.


Update Handler

We can extend our existing:

ProductHandler

because these endpoints belong to the same HTTP capability.

@PatchMapping("/{productId}")
public ResponseEntity<ProductResponse>
updateProduct(
        @PathVariable
        UUID productId,

        @Valid
        @RequestBody
        UpdateProductRequest request
) {
    Product product =
            updateProductUseCase.execute(
                    new UpdateProductCommand(
                            new ProductId(
                                    productId
                            ),
                            request.name(),
                            request.price()
                    )
            );

    return ResponseEntity.ok(
            ProductResponse.from(
                    product
            )
    );
}

No:

Repository

inside the Handler.

No direct Product mutation either.


UUID Parsing

Because HTTP uses UUID textual identifiers:

8cb143da-7556-485f-a042-044efa9a8f82

Handler can receive:

@PathVariable UUID productId

and then convert it into the domain identifier:

new ProductId(
        productId
);

Malformed UUID syntax should fail at the HTTP boundary as an invalid request.

It should not reach:

ProductRepository

as a fake ID.


Current Price vs Historical Price

Changing Product price:

89.90
→
99.90

changes:

current Product price

only.

Suppose an existing OrderItem contains:

productId = X
unitPrice = 89.90

That historical value must remain:

89.90

even after Product now costs:

99.90

This is one of our central persistence decisions.


Price Update Does Not Rewrite Order History

Never implement Product price update like:

UPDATE products

then

UPDATE order_items
SET unit_price = new product price

That would destroy historical truth.

Product price means:

current price for future successful Order creation

OrderItem price means:

price captured when that Order was created

Different concepts.


What Happens to Existing Unpaid Orders?

Suppose:

10:00
Product price = 100

Customer creates Order:

OrderItem.unitPrice = 100

At:

10:05

admin changes Product price:

120

The existing unpaid Order remains:

100

The Product change does not retroactively reprice it.

A new Order created afterward captures:

120

This is exactly why purchase-time price is persisted on OrderItem.


Deactivating Product

Now the second operation.

Endpoint:

POST /api/v1/products/{productId}/deactivate

This represents a business command:

Stop this Product from being newly orderable.

It does not mean:

delete Product

DeactivateProductUseCase

Workflow:

load Product
    ↓
Product.deactivate()
    ↓
persist Product

Implementation:

package io.liveklass.ordermanagement.product.usecase;

import io.liveklass.ordermanagement.product.domain.Product;
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 DeactivateProductUseCase {

    private final ProductRepository
            productRepository;

    public DeactivateProductUseCase(
            ProductRepository productRepository
    ) {
        this.productRepository =
                productRepository;
    }

    @Transactional
    public Product execute(
            ProductId productId
    ) {
        Product product =
                productRepository
                        .findById(
                                productId
                        )
                        .orElseThrow(
                                ProductNotFoundException::new
                        );

        product.deactivate();

        productRepository.save(
                product
        );

        return product;
    }
}

Again, the UseCase reads like the operation.


Domain Deactivation

Our Product implementation:

public void deactivate() {
    this.active = false;
}

This makes repeated deactivation harmless.

First call:

true
→ false

Second call:

false
→ false

No additional business state exists.


Repeated Deactivation

Suppose client sends:

POST /products/{id}/deactivate

twice.

We do not currently define:

ALREADY_DEACTIVATED

as a business conflict.

The operation is naturally idempotent in effect:

Product ends inactive

after either one or multiple successful requests.


Deactivation Handler

@PostMapping("/{productId}/deactivate")
public ResponseEntity<ProductResponse>
deactivateProduct(
        @PathVariable
        UUID productId
) {
    Product product =
            deactivateProductUseCase.execute(
                    new ProductId(
                            productId
                    )
            );

    return ResponseEntity.ok(
            ProductResponse.from(
                    product
            )
    );
}

Successful response:

200 OK
{
  "id": "8cb143da-7556-485f-a042-044efa9a8f82",
  "name": "Mechanical Keyboard",
  "price": 89.90,
  "active": false
}

Why Return the Product?

We could choose:

204 No Content

for a command that succeeds without a body.

For this course, returning:

200 OK
+
current Product representation

is useful because the admin client immediately sees:

active = false

Both styles can be valid API design.

The important thing is consistency.

For our current Product mutation endpoints, we return the updated Product representation.


Why Not DELETE?

We deliberately do not expose:

DELETE /api/v1/products/{productId}

because Product identity may already appear in:

order_items.product_id

Historical Orders need that reference to remain valid.

Product lifecycle uses:

active

instead.


Deactivation Does Not Delete Inventory

Suppose Product has:

availableQuantity = 30

Then admin deactivates it.

We do not automatically change Inventory to:

0

because:

Product active state

and:

Inventory quantity

are different facts.

The Product may remain physically available while being administratively unavailable for new Orders.


Availability Is Derived

Customer orderability remains:

Product active
AND
Inventory > 0

So after:

active = false

the Product is no longer newly orderable regardless of Inventory quantity.

No additional:

available = false

column is needed.


Deactivation Does Not Cancel Existing Orders

Suppose Customer created an Order while Product was active.

Later admin deactivates the Product.

Existing Order:

remains valid

We do not:

cancel Order

restore Inventory

remove OrderItem

change historical price

because Product deactivation applies to:

future ordering

not historical business state.


Existing Unpaid Order Can Still Be Paid

Our accepted rule is:

Product deactivation after Order creation does not invalidate the existing unpaid Order.

So:

Order created while Product active
    ↓
Product later deactivated
    ↓
Order remains UNPAID
    ↓
Customer may still pay that Order

The Order already captured:

ProductId

quantity

purchase-time unitPrice

at creation.

We do not re-run Product orderability when paying an already-created Order.


Existing Cancelled Order Restoration

Likewise, if an existing Order is cancelled after its Product became inactive:

Inventory is still restored

The Product active flag does not prevent restoration.

Inventory represents physical available quantity.

Product active state represents whether new ordering is allowed.

They remain separate responsibilities.


Can an Inactive Product Be Updated?

Our current requirements do not say:

inactive Product becomes immutable

Therefore admin may still:

rename an inactive Product

change its price

if the update endpoint is called.

We do not invent a:

PRODUCT_INACTIVE

restriction for normal admin editing.


Does Updating an Inactive Product Reactivate It?

No.

Suppose:

active = false

Then:

PATCH /products/{id}

with:

{
  "price": 120
}

results in:

price = 120

active = false

The update operation never changes lifecycle state.


No Reactivation Endpoint Yet

We currently have:

deactivate

but no business requirement for:

activate/reactivate

So we do not add:

POST /products/{id}/activate

just for symmetry.

If a requirement arrives later, we can add it deliberately.


Domain Encapsulation

Notice how UseCase code does not do:

product.setName(...);

product.setPrice(...);

product.setActive(false);

Instead:

product.rename(...);

product.changePrice(...);

product.deactivate();

These method names communicate intent and preserve Product invariants.

This is exactly what we meant earlier by avoiding an anemic domain model.


Persistence Flow for Update

Conceptually:

UpdateProductUseCase
    ↓
ProductRepository.findById()
    ↓
Product domain
    ↓
rename/changePrice
    ↓
ProductRepository.save()
    ↓
JPA
    ↓
PostgreSQL

Generated SQL may eventually be equivalent to:

UPDATE products
SET
    name = ?,
    price = ?,
    active = ?
WHERE id = ?;

The exact SQL is infrastructure detail.

The UseCase only understands:

persist updated Product state

Updating Only Changed Columns?

Hibernate can support different update behaviours and optimizations.

But we do not need to optimize this into:

only update price column

at this stage.

Correctness first.

If actual database measurements later show meaningful unnecessary write cost, persistence implementation can be optimized.

Do not introduce provider-specific complexity prematurely.


Transaction Boundary

Both operations use:

@Transactional

at UseCase level.

For Product update:

load Product

modify Product

persist state

belong to one application operation.

Likewise deactivation:

load

deactivate

persist

belongs to one operation.

These are small transactions.


Why Transaction Still Matters

Even though each operation affects one Product, the transaction gives the load/mutation/persistence workflow a clear boundary.

Later, operations such as:

Create Order

will involve several repositories, where the same principle becomes critical.

We're establishing a consistent application model now.


Concurrency

Suppose two admins update the same Product nearly simultaneously.

Example:

Admin A
→ price = 100
Admin B
→ price = 110

Our current Product model does not yet include:

optimistic versioning

or a conflict policy.

Therefore normal database/JPA last-write behaviour may occur depending on transaction timing.

We intentionally do not invent:

@Version

yet.

Product concurrent editing is not currently identified as a correctness-critical workflow like Inventory consumption.

If production requirements later demand lost-update detection, we can add it deliberately.


Inventory Concurrency Is Different

For Product price editing, simultaneous updates might be undesirable but are not currently a core integrity rule.

Inventory is different.

If two Orders consume the same final unit:

overselling

occurs.

That is a business correctness failure.

So we will solve Inventory concurrency explicitly before Order creation is considered complete.

Different data has different concurrency requirements.


Product Repository Does Not Change

Our application boundary already supports:

public interface ProductRepository {

    void save(
            Product product
    );

    Optional<Product> findById(
            ProductId productId
    );
}

Both new UseCases can use this existing interface.

We do not need:

updateProduct()

deactivateProduct()

updatePrice()

updateName()

persistence methods just because the application has those operations.

Repository persists Product state.

Domain/Application decides how that state may change.


Why Not deactivateById() in Repository?

We could write SQL:

UPDATE products
SET active = FALSE
WHERE id = ?;

directly.

That may be efficient.

But our current Product lifecycle is simple and domain behaviour already exists.

Loading the Product then:

product.deactivate();

keeps lifecycle semantics in Domain.

If future bulk administration requires:

deactivate 100,000 Products

then a specialized persistence operation might become justified.

Not now.


Testing UpdateProductUseCase

Important scenarios:

update name only

update price only

update both

empty update

Product missing

negative price rejected

blank provided name rejected

Update Name Test

Conceptually:

@Test
void updatesProductName() {
    Product product =
            Product.create(
                    ProductId.newId(),
                    "Keyboard",
                    new BigDecimal("89.90")
            );

    repository.save(
            product
    );

    Product updated =
            useCase.execute(
                    new UpdateProductCommand(
                            product.id(),
                            "Mechanical Keyboard",
                            null
                    )
            );

    assertEquals(
            "Mechanical Keyboard",
            updated.name()
    );

    assertEquals(
            new BigDecimal("89.90"),
            updated.price()
    );
}

The unchanged price remains unchanged.


Update Price Test

@Test
void updatesProductPrice() {
    Product updated =
            useCase.execute(
                    new UpdateProductCommand(
                            product.id(),
                            null,
                            new BigDecimal("99.90")
                    )
            );

    assertEquals(
            new BigDecimal("99.90"),
            updated.price()
    );
}

Empty Update Test

Product result =
        useCase.execute(
                new UpdateProductCommand(
                        product.id(),
                        null,
                        null
                )
        );

Expected:

Product unchanged

No invented validation error.


Product Missing Test

unknown UUID
    ↓
ProductRepository
    ↓
Optional.empty()
    ↓
PRODUCT_NOT_FOUND

At HTTP level:

404

Deactivation Tests

Important scenarios:

active Product
→ inactive
already inactive Product
→ remains inactive
unknown Product
→ PRODUCT_NOT_FOUND

Repeated Deactivation Test

product.deactivate();
product.deactivate();

assertFalse(
        product.active()
);

No special exception is required.


Persistence Integration Tests

Repository integration should verify:

updated name persists

updated price persists

active=false persists

Product reload reconstructs correct state

For example:

persist Product

update price

save

flush/clear

reload

verify new price

This proves actual PostgreSQL/JPA behaviour.


API Tests

Useful HTTP-level tests:

PATCH name
→ 200
PATCH price
→ 200
PATCH negative price
→ 400 VALIDATION_ERROR
PATCH blank name
→ 400 VALIDATION_ERROR
PATCH unknown Product
→ 404 PRODUCT_NOT_FOUND
POST deactivate
→ 200 + active=false
POST deactivate twice
→ both leave Product inactive

Authorization tests come in Module 8.


Full Product Mutation Flow

Update:

PATCH /api/v1/products/{id}
    ↓
ProductHandler
    ↓
UpdateProductUseCase
    ↓
ProductRepository.findById()
    ↓
Product.rename()
Product.changePrice()
    ↓
ProductRepository.save()
    ↓
PostgreSQL

Deactivation:

POST /api/v1/products/{id}/deactivate
    ↓
ProductHandler
    ↓
DeactivateProductUseCase
    ↓
ProductRepository.findById()
    ↓
Product.deactivate()
    ↓
ProductRepository.save()
    ↓
PostgreSQL

What We Deliberately Did Not Add

We did not add:

DELETE Product
reactivate Product
Product name uniqueness
SKU
Product version
soft_deleted_at
Product audit history
automatic Inventory modification
automatic existing Order repricing

None are required yet.


Product Lifecycle So Far

We now have:

Create Product
    ↓
active Product
    ├── rename
    ├── change price
    └── deactivate
             ↓
         inactive Product
             ├── rename
             └── change price

There is currently no:

inactive → active

transition because no requirement asks for it.


Product vs Order History

This distinction is now critical:

Product
→ current catalog state
OrderItem
→ historical purchase state

Therefore:

Product renamed

does not rewrite historical OrderItem data.

We currently do not snapshot Product name in OrderItem anyway.

And:

Product price changed

never rewrites:

OrderItem.unitPrice

Product vs Inventory

Likewise:

Product.active

and:

Inventory.availableQuantity

remain separate.

Examples:

active=true
inventory=0
→ not orderable
active=false
inventory=100
→ not orderable
active=true
inventory=100
→ orderable

This will become concrete when we implement Product browsing and Inventory management.


Engineering Principle

The core principle:

Update operations should express the fields that may change, while important lifecycle transitions deserve explicit business operations rather than generic writable flags.

Another:

Changing current Product state must never rewrite historical Order state. Current catalog price and purchase-time OrderItem price are intentionally different concepts.

And:

Domain methods such as rename(), changePrice(), and deactivate() should remain the authoritative way Product state changes, while UseCases coordinate loading and persistence.


Summary

In this lesson, we implemented:

PATCH /api/v1/products/{productId}

and:

POST /api/v1/products/{productId}/deactivate

We established that:

  • Product updates are partial.
  • name and price are editable through PATCH.
  • Product ID is immutable.
  • active is not a generic PATCH field.
  • Product deactivation is an explicit lifecycle command.
  • Missing Product produces PRODUCT_NOT_FOUND.
  • Product update uses Product.rename() and Product.changePrice().
  • Product deactivation uses Product.deactivate().
  • Blank provided names are invalid.
  • Negative prices are invalid.
  • Omitted PATCH fields mean no change.
  • Explicit null and omitted fields currently have the same no-change meaning.
  • An empty PATCH is allowed as a no-op.
  • Repeated deactivation is harmless and leaves the Product inactive.
  • We return the updated Product representation with 200 OK.
  • Product deactivation does not physically delete the Product.
  • Product deactivation does not delete or zero Inventory.
  • Product deactivation does not cancel existing Orders.
  • Existing unpaid Orders remain payable after Product deactivation.
  • Cancelling an existing Order still restores Inventory even if the Product is inactive.
  • Product price updates affect future Order creation only.
  • Existing OrderItem.unitPrice values remain unchanged.
  • Updating an inactive Product does not reactivate it.
  • We do not add a reactivation endpoint without a requirement.
  • Product update/deactivation use focused UseCases rather than a generic ProductService.
  • Product persistence continues through the existing ProductRepository.
  • We do not add speculative optimistic locking for Product updates.
  • Inventory concurrency will be handled separately because it is a correctness-critical workflow.

Next lesson:

Implementing Product Browsing

There we will implement the first customer-facing Product read path:

GET /api/v1/products?page=0&size=20

and connect Product + Inventory through an efficient database query so that customers see only Products that are both active and currently in stock, without creating an unnecessary JPA Product ↔ Inventory relationship.