Implementing Business Workflows
Implementing Product Updates and Deactivation
আপনি একটি 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(), anddeactivate()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.
nameandpriceare editable through PATCH.- Product ID is immutable.
activeis not a generic PATCH field.- Product deactivation is an explicit lifecycle command.
- Missing Product produces
PRODUCT_NOT_FOUND. - Product update uses
Product.rename()andProduct.changePrice(). - Product deactivation uses
Product.deactivate(). - Blank provided names are invalid.
- Negative prices are invalid.
- Omitted PATCH fields mean no change.
- Explicit
nulland 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.unitPricevalues 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.