Why Our Application Needs Persistence
Which state must survive a restart, who owns it, and what the database is not for.
You are viewing a free preview lesson.
So far, most of our design has focused on domain objects in memory and the HTTP contracts around them. We have modeled Product, Inventory, Order and OrderItem, and operations such as creating and browsing Products, setting Inventory, and creating, cancelling and paying Orders. But one fundamental problem remains. Imagine the application currently knows this state:
Product 59ba787c-6d83-4a21-9bae-19c3d0469bd5
Inventory availableQuantity = 12
Order d2e7b44e-463f-4bbc-b16f-b1c80bc80442
Then the Spring Boot process restarts. If that state exists only in Java memory, the process stops, the memory is released and the business state disappears. That is not acceptable for a production backend: a deployment must not erase Products, a restart must not reset Inventory, and a new application instance must not forget Orders.
We need durable storage that survives the lifetime of one Java process. For this course, that responsibility belongs to PostgreSQL. The goal of this lesson is:
Understand which application state must be persisted, why PostgreSQL is needed, where database responsibility begins and ends, and how persistence should support the domain rather than define it.
Memory Is Temporary
A Java object such as this one exists inside the JVM process:
<!-- fragment -->Product product =
Product.create(ProductId.newId(), "Mechanical Keyboard", 10000);
When the JVM stops, its memory is released and the object disappears. That is normal. But business state must survive deployments, restarts, crashes and new application instances. Persistence exists so that important state outlives the process currently using it.
What State Must Survive?
Our current v1 Domain has four categories of durable business state, and each teaches us something about what persistence must preserve.
Product holds its ProductId, name, priceCents and active flag. The catalog must survive restarts. Deactivation means active = false: the Product stays persisted rather than being deleted, because historical Orders may still reference its ProductId.
Inventory holds a ProductId and an availableQuantity. Suppose the quantity is 4. If a restart reset it to an arbitrary default such as 100, we could oversell Products. Persistence must also preserve one distinction: "no Inventory record exists" and "Inventory exists with availableQuantity = 0" are different states, and missing Inventory must not silently become zero Inventory.
Order holds its OrderId, CustomerId, OrderStatus, createdAt and its OrderItems. Customers need their history later, and cancellation and payment need existing Order state. Order history is ordered by createdAt DESC, OrderId DESC, and createdAt comes from Instant.now(clock) at creation, so it must be stored: reloading an Order after a restart must not create a new timestamp.
OrderItem holds ProductId, quantity and the purchase-time unitPriceCents (ADR-003, lesson 4.06). So persistence keeps both Product.priceCents, the current price, and OrderItem.unitPriceCents, the price one historical Order used; they may start identical and later differ, and that difference is intentional. The Order total stays derived from quantity × unitPriceCents, so we persist the inputs rather than a second authoritative total that could drift.
The Customer Is External
An Order belongs to a customer, so it must preserve the CustomerId. But our application does not own the customer account lifecycle: registration, passwords, profiles and account creation belong to the external identity system. "Order persists CustomerId" therefore does not imply "create a customers table". CustomerId is a stable, nonblank String, and we do not assume it is a UUID.
Identity Decisions Come Before JPA
The domain already chose these identities, and the schema will follow them:
| Concept | Identity |
|---|---|
| Product | ProductId → UUID |
| Order | OrderId → UUID |
| Customer (external) | CustomerId → String |
| Inventory | its ProductId |
| OrderItem | OrderId + ProductId |
The ORM does not decide them. A common mistake is writing @Id @GeneratedValue private Long id; and then concluding "our domain uses Long IDs because Hibernate generated them". That is backwards. ProductId and OrderId are application-generated: persistence stores them, it does not invent them. Nor do we add an InventoryId or an OrderItemId because an ORM often gives every table a generated ID.
Why PostgreSQL?
Persistence means more than "save the object". Our application needs to store, retrieve, update and query state: browse orderable Products, consume Inventory atomically, find a customer-owned Order for update, load a customer's Order history.
Our engineering context already selected PostgreSQL, and the choice fits. We have clear relationships (Product to Inventory, Order to OrderItems, Order to CustomerId), and workflows such as Create Order and Cancel Order need strong consistency. PostgreSQL gives us primary keys, foreign keys, constraints, transactions, indexes, querying and safe concurrent access.
That last point matters because many requests operate on shared state at once. Customer A creates an Order, customer B creates another, an Admin changes Inventory and customer C browses Products, all at the same moment. No request can assume it is the only actor modifying the system. PostgreSQL becomes the coordinated, durable source of application-owned state.
Persistence Changes the Lifetime of Objects
Without persistence, it is easy to imagine a Product object living indefinitely. In a real request, the use case loads durable state through a repository, the domain object performs the behaviour, and the result is persisted again:
Drawing diagram…
Product still owns its behaviour; persistence makes the resulting state durable. The Java object is temporary. The business identity is durable.
Persistence Does Not Replace the Domain
A dangerous direction is database table, then generated Java class, then public API. Now the database structure defines the application, and the classes naturally focus on columns, foreign keys, getters and setters, while our domain needs behaviour such as cancel(), markPaid() and totalCents(). Our direction remains the opposite: business requirements, then the domain model, then application workflows, then persistence design.
JPA also uses the word entity (@Entity), but that annotation describes persistence mapping. A Domain Entity such as Order has business identity and lifecycle, and it does not need to mirror one database row; in the domain an Order holds a List<OrderItem>, while relationally it is one orders row plus several order_items rows. Mapping connects the two representations.
What the Database Is For, and What It Is Not
PostgreSQL legitimately helps us with durable storage, atomic local persistence, relationships, structural integrity, efficient querying and concurrent access.
It is not the business workflow. We do not want the Order lifecycle hidden in database triggers: a rule such as "when an Order becomes CANCELLED, automatically restore Inventory" would hide important behaviour outside CancelOrderUseCase. Our architecture remains handler → UseCase → Domain → Repository → PostgreSQL: use cases own workflow, and the domain owns local invariants such as priceCents >= 0, availableQuantity >= 0, quantity > 0 and the transitions UNPAID → PAID and UNPAID → CANCELLED.
The database can reinforce those invariants. Suppose the schema includes this constraint:
CHECK (available_quantity >= 0)
We then have layered protection: request validation, then domain and application rules, then the database constraint. If a bug tries to persist availableQuantity = -3, PostgreSQL rejects it. That is useful, but it does not mean the domain should stop protecting its invariant.
Workflows Change Several Durable Facts
Our canonical Create Order workflow is:
- Resolve the
CustomerIdand reject duplicate Product IDs. - Load the Products, verify they exist and are active, and capture their authoritative
priceCents. - Atomically consume Inventory.
- Create the OrderItems, generate the
OrderIdandcreatedAt, and create anUNPAIDOrder. - Persist the Order and its OrderItems.
CreateOrderUseCase uses ProductRepository, InventoryRepository and OrderRepository along the way, so persistence is now central. Two persistence concerns follow. First, Order-time Inventory must not use load-check-save, because two concurrent Orders could both observe the same stock; ADR-005 chose an atomic conditional update in the database, inventoryRepository.consumeIfAvailable(productId, quantity), which lesson 7.08 implements. Second, successful creation changes Inventory, the Order and its OrderItems, and we must never leave Inventory decreased while the Order failed to persist. These local changes happen in one PostgreSQL transaction:
BEGIN;
-- consume Inventory
-- insert the Order
-- insert the OrderItems
COMMIT; -- or ROLLBACK if anything fails
Cancellation is the same kind of operation: the Order moves from UNPAID to CANCELLED and Inventory is restored, in one commit. Before cancellation the Order is UNPAID with a quantity of 2 and availableQuantity is 4; afterwards the Order is CANCELLED and Inventory is 6, and after a restart both are still true. That is durable consistency. Module 7 designs these transactions in detail.
Payment State
When payment succeeds and the Order becomes PAID, that status must persist; otherwise a restart could make the system believe the Order is still UNPAID and permit another payment attempt. Do we also need a payments table? Maybe: the integration may require provider references, attempt state or reconciliation data. Persistence follows the actual workflow, so Payment-specific persistence waits until module 9 establishes what is required. One thing is already clear: the provider is another system, so its work can never join our local PostgreSQL transaction.
Not Every Class Needs a Table
Types such as CreateOrderRequest, ProductResponse, ApiProblem and PageResponse are transport representations, and CreateOrderUseCase or PayOrderUseCase represent behaviour, not data records. None of them needs a table. The same reasoning applies to Customer, as we saw. Persistence is driven by durable business meaning, not by the number of Java classes.
Product and Inventory stay separate (ADR-002): Product changes through catalog decisions, Inventory through Orders, cancellations and Admin quantity changes, and their invariants and concurrency patterns differ. Separate tables do not mean separate services, though. One Spring Boot application with one PostgreSQL database gives us exactly the local transaction boundary across Inventory, Order and OrderItems that Create Order and Cancel Order need.
The Repository Is the Persistence Boundary
A repository expresses what the application needs from persistence, without forcing the use case to know SQL, JPA, tables, joins or EntityManager. A generic abstraction such as this one may expose operations the business does not support:
interface Repository<T, ID> {
T save(T entity);
T findById(ID id);
List<T> findAll();
void delete(ID id);
}
Product has no physical delete in v1, and atomic Inventory consumption is not a CRUD operation at all. So repository contracts emerge from actual workflows, and lessons 6.05 and 6.08 build them. Customer ownership belongs in those queries too: findOwnedById(orderId, customerId) makes a missing Order and someone else's Order the same ORDER_NOT_FOUND (module 8 enforces it). The boundary works in both directions: no entityManager.createQuery(...) inside CreateOrderUseCase, and no ResponseEntity, HTTP 404 or ApiProblem inside a repository.
Queries, Failures and Schema Evolution
Known queries will shape the schema, the indexes and the repository methods. Two are already concrete:
- Customer Product browsing means
active = trueandavailableQuantity > 0, ordered byname ASC, id ASC, with bounded pagination, answered in one relational query rather than one Inventory query per Product. - Customer Order history means
WHERE customer_id = <authenticated CustomerId>,ORDER BY created_at DESC, id DESC, withLIMIT/OFFSET.
Database design therefore considers both domain meaning and access patterns. It does not mean indexing everything: indexes cost disk space and write overhead, so we create them for actual query needs.
Persistence also introduces new failure modes: an unavailable database, connection and transaction failures, constraint violations, deadlocks and timeouts. They must be handled deliberately, without leaking raw SQL or database details into the public API.
And production databases contain real state, so we cannot handle every model change by dropping and recreating everything. Flyway manages versioned schema migrations; JPA maps Java structures to that schema. We do not rely on Hibernate's automatic schema generation (spring.jpa.hibernate.ddl-auto=create or update) as our migration strategy. A durable-data feature may therefore need a migration, repository and mapping changes and integration tests as part of its Definition of Done.
Local Development and Testing
As planned in module 3, PostgreSQL runs as a dependency through Docker Compose, while Spring Boot still runs directly through Gradle, the IDE or a packaged JAR:
docker compose up -d
./gradlew bootRun
The two lifecycles are separate: the Java application can restart without destroying PostgreSQL state. That is the central idea of persistence in practical form: the application process lifetime is not the business data lifetime.
Automated tests should not require a developer to start Docker Compose by hand. Later we use Testcontainers to start real PostgreSQL instances during integration tests. Not an in-memory H2, because our application depends on PostgreSQL behaviour for UUID, TIMESTAMPTZ, the conditional Inventory update, row locking and transactions. Domain behaviour such as order.cancel() still needs no database, and use case tests use test doubles; but a mock repository cannot prove that a mapping works, a migration runs or two concurrent Orders cannot oversell. Module 10 builds that strategy.
Persistence Review Questions
Before storing something, ask:
- Does this state need to survive a restart, and does this application own it?
- Is it current or historical state, and what identifies it?
- Which workflows modify it, which queries need it, and what concurrency concerns exist?
- Which integrity rules should the database reinforce?
- Does it really need its own table?
These questions help avoid both under-modeling and over-modeling.
Practice Exercise 1: Where Does the Product Live?
Solutions: lesson 6.14.
Your project is still the application from module 5. Start PostgreSQL with docker compose up -d --wait, start the application with the local profile and create a Product with POST /api/v1/products, as in module 5. Then open psql in the Compose database (3.08) and run \dt.
- What does
\dtprint, and why? - Stop the application with Ctrl+C and start it again. Where is the Product you created now?
- PostgreSQL kept running the whole time. What did it keep for you?
Practice Exercise 2: Table, Derived or Not Ours?
For each item, decide whether our application persists it (and in which of the four tables), derives it from persisted facts, or does not persist it at all. Give the reason in one sentence.
- A Product's current
priceCents. - The
unitPriceCentsof a line in last week's Order. - An Order's total.
- The Customer's email address.
- A
CreateOrderRequestbody. - The fact that a Product has no Inventory row yet.
createdAtof an Order.- The
PageResponsereturned byGET /api/v1/orders.
Practice Exercise 3: Two Commits Instead of One
A teammate implements Create Order with two separate database commits: the first consumes Inventory, the second inserts the Order and its OrderItems. The process crashes between the two. Describe what PostgreSQL holds after the restart, what the Customer sees, and why the lesson's single transaction avoids it.
Knowledge Check
Question 1
Why must createdAt be stored instead of being set again when an Order is loaded?
Question 2
Orders reference a CustomerId. Why is there still no customers table?
Question 3
The database rejects available_quantity = -3 with a CHECK constraint. Why does the domain still protect the same invariant?
Question 4
Why is a generic Repository<T, ID> with delete(ID) a poor fit for Products?
Question 5
Why does the course test persistence against real PostgreSQL instead of an in-memory H2 or a mocked repository?
Knowledge Check Answers
Answer 1
Order history is ordered by createdAt DESC, OrderId DESC. A timestamp regenerated on every load would change the history's order and destroy the fact "when the Order was placed".
Answer 2
The application does not own the customer account lifecycle; the external identity system does. A local table would invent ownership of registration and profiles, so we store only the stable identifier.
Answer 3
The constraint is the last line of defence against any writer, but it fails late and as a database error. The domain rejects invalid state for every Java caller with a clear rule, before anything reaches PostgreSQL.
Answer 4
Products are deactivated, never deleted, because historical Orders reference them. A generic contract offers an operation the business does not support, so repository contracts come from real workflows.
Answer 5
Our design depends on PostgreSQL behaviour: UUID, TIMESTAMPTZ, the conditional Inventory update, row locks and transactions. A mock proves only what we told it to return, and H2 behaves differently in exactly those areas.
Engineering Principle
Persistence exists to make business state durable; it should support the domain, not become the domain.
We persist only state the application genuinely owns and needs to survive, not every class, DTO, operation or noun in the codebase.
Summary
- Java memory is temporary; PostgreSQL is our authoritative durable store for application-owned business state.
- Product, Inventory, Order and OrderItem are persisted with the identities the domain already chose: UUID
ProductIdandOrderId, an externalStringCustomerId, Inventory keyed byProductId, OrderItem byOrderId + ProductId. - OrderItem keeps the historical
unitPriceCents; the Order total stays derived; missing Inventory differs from zero Inventory; Products are deactivated, not deleted. - Create Order and Cancel Order commit their changes atomically, and Order-time Inventory uses the conditional
consumeIfAvailable(...)(7.08); external payment cannot join our local transaction. - Use cases own workflows, constraints reinforce domain invariants, and repository contracts reflect real application needs with SQL and JPA behind them.
- No tables for DTOs, use cases, customers or (yet) payments.
- Flyway owns schema evolution; PostgreSQL runs in Docker Compose locally and in Testcontainers for integration tests, while domain tests stay database-free.
In the next lesson, we stop thinking in Java object graphs for a moment and learn how PostgreSQL represents our domain through relations, rows, columns, primary keys, foreign keys, constraints and joins, before designing the actual Product, Inventory, Order and OrderItem schema.
Think first
Coming soonCheckpoint questions that ask you to commit to an answer before the lesson shows it.