Implementing Business Workflows
Implementing Product Creation
আপনি একটি 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.
ProductIdwrapsjava.util.UUID.- PostgreSQL stores Product IDs using the native
UUIDtype. - JPA uses
UUIDdirectly and no longer needsGenerationType.IDENTITY. - Product identity exists before persistence.
- Create Product request contains only
nameandprice. - 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.
BigDecimalis used for price.- HTTP validation rejects invalid request input early.
- Product domain independently protects intrinsic invariants.
- PostgreSQL constraints provide another integrity boundary.
CreateProductUseCasegenerates identity, constructs Product, and persists it.- Handler does not create domain objects through repository orchestration itself.
- Application
ProductRepositoryremains separate from Spring Data'sJpaRepository. JpaProductRepositorymaps Product to and fromProductEntity.ProductJpaRepositorynow usesJpaRepository<ProductEntity, UUID>.201 Createdand theLocationheader 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.