Implementing Business Workflows
Implementing Order Creation
আপনি একটি free preview lesson দেখছেন।
এখন আমাদের application-এ Order তৈরি করার জন্য প্রয়োজনীয় foundation বাস্তবে আছে।
আমরা Product তৈরি করতে পারি:
POST /api/v1/products
Inventory configure করতে পারি:
PUT /api/v1/inventory/{productId}
Customer-facing browse query জানে কোন Product বর্তমানে orderable:
Product.active = true
AND
Inventory.availableQuantity > 0
এখন আমরা central business workflow implement করতে পারি:
POST /api/v1/orders
এই operation-এর কাজ শুধু একটি orders row insert করা নয়।
একটি valid Order তৈরি করতে backend-কে coordinate করতে হবে:
Customer identity
Products
Inventory
current prices
Order
OrderItems
Flow:
authenticated CustomerId
↓
requested items
↓
validate duplicate Product IDs
↓
load Products
↓
ensure Products are active
↓
load Inventory
↓
ensure enough quantity exists
↓
capture current server-side prices
↓
create Order + OrderItems
↓
decrease Inventory
↓
persist Order
এই lesson-এর goal:
একটি real cross-capability UseCase implement করা, যেখানে Product, Inventory, Order domain, এবং Repositories একসঙ্গে কাজ করবে—কিন্তু HTTP, JPA, এবং business logic এক class-এ collapse করবে না।
The API Contract
Request:
POST /api/v1/orders
Content-Type: application/json
{
"items": [
{
"productId": "9ed4c9a9-8761-4e4f-9f50-aef83f14c13d",
"quantity": 2
},
{
"productId": "e7065e8f-d5fc-4bf3-85c3-c42cf5ed4457",
"quantity": 1
}
]
}
Notice what the client does not send:
customerId
unitPrice
total
status
orderId
Those values are server-controlled.
Successful Response
Conceptually:
201 Created
Location: /api/v1/orders/36a25516-2af2-4644-b55d-d0cabf389c9e
{
"id": "36a25516-2af2-4644-b55d-d0cabf389c9e",
"status": "UNPAID",
"items": [
{
"productId": "9ed4c9a9-8761-4e4f-9f50-aef83f14c13d",
"quantity": 2,
"unitPrice": 89.90,
"total": 179.80
},
{
"productId": "e7065e8f-d5fc-4bf3-85c3-c42cf5ed4457",
"quantity": 1,
"unitPrice": 69.50,
"total": 69.50
}
],
"total": 249.30
}
The backend determined:
OrderId
CustomerId
OrderStatus
unitPrice
item total
Order total
Authentication Is Still an Outer Boundary
Module 8 will connect:
Bearer token
↓
Spring Security
↓
authenticated user
↓
CustomerId
For now, CreateOrderUseCase accepts a CustomerId that represents:
already authenticated identity
We do not create:
Customer table
CreateCustomerUseCase
CustomerRepository
Order simply stores the stable external Customer identity.
CreateOrderRequest
Transport model:
package io.liveklass.ordermanagement.order.handler;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.util.List;
import java.util.UUID;
public record CreateOrderRequest(
@NotEmpty
@Valid
List<CreateOrderItemRequest> items
) {
}
public record CreateOrderItemRequest(
@NotNull
UUID productId,
@NotNull
@Positive
Integer quantity
) {
}
Transport validation handles:
empty item list
missing productId
missing quantity
quantity <= 0
But it does not check:
Product exists
Product active
Inventory available
duplicate Product lines
Those require application/domain state.
Why Quantity Is an Integer
Our v1 inventory model represents:
whole units
So:
1
2
10
are valid.
We do not support:
1.5 units
because no requirement exists for fractional inventory.
That is why:
Integer
is appropriate here.
CreateOrderCommand
Application input should not be the HTTP DTO directly.
package io.liveklass.ordermanagement.order.usecase;
import io.liveklass.ordermanagement.customer.CustomerId;
import io.liveklass.ordermanagement.product.domain.ProductId;
import java.util.List;
public record CreateOrderCommand(
CustomerId customerId,
List<CreateOrderItemCommand> items
) {
}
public record CreateOrderItemCommand(
ProductId productId,
int quantity
) {
}
The Handler converts:
UUID
→ ProductId
and authenticated identity becomes:
CustomerId
before invoking the UseCase.
CustomerId
Because Customer identity is external, a simple domain/application value object is enough:
package io.liveklass.ordermanagement.customer;
import java.util.Objects;
public record CustomerId(
String value
) {
public CustomerId {
Objects.requireNonNull(
value,
"value must not be null"
);
if (value.isBlank()) {
throw new IllegalArgumentException(
"CustomerId must not be blank"
);
}
}
}
We do not assume the external identity itself is UUID.
The authentication provider owns that identifier format.
Our application only requires:
stable non-blank CustomerId
Duplicate Product Lines
This request is invalid:
{
"items": [
{
"productId": "9ed4c9a9-8761-4e4f-9f50-aef83f14c13d",
"quantity": 2
},
{
"productId": "9ed4c9a9-8761-4e4f-9f50-aef83f14c13d",
"quantity": 3
}
]
}
Our contract says:
Client must combine quantities for the same Product into one line.
So:
Product A × 2
Product A × 3
should instead be:
Product A × 5
Why Reject Duplicates Instead of Merging Them?
The backend could automatically merge duplicates.
But that changes client input semantics silently.
Rejecting duplicates gives us a cleaner invariant:
one Product
→ at most one OrderItem
This matches our database key:
PRIMARY KEY (
order_id,
product_id
)
Application and database now reinforce the same rule.
Duplicate Detection in the UseCase
Conceptually:
private void ensureNoDuplicateProducts(
List<CreateOrderItemCommand> items
) {
Set<ProductId> productIds =
new HashSet<>();
for (
CreateOrderItemCommand item : items
) {
if (
!productIds.add(
item.productId()
)
) {
throw new DuplicateOrderProductException(
item.productId()
);
}
}
}
This is cross-request collection validation.
It belongs naturally in the application workflow.
The Order domain should still protect the invariant when constructed.
Why Protect the Same Rule in Domain?
Imagine a future non-HTTP caller constructs OrderItems directly.
If only the REST UseCase checks duplicates, invalid Order state could still be created elsewhere.
So:
UseCase
→ reject early
and:
Order
→ reject invalid final state
are complementary.
Loading Products
For each requested ProductId, Create Order needs current server-side Product state.
We do not trust a browse response from five minutes ago.
We need:
Product exists?
Product active?
current price?
The authoritative state comes from:
ProductRepository
Product Must Exist
If requested Product does not exist:
CreateOrderUseCase
→ PRODUCT_NOT_FOUND
The operation fails.
We do not:
ignore that item
create a partial Order
because our rule is:
Any invalid item fails the whole Order.
Product Must Be Active
A Product may exist but be:
active = false
Then it cannot participate in a new Order.
Expected application failure:
PRODUCT_NOT_ORDERABLE
This is different from:
PRODUCT_NOT_FOUND
because the Product exists but current business state prevents new ordering.
Browse State Is Not Enough
Suppose Customer browsed:
Product active
Inventory = 5
Then before Order creation:
Admin deactivates Product
or another Order consumes Inventory.
The earlier browse result is stale.
Therefore Create Order must re-check:
current Product state
current Inventory state
at Order creation time.
Loading Inventory
For every Product we also need:
InventoryRepository
to obtain current available quantity.
If no Inventory row exists:
not enough Inventory
for Order creation.
A Product without Inventory is not orderable.
Missing Inventory vs Insufficient Inventory
Administratively we distinguished:
INVENTORY_NOT_FOUND
for:
GET /inventory/{productId}
But from Customer Order creation perspective, both:
Inventory absent
and:
Inventory quantity < requested quantity
mean:
Order cannot currently be fulfilled
So the application can expose a consistent business outcome such as:
INSUFFICIENT_INVENTORY
rather than leaking internal administrative setup distinctions to the Customer.
This is operation-specific error semantics.
Current Price Comes From Product
Suppose request:
Product A × 2
and current Product state says:
price = 89.90
Then OrderItem captures:
unitPrice = 89.90
The client cannot override that.
OrderItem Domain Model
Conceptually:
package io.liveklass.ordermanagement.order.domain;
import io.liveklass.ordermanagement.product.domain.ProductId;
import java.math.BigDecimal;
import java.util.Objects;
public final class OrderItem {
private final ProductId productId;
private final int quantity;
private final BigDecimal unitPrice;
public OrderItem(
ProductId productId,
int quantity,
BigDecimal unitPrice
) {
this.productId =
Objects.requireNonNull(
productId,
"productId must not be null"
);
if (quantity <= 0) {
throw new IllegalArgumentException(
"Quantity must be positive"
);
}
Objects.requireNonNull(
unitPrice,
"unitPrice must not be null"
);
if (
unitPrice.compareTo(
BigDecimal.ZERO
) < 0
) {
throw new IllegalArgumentException(
"Unit price must not be negative"
);
}
this.quantity =
quantity;
this.unitPrice =
unitPrice;
}
public ProductId productId() {
return productId;
}
public int quantity() {
return quantity;
}
public BigDecimal unitPrice() {
return unitPrice;
}
public BigDecimal total() {
return unitPrice.multiply(
BigDecimal.valueOf(
quantity
)
);
}
}
The item stores:
purchase-time unit price
not a reference to:
Product.price
that changes dynamically.
OrderId
Order identity follows our UUID decision:
package io.liveklass.ordermanagement.order.domain;
import java.util.Objects;
import java.util.UUID;
public record OrderId(
UUID value
) {
public OrderId {
Objects.requireNonNull(
value,
"value must not be null"
);
}
public static OrderId newId() {
return new OrderId(
UUID.randomUUID()
);
}
}
Identity exists before persistence.
OrderStatus
Our v1 lifecycle remains:
public enum OrderStatus {
UNPAID,
PAID,
CANCELLED
}
No additional states such as:
PENDING
PROCESSING
SHIPPED
FAILED
REFUNDED
are introduced.
Order Domain Model
A simplified Order:
package io.liveklass.ordermanagement.order.domain;
import io.liveklass.ordermanagement.customer.CustomerId;
import io.liveklass.ordermanagement.product.domain.ProductId;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public final class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private final Instant createdAt;
private OrderStatus status;
private Order(
OrderId id,
CustomerId customerId,
List<OrderItem> items,
OrderStatus status,
Instant createdAt
) {
this.id =
Objects.requireNonNull(
id,
"id must not be null"
);
this.customerId =
Objects.requireNonNull(
customerId,
"customerId must not be null"
);
Objects.requireNonNull(
items,
"items must not be null"
);
if (items.isEmpty()) {
throw new IllegalArgumentException(
"Order must contain at least one item"
);
}
ensureNoDuplicateProducts(
items
);
this.items =
List.copyOf(
items
);
this.status =
Objects.requireNonNull(
status,
"status must not be null"
);
this.createdAt =
Objects.requireNonNull(
createdAt,
"createdAt must not be null"
);
}
public static Order create(
OrderId id,
CustomerId customerId,
List<OrderItem> items,
Instant createdAt
) {
return new Order(
id,
customerId,
items,
OrderStatus.UNPAID,
createdAt
);
}
public OrderId id() {
return id;
}
public CustomerId customerId() {
return customerId;
}
public List<OrderItem> items() {
return items;
}
public OrderStatus status() {
return status;
}
public Instant createdAt() {
return createdAt;
}
public BigDecimal total() {
return items.stream()
.map(
OrderItem::total
)
.reduce(
BigDecimal.ZERO,
BigDecimal::add
);
}
private static void ensureNoDuplicateProducts(
List<OrderItem> items
) {
Set<ProductId> productIds =
new HashSet<>();
for (OrderItem item : items) {
if (
!productIds.add(
item.productId()
)
) {
throw new IllegalArgumentException(
"Duplicate product in order"
);
}
}
}
}
The Order creates itself as:
UNPAID
Client cannot choose status.
createdAt Source
We previously identified ambiguity around whether PostgreSQL or application owns created_at.
From this point onward we will use a clear rule:
Application creates the Order timestamp; PostgreSQL persists and validates the non-null value.
So:
Order.createdAt
→ application-controlled Instant
The database column remains:
TIMESTAMPTZ NOT NULL
and we no longer need to rely on a database default as the normal application path.
That makes Order construction deterministic and testable.
Why Instant?
Instant represents an absolute point in time.
That fits:
TIMESTAMPTZ
well for backend persistence.
We avoid storing:
server local date/time
as business chronology.
Presentation timezone belongs at transport/UI boundaries.
Time Source
For a simple implementation we could write:
Instant.now()
inside the UseCase.
However, time is now persisted business state.
Using:
Clock
makes tests deterministic without building a custom abstraction.
Example:
private final Clock clock;
then:
Instant.now(clock)
This is a justified dependency.
CreateOrderUseCase Dependencies
The workflow needs:
ProductRepository
InventoryRepository
OrderRepository
Clock
No:
PaymentService
because payment is a separate later operation.
No:
CustomerRepository
because Customer identity is externally owned.
OrderRepository
Application boundary:
package io.liveklass.ordermanagement.order.repository;
import io.liveklass.ordermanagement.order.domain.Order;
import io.liveklass.ordermanagement.order.domain.OrderId;
import java.util.Optional;
public interface OrderRepository {
void save(
Order order
);
Optional<Order> findById(
OrderId orderId
);
}
Order ID already exists before persistence, so save() does not need to return a generated identity.
Building Order Items
The UseCase can build OrderItems only after Product and Inventory state have been validated.
Conceptually:
request item
↓
Product
↓
current price
↓
OrderItem
Example:
new OrderItem(
product.id(),
requestedQuantity,
product.price()
);
This captures server-side price at creation time.
A Straightforward CreateOrderUseCase
Conceptually:
@Component
public class CreateOrderUseCase {
private final ProductRepository
productRepository;
private final InventoryRepository
inventoryRepository;
private final OrderRepository
orderRepository;
private final Clock clock;
public CreateOrderUseCase(
ProductRepository productRepository,
InventoryRepository inventoryRepository,
OrderRepository orderRepository,
Clock clock
) {
this.productRepository =
productRepository;
this.inventoryRepository =
inventoryRepository;
this.orderRepository =
orderRepository;
this.clock =
clock;
}
public Order execute(
CreateOrderCommand command
) {
ensureNoDuplicateProducts(
command.items()
);
List<OrderItem> orderItems =
new ArrayList<>();
List<Inventory> inventories =
new ArrayList<>();
for (
CreateOrderItemCommand item :
command.items()
) {
Product product =
productRepository
.findById(
item.productId()
)
.orElseThrow(
ProductNotFoundException::new
);
if (!product.active()) {
throw new ProductNotOrderableException(
product.id()
);
}
Inventory inventory =
inventoryRepository
.findByProductId(
product.id()
)
.orElseThrow(
() ->
new InsufficientInventoryException(
product.id()
)
);
if (
inventory.availableQuantity()
< item.quantity()
) {
throw new InsufficientInventoryException(
product.id()
);
}
orderItems.add(
new OrderItem(
product.id(),
item.quantity(),
product.price()
)
);
inventories.add(
inventory
);
}
Order order =
Order.create(
OrderId.newId(),
command.customerId(),
orderItems,
Instant.now(clock)
);
for (int i = 0;
i < command.items().size();
i++) {
Inventory inventory =
inventories.get(i);
int quantity =
command.items()
.get(i)
.quantity();
inventory.decrease(
quantity
);
inventoryRepository.save(
inventory
);
}
orderRepository.save(
order
);
return order;
}
}
This shows the complete orchestration clearly.
But it is not yet the final concurrency-safe implementation.
We'll improve that deliberately.
Why Validate Everything Before Decreasing Inventory?
Imagine request has two lines:
Product A × 2
Product B × 3
Product A is valid.
Product B is inactive.
Bad sequence:
validate A
decrease A
validate B
fail
Without a transaction, Inventory A could be changed before we discover B is invalid.
A safer application flow is:
validate all Products and Inventory
↓
construct Order
↓
apply mutations
and ultimately protect the whole operation with one database transaction.
Any Invalid Item Fails the Entire Order
Our rule:
Product A valid
Product B invalid
does not produce:
Order containing only Product A
The entire operation fails.
Same for:
Product missing
Product inactive
Inventory missing
Inventory insufficient
No partial Order.
Why Not Persist Each OrderItem Separately?
Application should not do:
OrderRepository.save(order)
OrderItemRepository.save(item1)
OrderItemRepository.save(item2)
because OrderItems belong to the Order aggregate.
We deliberately do not expose an application:
OrderItemRepository
OrderRepository persists the Order aggregate.
JPA Persistence Shape
Our persistence model already established:
OrderEntity
↓
@OneToMany
OrderItemEntity
with:
CascadeType.PERSIST
for new Order creation.
So OrderRepository can translate:
Order
into:
OrderEntity
+
OrderItemEntities
and persist them together.
UUID Order Schema
With our updated UUID decision, orders becomes:
CREATE TABLE orders (
id UUID PRIMARY KEY,
customer_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT orders_status_valid
CHECK (
status IN (
'UNPAID',
'PAID',
'CANCELLED'
)
)
);
OrderItems:
CREATE TABLE order_items (
order_id UUID NOT NULL,
product_id UUID NOT NULL,
quantity INTEGER NOT NULL,
unit_price NUMERIC NOT NULL,
CONSTRAINT order_items_pk
PRIMARY KEY (
order_id,
product_id
),
CONSTRAINT order_items_order_fk
FOREIGN KEY (order_id)
REFERENCES orders(id),
CONSTRAINT order_items_product_fk
FOREIGN KEY (product_id)
REFERENCES products(id),
CONSTRAINT order_items_quantity_positive
CHECK (quantity > 0),
CONSTRAINT order_items_unit_price_non_negative
CHECK (unit_price >= 0)
);
No generated database identity.
OrderEntity
Conceptually:
@Entity
@Table(name = "orders")
public class OrderEntity {
@Id
@Column(
name = "id",
nullable = false
)
private UUID id;
@Column(
name = "customer_id",
nullable = false
)
private String customerId;
@Enumerated(
EnumType.STRING
)
@Column(
name = "status",
nullable = false
)
private OrderStatus status;
@Column(
name = "created_at",
nullable = false
)
private Instant createdAt;
@OneToMany(
mappedBy = "order",
cascade = CascadeType.PERSIST,
fetch = FetchType.LAZY
)
private List<OrderItemEntity> items =
new ArrayList<>();
protected OrderEntity() {
}
// persistence mapping behaviour...
}
No:
CustomerEntity
association.
customerId stays scalar.
OrderItem Composite Key With UUID
Persistence identifier:
@Embeddable
public class OrderItemEntityId
implements Serializable {
@Column(
name = "order_id",
nullable = false
)
private UUID orderId;
@Column(
name = "product_id",
nullable = false
)
private UUID productId;
protected OrderItemEntityId() {
}
public OrderItemEntityId(
UUID orderId,
UUID productId
) {
this.orderId =
orderId;
this.productId =
productId;
}
// equals + hashCode
}
The composite key still expresses:
one Product line per Order
UUID changes the key type, not the domain relationship.
OrderRepository Mapping
Persistence adapter conceptually:
Order
↓
OrderEntity
OrderItem
↓
OrderItemEntity
For each item:
orderId
→ Order.id
productId
→ OrderItem.productId
quantity
→ OrderItem.quantity
unitPrice
→ OrderItem.unitPrice
No total column is persisted.
Why Total Is Not Persisted
Order total is derived from:
OrderItem.unitPrice × quantity
So:
order.total()
calculates:
sum of item totals
Persisting another mutable:
orders.total
would duplicate data and introduce synchronization risk.
Price Changes After Order Creation
Suppose:
Product price = 89.90
Order gets created.
OrderItem stores:
89.90
Admin later changes Product:
price = 99.90
Existing Order remains:
89.90
because OrderItem owns historical purchase-time price.
Product Deactivation After Order Creation
Suppose:
Order created successfully
then Product becomes inactive.
Existing Order remains valid.
It can still be:
paid
or:
cancelled
according to Order lifecycle rules.
We do not re-check Product active state for an already-created Order.
Order Creation Decreases Inventory
Our v1 semantics are explicit:
successful Order creation
→ available Inventory decreases immediately
No reservation.
No expiration.
No delayed inventory consumption at payment time.
If the Order is later cancelled:
Inventory is restored
Why Consume Before Payment?
This is our chosen v1 business model.
An unpaid Order still represents allocated stock.
Therefore:
Create Order
→ consume Inventory
and:
Cancel unpaid Order
→ restore Inventory
Payment failure does not automatically cancel the Order.
That means Inventory stays consumed until the Order is cancelled.
Concurrency Warning
The straightforward implementation currently does:
load Inventory
check quantity
decrease in memory
save
This is not sufficient for concurrent stock consumption.
Example:
Inventory = 1
Request A:
reads 1
Request B:
reads 1
Both pass:
quantity >= 1
Both may attempt to create an Order.
This is the overselling race we deliberately deferred.
Why We Still Implement the Workflow First
Before choosing locking/atomic update mechanics, we need the business operation clearly expressed:
consume N units if available
Then persistence can implement that intent safely.
Architecture should remain:
CreateOrderUseCase
↓
InventoryRepository
not:
CreateOrderUseCase
↓
EntityManager
↓
SQL lock
The next Inventory-safety lesson will strengthen the Repository contract where needed.
Do Not Add @Version Yet
Potential solutions include:
optimistic locking
pessimistic row locking
conditional atomic UPDATE
We have not selected the final strategy in this lesson.
Do not casually add:
@Version
to Inventory and call the problem solved.
We will evaluate the actual workflow requirements.
Transaction Boundary Preview
Create Order must eventually guarantee:
Inventory changes
Order insert
OrderItem inserts
are:
all committed
or:
all rolled back
If Order persistence fails after Inventory changes, Inventory must not remain consumed.
So the final operation needs one local PostgreSQL transaction.
We will implement and examine that transaction boundary in the dedicated transaction lesson rather than treating @Transactional as magic here.
Handler
Conceptually:
@PostMapping
public ResponseEntity<OrderResponse>
createOrder(
@Valid
@RequestBody
CreateOrderRequest request
) {
CustomerId customerId =
currentAuthenticatedCustomerId();
CreateOrderCommand command =
new CreateOrderCommand(
customerId,
request.items()
.stream()
.map(
item ->
new CreateOrderItemCommand(
new ProductId(
item.productId()
),
item.quantity()
)
)
.toList()
);
Order order =
createOrderUseCase
.execute(
command
);
URI location =
URI.create(
"/api/v1/orders/"
+ order.id().value()
);
return ResponseEntity
.created(location)
.body(
OrderResponse.from(
order
)
);
}
currentAuthenticatedCustomerId() is conceptual until Module 8 wires Spring Security.
We do not accept customerId from request JSON.
OrderResponse
Conceptually:
public record OrderResponse(
UUID id,
OrderStatus status,
List<OrderItemResponse> items,
BigDecimal total
) {
public static OrderResponse from(
Order order
) {
return new OrderResponse(
order.id().value(),
order.status(),
order.items()
.stream()
.map(
OrderItemResponse::from
)
.toList(),
order.total()
);
}
}
Item response:
public record OrderItemResponse(
UUID productId,
int quantity,
BigDecimal unitPrice,
BigDecimal total
) {
public static OrderItemResponse from(
OrderItem item
) {
return new OrderItemResponse(
item.productId()
.value(),
item.quantity(),
item.unitPrice(),
item.total()
);
}
}
No persistence entities leak into HTTP.
Important Error Cases
Create Order can fail for several expected reasons.
Empty items
Handled by HTTP validation:
400 VALIDATION_ERROR
Invalid quantity
quantity <= 0
Handled by HTTP/domain validation:
400 VALIDATION_ERROR
Duplicate Product line
Application failure:
INVALID_REQUEST
or a specific stable validation/application code if we choose one.
The important thing is consistent semantics.
Product missing
PRODUCT_NOT_FOUND
Likely:
404
Product inactive
PRODUCT_NOT_ORDERABLE
Likely:
409
because the resource exists but current state prevents ordering.
Inventory insufficient
INSUFFICIENT_INVENTORY
Likely:
409
because the request conflicts with current stock state.
Database failure
Unexpected:
INTERNAL_ERROR
not:
PRODUCT_NOT_FOUND
or:
INSUFFICIENT_INVENTORY
Failure meaning must remain accurate.
Multi-Item Atomicity
Suppose request:
Product A × 2
Product B × 5
Product C × 1
A and B are valid.
C has insufficient Inventory.
Expected:
no Order
and eventually:
no Inventory changes
for A or B.
The Order is one business operation, not three independent purchases.
Why Not Save as We Iterate?
Bad:
validate Product A
decrease Inventory A
save
validate Product B
decrease Inventory B
save
validate Product C
fail
This makes partial mutation more likely and complicates reasoning.
Prefer:
validate requested state first
then perform the final state changes inside one transaction.
Order Creation Should Not Call Product Browsing
Bad:
CreateOrderUseCase
→ ListOrderableProductsUseCase
then search that result.
That would couple one UseCase to another endpoint-oriented operation and introduce pagination/query problems.
CreateOrderUseCase directly uses:
ProductRepository
InventoryRepository
for the state it needs.
No Internal HTTP Calls
Although Product and Inventory are capability modules, CreateOrderUseCase does not call:
GET /products/{id}
GET /inventory/{id}
internally.
This is one modular monolith.
UseCases coordinate Repositories directly in-process.
No internal networking is needed.
Avoid Querying Product One by One Forever
The simple implementation loads each Product separately.
For a very small bounded Order this may be acceptable initially.
But if Order item count can become larger, Repository may eventually expose a batch lookup such as:
findByIds(Set<ProductId>)
to avoid N queries.
We have not yet established a maximum Order size.
So we should recognize the optimization opportunity without inventing an arbitrary limit.
Same for Inventory
Likewise, a future persistence implementation might load requested Inventory rows in one query.
However, concurrency-safe consumption may require a different strategy entirely.
Do not optimize prematurely before we choose the correctness mechanism.
Order Size
We currently require:
at least one item
but we have not defined:
maximum 10 items
maximum 100 items
Do not invent one.
If abuse/resource concerns later require a bounded order size, that becomes an explicit API/business requirement.
No Customer Lookup
CreateOrderUseCase does not do:
CustomerRepository.findById(customerId)
because there is no local Customer aggregate.
The authenticated CustomerId itself is sufficient for Order ownership.
No Payment During Creation
Create Order ends:
UNPAID
It does not:
call Payment Provider
and does not become:
PAID
within the same endpoint.
Payment is an explicit later operation:
POST /api/v1/orders/{id}/pay
This keeps external-system failure out of local Order creation.
No Automatic Cancellation on Payment Failure
Because payment is separate:
Order creation
→ UNPAID
Later payment can:
succeed
→ PAID
or:
fail / timeout
→ remains UNPAID
Payment failure does not automatically restore Inventory.
Only valid cancellation does that in v1.
Testing CreateOrderUseCase
Important unit scenarios:
valid single item
→ Order created
valid multiple items
→ Order created
duplicate Product
→ reject
Product missing
→ reject
Product inactive
→ reject
Inventory missing
→ reject
Inventory insufficient
→ reject
valid Order
→ unitPrice comes from Product
valid Order
→ status UNPAID
valid Order
→ correct CustomerId
valid Order
→ Inventory decrease requested
Server-Side Pricing Test
Suppose Product Repository returns:
price = 99.90
Even if a hypothetical client tried to submit some other price, the command does not even contain a price field.
Expected OrderItem:
unitPrice = 99.90
This makes client price manipulation structurally impossible through this API.
Historical Price Test
- Create Product at:
89.90
-
Create Order.
-
Change Product price to:
99.90
- Reload Order.
Expected:
OrderItem.unitPrice = 89.90
This should eventually be an integration test because persistence is central to the behaviour.
Inventory Consumption Test
Initial:
Inventory = 10
Create Order:
quantity = 3
Expected after successful operation:
Inventory = 7
At this stage a unit test can verify orchestration intent.
The later concurrency lesson will add tests for simultaneous consumption correctness.
Whole-Order Failure Test
Initial:
Product A Inventory = 10
Product B Inventory = 1
Request:
A × 2
B × 5
Expected:
Order creation fails
Final desired persistent state:
A Inventory = 10
B Inventory = 1
no Order
The transaction lesson will prove this against PostgreSQL.
Persistence Integration Tests
Eventually verify:
Order UUID persists
CustomerId persists
UNPAID persists
createdAt persists
OrderItems persist
composite OrderItem key works
purchase-time price persists
total reconstructs correctly
Use PostgreSQL/Testcontainers, not a mocked JpaRepository, for this confidence.
What We Have Not Finished Yet
This lesson implements the business workflow, but two production-critical concerns intentionally remain:
concurrency-safe Inventory consumption
and:
transaction/rollback guarantees
That is not an accidental omission.
They are the next engineering problems created by this workflow.
The course is now following a realistic progression:
first make the business operation clear
↓
identify race conditions
↓
choose persistence strategy
↓
define transaction boundary
↓
test rollback/failure
What We Deliberately Did Not Add
We did not add:
Customer table
CustomerRepository
Cart
OrderItemId
Order total column
Product name snapshot
Inventory reservation
reservation expiry
warehouse
payment during Order creation
discount
tax
shipping
coupon
refund
Kafka
Redis
distributed transaction
None are part of the approved v1 workflow.
Engineering Principle
The core principle:
Create Order is an application workflow, not a database insert. The UseCase coordinates Customer identity, Product state, Inventory state, pricing, Domain construction, and persistence as one business operation.
Another:
The server is authoritative for Product state, Inventory availability, Customer ownership, purchase-time price, Order identity, total, and lifecycle state. The request supplies only the customer's intended Product IDs and quantities.
And:
Make the workflow semantically correct first, then solve its persistence race conditions and transaction guarantees deliberately. ORM convenience should never hide those problems.
Summary
In this lesson, we implemented the structure of:
POST /api/v1/orders
and established that:
- Create Order receives only Product IDs and quantities from the client.
- CustomerId comes from authenticated identity, not request JSON.
- No local Customer aggregate or CustomerRepository is required.
- Order IDs are application-generated UUIDs.
- Orders begin in
UNPAID. - Order creation requires at least one item.
- Item quantity must be a positive whole number.
- Duplicate Product lines are rejected.
- The database composite key
(order_id, product_id)reinforces the no-duplicate rule. - Every requested Product must exist.
- Every requested Product must be active at Order creation time.
- Product browsing results are not trusted as current Order state.
- Inventory is rechecked during Order creation.
- Missing or insufficient Inventory prevents Order creation.
- Any invalid item fails the whole Order.
- Current Product price is loaded server-side.
- OrderItem captures purchase-time
unitPrice. - Product price changes later do not reprice existing Orders.
- Product deactivation after Order creation does not invalidate an existing Order.
- Order total is derived from OrderItem totals rather than persisted independently.
- Successful Order creation consumes available Inventory immediately in v1.
- Order cancellation will restore consumed Inventory.
- No Inventory reservation model is introduced.
- Payment is a separate later operation.
- Payment failure will not automatically cancel the Order.
- OrderRepository owns persistence of the Order aggregate and its OrderItems.
- No application-level OrderItemRepository is needed.
- Order
createdAtis now explicitly application-generated usingInstant, persisted asTIMESTAMPTZ. Clockis a justified dependency for deterministic time-based tests.- The straightforward Inventory load/check/decrease/save flow is not yet concurrency-safe.
- We deliberately defer the concrete concurrency mechanism rather than pretending ordinary JPA
save()solves overselling. - The final Create Order workflow must run inside one local PostgreSQL transaction.
- Transaction and rollback behaviour will be implemented and tested in the upcoming lessons.
Next lesson:
Calculating Order Totals
There we will focus on monetary correctness inside the Order aggregate: BigDecimal, OrderItem subtotal, Order total derivation, immutable purchase-time pricing, scale/rounding considerations, and why the backend must never trust or persist a client-supplied total.