Jakarta Persistence
So far the blog posts live in memory and disappear on restart. Jakarta Persistence (JPA) stores Java objects in a relational database: an entity class maps to a table, and an object maps to a row. An application on a server differs from a plain Java program in one important way: the server configures the database connection, and the application only refers to it by name.
The database driver
For the learning blog we take H2 — a database that runs right in the server's memory. The driver is needed by the server, not by the WAR file, so the Liberty plugin copies it. Add plugin configuration to pom.xml:
<!-- pom.xml (fragment) -->
<plugin>
<groupId>io.openliberty.tools</groupId>
<artifactId>liberty-maven-plugin</artifactId>
<version>3.12.3</version>
<configuration>
<copyDependencies>
<location>jdbc</location>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.5.252</version>
</dependency>
</copyDependencies>
</configuration>
</plugin>On start the file h2-2.5.252.jar lands in the server's jdbc folder.
The data source
In src/main/liberty/config/server.xml, before webApplication, describe a library and a data source — a named connection pool:
<!-- src/main/liberty/config/server.xml (fragment) -->
<library id="h2Library">
<fileset dir="${server.config.dir}/jdbc" includes="h2-*.jar" />
</library>
<dataSource id="blogDataSource" jndiName="jdbc/blog">
<jdbcDriver libraryRef="h2Library" />
<properties URL="jdbc:h2:mem:blog;DB_CLOSE_DELAY=-1;MODE=LEGACY" user="sa" password="changeme" />
</dataSource>jndiName="jdbc/blog" is the name the application uses to find the database. DB_CLOSE_DELAY=-1 keeps the in-memory database from vanishing between requests. MODE=LEGACY is explained at the end of the lesson.
persistence.xml
The file src/main/resources/META-INF/persistence.xml describes a persistence unit:
<!-- src/main/resources/META-INF/persistence.xml -->
<persistence xmlns="https://jakarta.ee/xml/ns/persistence" version="3.2">
<persistence-unit name="blog" transaction-type="JTA">
<jta-data-source>jdbc/blog</jta-data-source>
<properties>
<property name="jakarta.persistence.schema-generation.database.action"
value="drop-and-create"/>
<property name="jakarta.persistence.sql-load-script-source"
value="META-INF/data.sql"/>
</properties>
</persistence-unit>
</persistence>On start the tables are recreated, then src/main/resources/META-INF/data.sql runs — one statement per line:
INSERT INTO posts (title, body, created_at) VALUES ('Hello, Jakarta EE', 'The first post of the blog.', '2026-10-01');
INSERT INTO posts (title, body, created_at) VALUES ('Servlets and filters', 'How a request reaches our code.', '2026-10-02');The entity
JPA does not work with records: an entity needs a no-argument constructor and mutable fields. Replace Post.java with a class:
// src/main/java/com/example/blog/Post.java
package com.example.blog;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDate;
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String title;
@Column(length = 5000)
private String body;
@Column(name = "created_at")
private LocalDate createdAt;
protected Post() {
// required by JPA
}
public Post(String title, String body, LocalDate createdAt) {
this.title = title;
this.body = body;
this.createdAt = createdAt;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public String getBody() { return body; }
public LocalDate getCreatedAt() { return createdAt; }
public void setTitle(String title) { this.title = title; }
public void setBody(String body) { this.body = body; }
}JSON-B reads public getters, so the JSON stays the same.
The repository
Instead of PostStore, create src/main/java/com/example/blog/PostRepository.java:
// src/main/java/com/example/blog/PostRepository.java
package com.example.blog;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.transaction.Transactional;
import java.util.List;
import java.util.Optional;
@ApplicationScoped
public class PostRepository {
@PersistenceContext(unitName = "blog")
EntityManager em;
public List<Post> findAll() {
return em.createQuery("SELECT p FROM Post p ORDER BY p.id", Post.class)
.getResultList();
}
public Optional<Post> findById(long id) {
return Optional.ofNullable(em.find(Post.class, id));
}
public Post get(long id) {
return findById(id).orElseThrow(() -> new PostNotFoundException(id));
}
public List<Post> search(String text) {
return em.createQuery(
"SELECT p FROM Post p WHERE LOWER(p.title) LIKE :text ORDER BY p.id",
Post.class)
.setParameter("text", "%" + text.toLowerCase() + "%")
.getResultList();
}
public long count() {
return em.createQuery("SELECT COUNT(p) FROM Post p", Long.class)
.getSingleResult();
}
@Transactional
public Post save(Post post) {
em.persist(post);
return post;
}
@Transactional
public void delete(long id) {
em.remove(get(id));
}
}@PersistenceContext injects the EntityManager, the main JPA object. Queries are written in JPQL: it uses classes and fields (Post p, p.title), not tables. The @Transactional annotation is required for writing to the database — the next lesson is about it.
The rest of the code
Delete PostStore.java and replace its uses:
| Where | Before | After |
|---|---|---|
PostResource, StatsServlet | PostStore store | PostRepository repository |
PostService.publish | store.add(data) | repository.save(new Post(data.title(), data.body(), LocalDate.now(clock))) |
PostService | — | A field @Inject Clock clock; |
| Everywhere | post.id(), post.title() | post.getId(), post.getTitle() |
StatsServlet | store.findAll().size() | repository.count() |
Let's teach the list method to search:
// src/main/java/com/example/blog/PostResource.java (fragment)
@GET
public List<Post> list(@QueryParam("q") @DefaultValue("") String q) {
return q.isBlank() ? repository.findAll() : repository.search(q);
}curl "http://localhost:8080/blog/api/posts?q=serv"[{"body":"How a request reaches our code.","createdAt":"2026-10-02","id":2,"title":"Servlets and filters"}]
Common mistake
Connecting H2 2.x without MODE=LEGACY. EclipseLink, the JPA implementation in Open Liberty, creates the table with old H2 syntax, and the new database version rejects it:
Syntax error in SQL statement "CREATE TABLE posts (ID BIGINT [*]IDENTITY NOT NULL, ...
The table is not created, and the very first query fails with Table "POSTS" not found. MODE=LEGACY in the database URL turns on compatibility. DROP TABLE posts warnings on the first start are normal: with drop-and-create JPA tries to drop tables that do not exist yet.