Implementing Product Creation
The first complete vertical slice, from request to committed row.
You are viewing a free preview lesson.
Until now, Product creation has existed in separate pieces of our design. The domain has Product and ProductId (Module 4), the HTTP API has POST /api/v1/products (Module 5), PostgreSQL has the products table, and the repository boundary runs from ProductRepository through JpaProductRepository to the database (Module 6). Our architecture says handler → UseCase → Repository → PostgreSQL.
In this lesson, we connect all of those pieces into one complete application workflow:
Drawing diagram…
This is our first complete vertical slice. Instead of designing one layer in isolation, we follow one real business operation from the HTTP request to durable persistence and back to the HTTP response.
The Requirement
An Admin can create a Product:
POST /api/v1/products
Content-Type: application/json
{
"name": "Mechanical Keyboard",
"priceCents": 8990
}
A successful response:
HTTP/1.1 201 Created
Location: /api/v1/products/8cb143da-7556-485f-a042-044efa9a8f82
Content-Type: application/json
{
"id": "8cb143da-7556-485f-a042-044efa9a8f82",
"name": "Mechanical Keyboard",
"priceCents": 8990,
"active": true
}
The client provides name and priceCents; the server controls id and active, because Product identity and initial lifecycle state belong to the application, and lifecycle changes go through the dedicated POST /api/v1/products/{productId}/deactivate. Our request DTO simply has no field for them. If a client sends "id": "…" and "active": false anyway, Spring Boot's Jackson configuration ignores the unknown properties: the response carries a server-generated ID and "active": true. The client cannot choose either value.
Product Creation Rules
Our current requirements are intentionally small. A Product requires a name, which must not be blank, and a priceCents in integer cents (4.06), which must not be negative, so {"name": "Free Sample", "priceCents": 0} is valid. A new Product starts with active = true. We have no requirements for SKU, description, category, image, brand, currency, a maximum name length, name uniqueness or a maximum price, and we do not invent them.
Identity is generated in the application before persistence, with the Product class from Module 4 unchanged: Product.create(...) always produces an active Product and rejects a blank name or a negative price with IllegalArgumentException, and Product.reconstitute(...) restores persisted state.
Product product = Product.create(ProductId.newId(), name, priceCents);
The repository receives a complete Product with its ID and persists it; there is no null ID and no database-generated ID round trip.
The Request DTO
Our transport model belongs in product/api/. It is the validated DTO from Module 5, with messages for the field errors our clients see:
package com.tarkev.ordermanagement.product.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
public record CreateProductRequest(
@NotBlank(message = "Name is required.")
String name,
@NotNull(message = "Price is required.")
@PositiveOrZero(message = "Price must not be negative.")
Long priceCents) {
}
priceCents stays a Long at the HTTP boundary so a missing value is a REQUIRED field error rather than an unreadable body (5.06). The DTO rejects a missing or blank name (including " ") and a missing or negative priceCents before any workflow runs; the domain and the products_price_cents_non_negative CHECK constraint still protect the same rules for every other caller (validation layers, 5.06). The handler should only translate HTTP into an application operation: generating the ID, constructing the Product and saving it are the use case's job.
CreateProductCommand
The use case needs a name and a price. A small application input makes that explicit:
package com.tarkev.ordermanagement.product.application;
public record CreateProductCommand(String name, long priceCents) {
}
Notice what it does not contain: ProductId and active. Those values are application-controlled. The command uses long because, by the time a command exists, the price has been validated as present.
CreateProductUseCase
The application operation is intentionally simple:
package com.tarkev.ordermanagement.product.application;
import com.tarkev.ordermanagement.product.domain.Product;
import com.tarkev.ordermanagement.product.domain.ProductId;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@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.priceCents());
productRepository.save(product);
return product;
}
}
The workflow generates a ProductId, constructs a valid Product, persists it and returns it. Nothing more is required. Module 4 sketched the same execute(CreateProductCommand); what changes now that it is a real HTTP operation is the transaction around it.
What Creation Does Not Do
Check Name Uniqueness
We have no requirement stating that Product names must be unique, so we do not add productRepository.existsByName(command.name()) and we do not add UNIQUE (name) to the schema. Two Products may legitimately share a display name. Requirements come first.
Create Inventory
Creating a Product does not automatically create an Inventory row with availableQuantity = 0. Product and Inventory are separate capabilities, and Inventory is managed explicitly through PUT /api/v1/inventory/{productId}. An Admin workflow is "create the Product, then set its Inventory", without coupling the two operations implicitly.
Make the Product Visible to Customers
A new Product is active, but it may still have no Inventory row or availableQuantity = 0. Customer browsing requires Product.active = true and Inventory.availableQuantity > 0, so "the Product exists" does not mean "Customers can see or order it". That is the model we deliberately designed.
Transaction Boundary
Product creation performs one local persistence mutation, the insert of one row. The persistence infrastructure could perform that write transactionally on its own, but our application convention is:
Mutating use cases own their local transaction semantics.
So @Transactional on execute(...) is appropriate, and the transaction stays very small: generate a UUID, construct the Product, persist it.
The Persistence Side
ProductRepository is unchanged from Module 6: findById(ProductId) and void save(Product), since the ID exists before persistence. ProductEntity from the entity-mapping lesson maps id, name, price_cents and active without @GeneratedValue: the application already generated the UUID. The Flyway migration V1__create_products.sql matches it: id UUID PRIMARY KEY with no GENERATED ALWAYS AS IDENTITY and no DEFAULT gen_random_uuid(), because the application inserts the Product UUID explicitly. The Spring Data interface stays package-private inside product/persistence/, with UUID as its identifier type, matching PostgreSQL and our domain identifier:
package com.tarkev.ordermanagement.product.persistence;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
interface ProductJpaRepository extends JpaRepository<ProductEntity, UUID> {
}
Assigned UUIDs Need a Deliberate Insert
Spring Data's save(...) treats an entity with an assigned UUID as existing and merges it (an extra SELECT, measured in 6.08 and explained in 6.12), so the JPA adapter from 6.08 now persists a new Product deliberately with EntityManager.persist(...), inside the use case's transaction:
package com.tarkev.ordermanagement.product.persistence;
import com.tarkev.ordermanagement.product.application.ProductRepository;
import com.tarkev.ordermanagement.product.domain.Product;
import com.tarkev.ordermanagement.product.domain.ProductId;
import jakarta.persistence.EntityManager;
import java.util.Optional;
import org.springframework.stereotype.Repository;
@Repository
public class JpaProductRepository implements ProductRepository {
private final ProductJpaRepository repository;
private final EntityManager entityManager;
public JpaProductRepository(
ProductJpaRepository repository, EntityManager entityManager) {
this.repository = repository;
this.entityManager = entityManager;
}
@Override
public void save(Product product) {
entityManager.persist(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.priceCents(), product.isActive());
}
private Product toDomain(ProductEntity entity) {
return Product.reconstitute(new ProductId(entity.getId()),
entity.getName(), entity.getPriceCents(), entity.isActive());
}
}
Creating a Product through the API now logs exactly one statement, executed when the use case's transaction commits:
Hibernate: insert into products (active,name,price_cents,id) values (?,?,?,?)
The use case remains unaware of EntityManager, persist() and managed state. The same save(...) will also have to persist changes to existing Products, and persist(...) of an object with an existing ID is not an update; the next lesson refines the adapter for that.
ProductHandler
The HTTP boundary remains thin. It is the handler from Module 5: create(...) validates the body (5.06) and builds a command:
package com.tarkev.ordermanagement.product.api;
import com.tarkev.ordermanagement.product.application.CreateProductCommand;
import com.tarkev.ordermanagement.product.application.CreateProductUseCase;
import com.tarkev.ordermanagement.product.domain.Product;
import jakarta.validation.Valid;
import java.net.URI;
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;
@RestController
@RequestMapping("/api/v1/products")
public class ProductHandler {
private final CreateProductUseCase createProduct;
public ProductHandler(CreateProductUseCase createProduct) {
this.createProduct = createProduct;
}
@PostMapping
public ResponseEntity<ProductResponse> create(
@Valid @RequestBody CreateProductRequest request) {
Product product = createProduct.execute(new CreateProductCommand(
request.name(), request.priceCents()));
URI location = URI.create("/api/v1/products/" + product.id().value());
return ResponseEntity.created(location)
.body(ProductResponse.from(product));
}
}
The handler owns the HTTP request, validation, the mapping from request DTO to command, the use case call, the mapping from Product to ProductResponse, the 201 Created status and the Location header. It does not touch repository infrastructure.
ProductResponse is the record from Module 5, built by ProductResponse.from(product).
Validation Failures
A request with a blank name and a negative price fails before the use case runs:
POST /api/v1/products
Content-Type: application/json
{
"name": " ",
"priceCents": -1000
}
Our ApiExceptionHandler from Module 5 turns the MethodArgumentNotValidException into the canonical RFC 9457 response, with one entry per field:
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://api.tarkev.com/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."
},
{
"field": "priceCents",
"code": "INVALID_VALUE",
"message": "Price must not be negative."
}
]
}
@NotBlank maps to REQUIRED and @PositiveOrZero to INVALID_VALUE. A body without priceCents gives "code": "REQUIRED" with "Price is required.". The order of the entries is not guaranteed, so tests should not depend on it.
We have not defined a normalization rule, so we do not silently trim " Mechanical Keyboard ": validation and normalization are different decisions.
Duplicates, Security and Failures
Sending the same create request twice creates two Products with two different UUIDs: Product creation is not idempotent, and with no uniqueness rule or idempotency-key requirement that is acceptable for an Admin operation. Product creation is an ADMIN operation, but authorization arrives in Module 8, so we add no temporary isAdmin flag or fake role check now. A PostgreSQL outage at commit is an infrastructure failure that becomes our safe 500 INTERNAL_ERROR (7.13), and with no requirement about duplicates or quotas we invent no ProductAlreadyExists. Simple workflows should remain simple.
Testing the Slice
Domain Tests
Product domain tests remain plain Java, with no Spring, HTTP or PostgreSQL, because they test Product behaviour. ProductTest from the Product-modeling lesson (4.04) already covers the price rules, renaming and deactivation. The creation slice adds two tests to that class for the creation rules it relies on, a new Product starting active and a blank name being rejected:
// Added to ProductTest (product.domain, lesson 4.04);
// also imports assertTrue.
@Test
void createsActiveProduct() {
Product product =
Product.create(ProductId.newId(), "Mechanical Keyboard", 8990);
assertTrue(product.isActive());
assertEquals(8990, product.priceCents());
}
@Test
void rejectsBlankName() {
assertThrows(IllegalArgumentException.class, () ->
Product.create(ProductId.newId(), " ", 8990));
}
UseCase Test
UseCase testing focuses on orchestration. A small in-memory fake implements the application repository; it lives in the test sources, next to the use case tests:
<!-- file: src/test/java/com/tarkev/ordermanagement/product/application/InMemoryProductRepository.java -->package com.tarkev.ordermanagement.product.application;
import com.tarkev.ordermanagement.product.domain.Product;
import com.tarkev.ordermanagement.product.domain.ProductId;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
class InMemoryProductRepository implements ProductRepository {
private final Map<ProductId, Product> products = new HashMap<>();
@Override
public Optional<Product> findById(ProductId productId) {
return Optional.ofNullable(products.get(productId));
}
@Override
public void save(Product product) {
products.put(product.id(), product);
}
}
package com.tarkev.ordermanagement.product.application;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.tarkev.ordermanagement.product.domain.Product;
import org.junit.jupiter.api.Test;
class CreateProductUseCaseTest {
private final InMemoryProductRepository repository =
new InMemoryProductRepository();
private final CreateProductUseCase useCase =
new CreateProductUseCase(repository);
@Test
void createsAndPersistsProduct() {
Product product = useCase.execute(
new CreateProductCommand("Mechanical Keyboard", 8990));
assertEquals("Mechanical Keyboard", product.name());
assertEquals(8990, product.priceCents());
assertTrue(product.isActive());
assertSame(product, repository.findById(product.id()).orElseThrow());
}
}
No JPA is required to verify the application workflow. ProductId is a record, so it works as a map key.
Repository and API Tests
A persistence integration test against real PostgreSQL (Module 10) proves what a fake cannot: the UUID, name, BIGINT price and active state round-trip correctly. The HTTP test verifies the public contract: 201, the Location header, "active": true, and 400 VALIDATION_ERROR for a negative price or a blank name.
One Vertical Slice, End to End
We can now describe the entire capability:
| Layer | Classes |
|---|---|
| Transport | ProductHandler, CreateProductRequest, ProductResponse |
| Application | CreateProductCommand, CreateProductUseCase, ProductRepository |
| Domain | Product, ProductId |
| Persistence | ProductEntity, ProductJpaRepository, JpaProductRepository |
| Database | products |
POST /api/v1/products now runs a complete application workflow: this is the point where architecture becomes executable software, and the pattern future capabilities can follow without cloning every class blindly.
Keep the Slice Specific
After implementing Product creation, it may be tempting to introduce CreateEntityUseCase<T>, CrudHandler<T> or GenericRepository<T>, because Product, Inventory and Order all interact with persistence. Do not. They have very different semantics:
- Product: create, rename, change price, deactivate.
- Inventory: set an exact quantity, consume atomically, restore.
- Order: create an aggregate, cancel, pay, check ownership, list history.
A little repeated structural shape (Handler, use case, repository) is not a problem; it keeps responsibilities explicit.
Practice Exercise 1: Predict the Responses
Solutions: lesson 7.15.
Predict the status and, for success, the name and active in the response for each POST /api/v1/products body. For failures, name the error code and the field.
{"name": "USB-C Cable", "priceCents": 0}{"name": "Desk Mat", "priceCents": 1500, "active": false, "id": "8cb143da-7556-485f-a042-044efa9a8f82"}{"name": "Desk Mat"}{"name": " Desk Mat ", "priceCents": 1500}- The second body sent again, unchanged.
Practice Exercise 2: Which Layer Stops It?
The request DTO, the domain and the database each protect part of Product creation. Find out which rules PostgreSQL enforces on its own. In psql against your local database (docker compose exec postgres psql -U order_management order_management), insert directly into products, bypassing the application, with IDs you choose:
- A Product whose name is
' 'and whose price is 1500. - A Product named
Desk Matwith price-100. - Two Products named
Desk Matwith price 1500 and different IDs, in oneINSERT. - A Product named
Desk Padthat reuses one of the IDs from step 3.
Predict INSERT 0 n or the constraint that refuses each statement, then run them and compare. Finish with a DELETE of your practice rows. Which of these rules would be lost if the request DTO and Product.create(...) were removed?
Practice Exercise 3: Should Creation Add Inventory?
A teammate proposes that CreateProductUseCase also saves Inventory.create(product.id(), 0), "so every Product has an Inventory row from the start". Argue for or against the change in a few sentences, using what this lesson says about Product, Inventory and the Admin workflow.
Knowledge Check
Question 1
Why does the application, not PostgreSQL, generate the Product's UUID?
Question 2
The DTO already rejects a negative price. Why do Product.create(...) and the CHECK constraint reject it too?
Question 3
Why does CreateProductRequest declare Long priceCents while CreateProductCommand uses long?
Question 4
Why does JpaProductRepository.save(...) call EntityManager.persist(...) instead of Spring Data's save(...)?
Question 5
Product creation inserts one row. Why does execute(...) still carry @Transactional?
Knowledge Check Answers
Answer 1
Identity is part of the domain object from the moment it exists: Product.create(...) needs a ProductId, the repository receives a complete Product, and save(...) can return void with no database round trip for the key.
Answer 2
Each layer protects a different path: the DTO gives HTTP clients a clear 400, the domain protects every Java caller such as a test or a future job, and the constraint protects the data against any writer, including a bug or manual SQL.
Answer 3
At the HTTP boundary a missing price must be detectable, so null becomes a REQUIRED field error rather than an unreadable body. Once a command exists, the price has been validated as present, and a primitive says so.
Answer 4
The entity has an assigned UUID, so Spring Data treats it as existing and merges it, which costs an extra SELECT. persist(...) states the intent "insert this new Product" and logs exactly one INSERT.
Answer 5
The team's convention is that mutating use cases own their local transaction semantics. The boundary stays visible and correct when the operation later grows, and the same rule applies to every write.
Engineering Principle
A vertical slice connects one real business operation from the HTTP boundary to durable persistence without collapsing transport, application workflow, domain and database responsibilities into one class.
It implements only the creation rules we actually have, not the ones common in commerce systems.
Summary
- Product creation is our first complete vertical slice:
POST /api/v1/productsruns fromProductHandlerthroughCreateProductUseCase,ProductandProductRepositoryto PostgreSQL, and answers201 Createdwith aLocationheader. - The client sends only
nameandpriceCents(integer cents, zero allowed); the application generates the UUID and every new Product starts active. CreateProductRequestuses@NotBlank,@NotNulland@PositiveOrZeroon aLongprice; the domain and theCHECKconstraint still protect the same rules, and failures become400 VALIDATION_ERRORwith field errors.CreateProductCommandcarries application input only, andCreateProductUseCasegenerates identity, constructs the Product, persists it and returns it inside a small transaction.ProductRepository.save(...)returnsvoid;JpaProductRepositoryusesEntityManager.persist(...)because Spring Data'ssave()would merge an entity with an assigned UUID.- No name uniqueness, automatic Inventory, trimming or idempotency is added without a requirement; an active Product is not customer-orderable until it has Inventory.
- We build no generic CRUD framework: Product, Inventory and Order have different semantics.
- Domain tests need no Spring; use case tests use an in-memory
ProductRepository; persistence and API tests check the real database and the HTTP contract.
In the next lesson, Implementing Product Updates and Deactivation, we implement PATCH /api/v1/products/{productId} and POST /api/v1/products/{productId}/deactivate with UpdateProductUseCase and DeactivateProductUseCase: partial updates where omitted fields stay unchanged, idempotent deactivation, and persistence of existing UUID-backed Products without physical deletion.
Think first
Coming soonCheckpoint questions that ask you to commit to an answer before the lesson shows it.