Implementing Business Workflows

Implementing Order Creation

ReadingPreview

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

  1. Create Product at:
89.90
  1. Create Order.

  2. Change Product price to:

99.90
  1. 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 createdAt is now explicitly application-generated using Instant, persisted as TIMESTAMPTZ.
  • Clock is 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.