Implementing Business Workflows

Implementing Product Creation

ReadingPreview

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

এখন পর্যন্ত আমরা Product নিয়ে অনেক design decision নিয়েছি।

আমাদের Product domain:

Product
├── ProductId
├── name
├── price
└── active

আমাদের API:

POST /api/v1/products

আমাদের database:

products

আমাদের architecture:

Handler
    ↓
UseCase
    ↓
Repository
    ↓
PostgreSQL

কিন্তু এখন পর্যন্ত এগুলো mostly আলাদা আলাদা concept ছিল।

এই lesson-এ আমরা প্রথমবার পুরো flow-টি একসঙ্গে implement করব:

HTTP Request
    ↓
CreateProductHandler
    ↓
CreateProductUseCase
    ↓
Product
    ↓
ProductRepository
    ↓
JpaProductRepository
    ↓
PostgreSQL
    ↓
201 Created

এটি আমাদের application-এর প্রথম complete vertical slice


The Requirement

Admin একটি Product create করতে পারবে।

Request:

POST /api/v1/products
Content-Type: application/json
{
  "name": "Mechanical Keyboard",
  "price": 89.90
}

Successful response:

201 Created
Location: /api/v1/products/8cb143da-7556-485f-a042-044efa9a8f82
{
  "id": "8cb143da-7556-485f-a042-044efa9a8f82",
  "name": "Mechanical Keyboard",
  "price": 89.90,
  "active": true
}

Important server-controlled values:

id
→ server generates

active
→ server decides initial state

Client cannot send:

{
  "id": "...",
  "active": false
}

during Product creation.


Product Creation Rules

Our current requirements are intentionally small.

A Product requires:

name
price

Rules:

name
→ must not be blank
price
→ must not be negative

Initial Product state:

active = true

No requirements currently exist for:

SKU

description

category

image

brand

stock inside Product

maximum name length

maximum price

We do not invent them.


UUID Identity

We have now standardized application entity identifiers on UUID.

Product ID representation:

HTTP
→ UUID string

Domain
→ ProductId(UUID)

Persistence
→ UUID

PostgreSQL
→ UUID

Domain identifier:

package io.liveklass.ordermanagement.product.domain;

import java.util.Objects;
import java.util.UUID;

public record ProductId(
        UUID value
) {

    public ProductId {
        Objects.requireNonNull(
                value,
                "value must not be null"
        );
    }

    public static ProductId newId() {
        return new ProductId(
                UUID.randomUUID()
        );
    }
}

Now identity exists before persistence.

Flow:

Create Product
    ↓
generate UUID
    ↓
construct Product
    ↓
persist Product

We no longer need:

null ID

database-generated identity

fake ProductId(0)

Why Generate UUID in the Application?

This gives the domain a real identity immediately:

ProductId productId =
        ProductId.newId();

We can then create:

Product product =
        Product.create(
                productId,
                name,
                price
        );

The Product remains a complete domain object before PostgreSQL is involved.

This also keeps persistence simple:

Repository receives Product with identity
→ persists it

rather than:

Repository generates identity
→ application reconstructs Product

Product Domain Model

A focused Product implementation could look like:

package io.liveklass.ordermanagement.product.domain;

import java.math.BigDecimal;
import java.util.Objects;

public final class Product {

    private final ProductId id;

    private String name;

    private BigDecimal price;

    private boolean active;

    private Product(
            ProductId id,
            String name,
            BigDecimal price,
            boolean active
    ) {
        this.id =
                Objects.requireNonNull(
                        id,
                        "id must not be null"
                );

        this.name =
                requireValidName(name);

        this.price =
                requireValidPrice(price);

        this.active =
                active;
    }

    public static Product create(
            ProductId id,
            String name,
            BigDecimal price
    ) {
        return new Product(
                id,
                name,
                price,
                true
        );
    }

    public static Product reconstitute(
            ProductId id,
            String name,
            BigDecimal price,
            boolean active
    ) {
        return new Product(
                id,
                name,
                price,
                active
        );
    }

    public ProductId id() {
        return id;
    }

    public String name() {
        return name;
    }

    public BigDecimal price() {
        return price;
    }

    public boolean active() {
        return active;
    }

    public void rename(
            String name
    ) {
        this.name =
                requireValidName(name);
    }

    public void changePrice(
            BigDecimal price
    ) {
        this.price =
                requireValidPrice(price);
    }

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

    private static String requireValidName(
            String name
    ) {
        if (
                name == null ||
                name.isBlank()
        ) {
            throw new IllegalArgumentException(
                    "Product name must not be blank"
            );
        }

        return name;
    }

    private static BigDecimal requireValidPrice(
            BigDecimal price
    ) {
        Objects.requireNonNull(
                price,
                "price must not be null"
        );

        if (
                price.compareTo(
                        BigDecimal.ZERO
                ) < 0
        ) {
            throw new IllegalArgumentException(
                    "Product price must not be negative"
            );
        }

        return price;
    }
}

The important part is not the exact exception class yet.

The important part is:

Product.create()
→ always creates an active Product

and:

Product
→ protects its own intrinsic state

Why active Is Not a Request Field

A newly created Product begins:

active = true

because that is our application behaviour.

If the client could send:

{
  "active": false
}

during creation, then the transport contract would start controlling Product lifecycle unnecessarily.

We already have a separate operation:

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

Lifecycle changes should happen through the operation designed for them.


Request DTO

HTTP request:

package io.liveklass.ordermanagement.product.handler;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;

import java.math.BigDecimal;

public record CreateProductRequest(

        @NotBlank
        String name,

        @NotNull
        @PositiveOrZero
        BigDecimal price
) {
}

This handles transport-level validation:

name missing/blank

price missing

price negative

before the UseCase performs application work.


Why BigDecimal?

Price is an exact decimal business value.

Do not use:

double price;

for monetary values.

Request:

{
  "price": 89.90
}

maps to:

BigDecimal

and PostgreSQL stores:

NUMERIC

This keeps decimal representation consistent across boundaries.


Handler Should Not Create the Product

A common implementation would be:

@PostMapping
public ProductResponse create(
        @RequestBody CreateProductRequest request
) {
    Product product =
            Product.create(...);

    repository.save(product);

    ...
}

But now Handler owns:

UUID generation

domain construction

persistence orchestration

That violates our boundary.

Handler should translate HTTP into an application operation.


CreateProductCommand

The UseCase only needs:

name
price

A small application command is useful:

package io.liveklass.ordermanagement.product.usecase;

import java.math.BigDecimal;

public record CreateProductCommand(
        String name,
        BigDecimal price
) {
}

Notice it does not contain:

ProductId

active

because those are application-controlled.


CreateProductUseCase

Now the application operation:

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;

@Component
public class CreateProductUseCase {

    private final ProductRepository
            productRepository;

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

    public Product execute(
            CreateProductCommand command
    ) {
        Product product =
                Product.create(
                        ProductId.newId(),
                        command.name(),
                        command.price()
                );

        productRepository.save(
                product
        );

        return product;
    }
}

This is intentionally simple.

The operation is:

generate identity

construct valid Product

persist Product

return Product

Nothing more is required.


Should CreateProductUseCase Check Name Uniqueness?

No.

We do not have a requirement saying:

Product names must be unique

Therefore do not add:

if (
    productRepository.existsByName(...)
) {
    ...
}

and do not add:

UNIQUE(name)

to the database.

Two Products having the same display name may be perfectly valid.

Requirements first.


Should UseCase Validate Price Again?

Transport already has:

@PositiveOrZero

but Product still protects:

price >= 0

because Product may be created from places other than HTTP.

This is intentional layered protection:

Handler validation
→ reject invalid HTTP input early
Domain invariant
→ invalid Product cannot exist
Database CHECK
→ invalid persisted price cannot exist

Different layers protect different boundaries.


Do We Need @Transactional Here?

Product creation is currently:

one Product insert

There is no multi-resource workflow yet.

Spring Data persistence itself operates transactionally where required, and we could technically persist this simple Product without declaring a wider application transaction.

However, our architectural convention is that mutating application operations own their transaction semantics.

So a reasonable final UseCase is:

@Component
public class CreateProductUseCase {

    private final ProductRepository
            productRepository;

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

    @Transactional
    public Product execute(
            CreateProductCommand command
    ) {
        Product product =
                Product.create(
                        ProductId.newId(),
                        command.name(),
                        command.price()
                );

        productRepository.save(
                product
        );

        return product;
    }
}

with:

import org.springframework.transaction.annotation.Transactional;

For this simple operation the transaction is small.

Later, Create Order will show why UseCase-level transactions become essential.


Keep Transaction Small

Inside this transaction we do:

generate UUID

construct Product

persist Product

No:

remote HTTP call

file upload

email

Payment Provider

The transaction surrounds local database work.


ProductRepository

Application repository:

package io.liveklass.ordermanagement.product.repository;

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

import java.util.Optional;

public interface ProductRepository {

    void save(
            Product product
    );

    Optional<Product> findById(
            ProductId productId
    );
}

Because UUID already exists before persistence, we do not need:

Product save(Product product);

merely to receive a generated ID back.

Returning void is now a perfectly reasonable contract.


Repository Contract Should Match Application Need

Create Product needs:

persist this Product

It does not currently need Repository to return:

the exact same Product again

So:

void save(Product product);

is clear.

If persistence later legitimately produces application-relevant state, we can change the contract.


ProductEntity

Our UUID-based persistence entity:

package io.liveklass.ordermanagement.product.persistence;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import java.math.BigDecimal;
import java.util.UUID;

@Entity
@Table(name = "products")
public class ProductEntity {

    @Id
    @Column(
            name = "id",
            nullable = false
    )
    private UUID id;

    @Column(
            name = "name",
            nullable = false
    )
    private String name;

    @Column(
            name = "price",
            nullable = false
    )
    private BigDecimal price;

    @Column(
            name = "active",
            nullable = false
    )
    private boolean active;

    protected ProductEntity() {
    }

    public ProductEntity(
            UUID id,
            String name,
            BigDecimal price,
            boolean active
    ) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.active = active;
    }

    public UUID getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public boolean isActive() {
        return active;
    }
}

Notice:

no @GeneratedValue

because application already generated the UUID.


Flyway Schema Adjustment

Our canonical Product migration now uses UUID:

CREATE TABLE products (
    id UUID PRIMARY KEY,
    name TEXT NOT NULL,
    price NUMERIC NOT NULL,
    active BOOLEAN NOT NULL,

    CONSTRAINT products_price_non_negative
        CHECK (price >= 0)
);

No:

GENERATED ALWAYS AS IDENTITY

No PostgreSQL UUID default is required either.

The application inserts the ID explicitly.


Spring Data Repository

Infrastructure:

package io.liveklass.ordermanagement.product.persistence;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.UUID;

interface ProductJpaRepository
        extends JpaRepository<
                ProductEntity,
                UUID
        > {
}

Again:

UUID

is now consistent from domain through persistence.


JPA Adapter

package io.liveklass.ordermanagement.product.persistence;

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.Repository;

import java.util.Optional;

@Repository
public class JpaProductRepository
        implements ProductRepository {

    private final ProductJpaRepository
            repository;

    public JpaProductRepository(
            ProductJpaRepository repository
    ) {
        this.repository =
                repository;
    }

    @Override
    public void save(
            Product product
    ) {
        repository.save(
                toEntity(product)
        );
    }

    @Override
    public Optional<Product> findById(
            ProductId productId
    ) {
        return repository
                .findById(
                        productId.value()
                )
                .map(
                        this::toDomain
                );
    }

    private ProductEntity toEntity(
            Product product
    ) {
        return new ProductEntity(
                product.id().value(),
                product.name(),
                product.price(),
                product.active()
        );
    }

    private Product toDomain(
            ProductEntity entity
    ) {
        return Product.reconstitute(
                new ProductId(
                        entity.getId()
                ),
                entity.getName(),
                entity.getPrice(),
                entity.isActive()
        );
    }
}

Persistence mapping remains explicit.


A Nuance About save()

This implementation:

repository.save(
        toEntity(product)
);

is simple enough for our current Product workflow.

But remember from Module 6:

Spring Data save()

is not equivalent to:

always execute this exact INSERT immediately

JPA lifecycle and transaction synchronization still apply.

Our application Repository deliberately hides those mechanics.


Handler

Now HTTP layer:

package io.liveklass.ordermanagement.product.handler;

import io.liveklass.ordermanagement.product.domain.Product;
import io.liveklass.ordermanagement.product.usecase.CreateProductCommand;
import io.liveklass.ordermanagement.product.usecase.CreateProductUseCase;

import jakarta.validation.Valid;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;

@RestController
@RequestMapping("/api/v1/products")
public class ProductHandler {

    private final CreateProductUseCase
            createProductUseCase;

    public ProductHandler(
            CreateProductUseCase createProductUseCase
    ) {
        this.createProductUseCase =
                createProductUseCase;
    }

    @PostMapping
    public ResponseEntity<ProductResponse>
    createProduct(
            @Valid
            @RequestBody
            CreateProductRequest request,
            UriComponentsBuilder uriBuilder
    ) {
        Product product =
                createProductUseCase.execute(
                        new CreateProductCommand(
                                request.name(),
                                request.price()
                        )
                );

        URI location =
                uriBuilder
                        .path(
                                "/api/v1/products/{productId}"
                        )
                        .buildAndExpand(
                                product.id().value()
                        )
                        .toUri();

        return ResponseEntity
                .created(location)
                .body(
                        ProductResponse.from(
                                product
                        )
                );
    }
}

Handler responsibilities remain:

HTTP input

request validation

request → command mapping

invoke UseCase

domain/application result → response

201 + Location

No repository access.


ProductResponse

package io.liveklass.ordermanagement.product.handler;

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

import java.math.BigDecimal;
import java.util.UUID;

public record ProductResponse(
        UUID id,
        String name,
        BigDecimal price,
        boolean active
) {

    public static ProductResponse from(
            Product product
    ) {
        return new ProductResponse(
                product.id().value(),
                product.name(),
                product.price(),
                product.active()
        );
    }
}

When serialized to JSON, UUID is represented as its standard textual form.

Example:

{
  "id": "d2487622-aa2e-4734-8e07-b51b4667413c",
  "name": "Mechanical Keyboard",
  "price": 89.90,
  "active": true
}

Do We Want String id in ProductResponse?

We could define:

String id

and manually call:

product.id().value().toString()

But using:

UUID

in the Java transport model is cleaner while JSON still represents it as a string.

The important public contract is:

UUID textual value

not the internal Java field type.


Full Request Flow

Let's trace the entire operation.

Client:

POST /api/v1/products
{
  "name": "Mechanical Keyboard",
  "price": 89.90
}

Handler:

validate JSON

then:

CreateProductRequest
    ↓
CreateProductCommand

UseCase:

ProductId.newId()
    ↓
Product.create(...)

Domain:

name valid

price >= 0

active = true

Repository:

Product
    ↓
ProductEntity

Spring Data:

save ProductEntity

PostgreSQL:

INSERT INTO products

Then result travels back:

Product
    ↓
ProductResponse

HTTP:

201 Created

Conceptual SQL

The persistence operation is conceptually equivalent to:

INSERT INTO products (
    id,
    name,
    price,
    active
)
VALUES (
    ?,
    ?,
    ?,
    ?
);

Values might be:

id
→ d2487622-aa2e-4734-8e07-b51b4667413c

name
→ Mechanical Keyboard

price
→ 89.90

active
→ TRUE

Hibernate generates the actual SQL.

We still understand what is happening relationally.


Why 201 Created?

The request created a new resource:

Product

So:

201 Created

is more precise than:

200 OK

The response also includes:

Location

pointing to the newly created Product resource.

This matches the API contract we designed earlier.


Validation Failure

Request:

{
  "name": "",
  "price": 89.90
}

should fail before UseCase:

400
VALIDATION_ERROR

Example canonical response:

{
  "type": "https://api.liveklass.io/problems/validation-error",
  "title": "Validation failed",
  "status": 400,
  "detail": "One or more fields are invalid.",
  "instance": "/api/v1/products",
  "code": "VALIDATION_ERROR",
  "errors": [
    {
      "field": "name",
      "code": "REQUIRED",
      "message": "Name is required."
    }
  ]
}

The exact Bean Validation → field-code mapping belongs to our centralized error handling from Module 5.


Negative Price

Request:

{
  "name": "Mechanical Keyboard",
  "price": -10
}

fails:

HTTP validation

and, even if a non-HTTP caller bypassed that boundary:

Product domain

would still reject negative price.

And if invalid state somehow reached PostgreSQL:

CHECK (price >= 0)

would reject it again.

This is layered integrity.


Zero Price Is Valid

Our accepted rule is:

price >= 0

not:

price > 0

So this is valid:

{
  "name": "Free Sample",
  "price": 0
}

Do not accidentally replace:

@PositiveOrZero

with:

@Positive

unless product requirements change.


Blank vs Whitespace Name

This:

{
  "name": "   ",
  "price": 10
}

should be invalid because:

@NotBlank

rejects whitespace-only names.

Domain's:

name.isBlank()

reinforces the same intrinsic rule.


Should We Trim Product Names Automatically?

We have not defined a normalization rule.

So do not silently transform:

"  Keyboard  "

into:

"Keyboard"

unless we deliberately decide that Product name normalization is part of the contract.

Validation and normalization are different decisions.

For now:

non-blank

is the only rule.


What About Duplicate Requests?

Suppose client sends the same Product creation request twice:

Mechanical Keyboard
89.90

Two separate calls will create:

Product UUID A

Product UUID B

because Product creation is not currently idempotent.

We do not have:

Product name uniqueness

idempotency key for Product creation

requirements.

This is acceptable.

Payment idempotency will be a much more important concern later.


Security Is Not Fully Implemented Yet

Our API contract says Product creation is an:

ADMIN

operation.

But authentication and authorization are implemented in Module 8.

For now we are implementing the application workflow itself.

Later:

Bearer token
    ↓
Spring Security
    ↓
ADMIN authorization
    ↓
ProductHandler

will protect this endpoint.

We do not put temporary fake admin checks inside:

CreateProductUseCase

just because Security is taught later.


Don't Add isAdmin to CreateProductCommand

Avoid:

new CreateProductCommand(
    name,
    price,
    true
)

where:

true = isAdmin

Authorization identity comes from the security boundary.

Business input should not pretend client-supplied authorization is trusted.


Persistence Failure

Suppose PostgreSQL is unavailable.

Then:

repository.save(product)

fails.

This is not:

PRODUCT_NOT_FOUND

or:

VALIDATION_ERROR

It is an unexpected infrastructure/system failure.

At HTTP boundary it should eventually become our safe:

500
INTERNAL_ERROR

response while technical details stay in logs.

UseCase should not catch:

Exception

and convert everything into:

"Could not create product"

business error.


Do We Need a Business Exception in This UseCase?

Currently Create Product has almost no expected application-state conflicts.

Input problems are handled by validation/domain invariants.

There is no:

duplicate SKU

duplicate name

organization quota

product limit

requirement.

So we don't need to invent:

ProductAlreadyExistsException

or:

CannotCreateProductException

yet.

Simple operations should remain simple.


Testing the Domain

Domain tests should verify Product creation rules.

Example:

class ProductTest {

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

        assertTrue(
                product.active()
        );
    }
}

Negative Price Test

@Test
void rejectsNegativePrice() {
    assertThrows(
            IllegalArgumentException.class,
            () -> Product.create(
                    ProductId.newId(),
                    "Mechanical Keyboard",
                    new BigDecimal("-1")
            )
    );
}

Blank Name Test

@Test
void rejectsBlankName() {
    assertThrows(
            IllegalArgumentException.class,
            () -> Product.create(
                    ProductId.newId(),
                    " ",
                    new BigDecimal("89.90")
            )
    );
}

These tests need:

no Spring

no PostgreSQL

no HTTP

because they test domain behaviour.


Testing CreateProductUseCase

UseCase test focuses on orchestration.

We can provide a fake Repository:

class InMemoryProductRepository
        implements ProductRepository {

    private Product savedProduct;

    @Override
    public void save(
            Product product
    ) {
        this.savedProduct =
                product;
    }

    @Override
    public Optional<Product> findById(
            ProductId productId
    ) {
        if (
                savedProduct != null &&
                savedProduct.id()
                        .equals(productId)
        ) {
            return Optional.of(
                    savedProduct
            );
        }

        return Optional.empty();
    }
}

Then:

@Test
void createsAndPersistsProduct() {
    InMemoryProductRepository repository =
            new InMemoryProductRepository();

    CreateProductUseCase useCase =
            new CreateProductUseCase(
                    repository
            );

    Product product =
            useCase.execute(
                    new CreateProductCommand(
                            "Mechanical Keyboard",
                            new BigDecimal("89.90")
                    )
            );

    assertNotNull(
            product.id()
    );

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

    assertTrue(
            product.active()
    );

    assertEquals(
            product,
            repository
                    .findById(
                            product.id()
                    )
                    .orElseThrow()
    );
}

No JPA is needed to test UseCase behaviour.


Repository Integration Test

Persistence test should prove:

UUID stored correctly

name stored correctly

NUMERIC price round-trips correctly

active state stored

Product can be loaded again

Eventually with PostgreSQL Testcontainers:

save
    ↓
flush / clear if needed
    ↓
reload
    ↓
assert domain state

This tests actual persistence mapping rather than mocks.


API Test

HTTP test should verify:

valid request
→ 201
Location header
→ Product resource URI
response UUID
→ present
active
→ true
negative price
→ 400 VALIDATION_ERROR
blank name
→ 400 VALIDATION_ERROR

Security behaviour is added when Module 8 wires authorization.


One Vertical Slice, End to End

We can now point to one capability and explain exactly how it works:

POST /products

Transport

CreateProductRequest
ProductResponse

Application

CreateProductCommand
CreateProductUseCase

Domain

Product
ProductId

Persistence boundary

ProductRepository

JPA infrastructure

ProductEntity
ProductJpaRepository
JpaProductRepository

Database

products

This is the pattern future capabilities can follow without blindly copying every class.


Do Not Create a Generic CRUD Framework Now

After implementing Product creation, it may be tempting to make:

CreateEntityUseCase<T>

CrudHandler<T>

GenericRepository<T>

because Inventory and Order also need persistence.

Don't.

Product, Inventory, and Order have different business semantics.

A little repeated structure:

Handler
UseCase
Repository

is not a problem.

It makes responsibilities explicit.


Do Not Introduce ProductService

The implementation remains:

ProductHandler
    ↓
CreateProductUseCase
    ↓
ProductRepository

not:

ProductController
    ↓
ProductService
    ↓
ProductRepository

Our terminology is now intentionally established.


Do Not Put Inventory in Product Creation

Product creation does not automatically create:

Inventory quantity = 0

because we never decided that every Product creation must implicitly create an Inventory row.

Inventory is a separate capability.

The admin can explicitly set Inventory through:

PUT /api/v1/inventory/{productId}

This keeps:

Product lifecycle

and:

Inventory state

separate, consistent with our domain and schema decisions.


Product Is Not Immediately Orderable Just Because It Exists

A Product can be:

active = true

but still have:

no Inventory row

or:

availableQuantity = 0

Customer browse requires:

Product active
AND
Inventory > 0

Therefore:

Product created

does not necessarily mean:

Product visible/orderable to Customer

This distinction becomes important when we implement Product browsing and Inventory management.


What We Have Achieved

Before this lesson:

Product creation

existed as:

domain design

API design

schema design

repository design

After this lesson we have a concrete application operation:

POST /api/v1/products
    ↓
working vertical slice

This is the point where architecture becomes software.


Engineering Principle

The core principle:

A vertical slice should connect one real business operation from HTTP boundary to durable persistence without letting transport, workflow, domain, and database responsibilities collapse into one class.

Another:

Generate UUID identity deliberately at the application/domain boundary, then carry the same identity through Domain, JPA, PostgreSQL, and HTTP representation.

And:

Implement only the Product creation rules we actually have. Avoid inventing uniqueness, automatic Inventory creation, normalization, or extra Product attributes just because they are common in ecommerce systems.


Summary

In this lesson, we implemented the first complete application workflow:

POST /api/v1/products

We established that:

  • Product IDs are application-generated UUIDs.
  • ProductId wraps java.util.UUID.
  • PostgreSQL stores Product IDs using the native UUID type.
  • JPA uses UUID directly and no longer needs GenerationType.IDENTITY.
  • Product identity exists before persistence.
  • Create Product request contains only name and price.
  • Product ID and initial active state are server-controlled.
  • New Products start active.
  • Product names must not be blank.
  • Product prices must be greater than or equal to zero.
  • BigDecimal is used for price.
  • HTTP validation rejects invalid request input early.
  • Product domain independently protects intrinsic invariants.
  • PostgreSQL constraints provide another integrity boundary.
  • CreateProductUseCase generates identity, constructs Product, and persists it.
  • Handler does not create domain objects through repository orchestration itself.
  • Application ProductRepository remains separate from Spring Data's JpaRepository.
  • JpaProductRepository maps Product to and from ProductEntity.
  • ProductJpaRepository now uses JpaRepository<ProductEntity, UUID>.
  • 201 Created and the Location header represent successful resource creation.
  • We do not add Product name uniqueness because no requirement exists.
  • We do not automatically create Inventory when a Product is created.
  • Product being active does not by itself make it customer-orderable; available Inventory is also required.
  • We do not introduce Product CRUD frameworks, ProductService, fake IDs, or speculative Product fields.
  • Domain, UseCase, Repository, and HTTP tests each verify different responsibilities.
  • Authorization for Product administration remains part of Module 8 rather than temporary business logic inside this UseCase.

Next lesson:

Implementing Product Updates and Deactivation

There we will implement:

PATCH /api/v1/products/{productId}

and:

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

using focused UseCases, domain behaviour such as rename(), changePrice(), and deactivate(), while preserving the rule that Products are never physically deleted from historical Order relationships.