Spring REST Data JPA Tutorial
This tutorial demonstrates Spring Data JPA integration with OfficeFloor REST YAML composition. Spring Data repositories are injected directly into service methods as plain parameters — the same way any Spring bean is injected.
The unique OfficeFloor feature shown here is transaction governance: rather than annotating service classes with @Transactional, the YAML file declares which governance wraps each step. The starter provides two built-in governance names:
transaction— begins a read/write transaction before the step runs and commits on success, rolls back on any exceptionreadonly-transaction— same lifecycle but marks the transaction read-only, allowing the database driver to apply optimisations
When multiple steps in a YAML composition all carry govern: [ transaction ], every step enrols in the same transaction. That means writes made in an earlier step are visible to later steps, and if any step fails the whole transaction rolls back. Exception-handler steps that also carry govern: [ transaction ] participate in the same transaction too — they can still read or write within the transaction, and the final commit-or-rollback decision is made once all steps complete.
The tutorial exposes a simple article CRUD API backed by an H2 in-memory database.
Maven dependency
<dependency>
<groupId>net.officefloor.springboot</groupId>
<artifactId>officefloor-rest-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
Application class
@SpringBootApplication
public class SpringRestDataJpaApplication {
public static void main(String[] args) {
SpringApplication.run(SpringRestDataJpaApplication.class, args);
}
}
Entity and repository
@Entity
@Table(name = "articles")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String content;
}
@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {
}
Request and response DTOs
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ArticleRequest {
private String title;
private String content;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ArticleResponse {
private Long id;
private String title;
private String content;
}
Write operations — govern: [ transaction ]
The CreateArticleService declares ArticleRepository as a plain parameter. The YAML file wraps the step in a read/write transaction via govern: [ transaction ]. If the service method throws an exception the transaction is automatically rolled back:
public class CreateArticleService {
public void service(@RequestBody ArticleRequest request,
ArticleRepository repository,
ObjectResponse<ResponseEntity<ArticleResponse>> response) {
Article saved = repository.save(new Article(null, request.getTitle(), request.getContent()));
ArticleResponse dto = new ArticleResponse(saved.getId(), saved.getTitle(), saved.getContent());
response.send(ResponseEntity
.created(URI.create("/article/" + saved.getId()))
.body(dto));
}
}
service:
class: net.officefloor.tutorial.springrestdatajpa.CreateArticleService
govern: [ transaction ]
ResponseEntity.created(uri) sets both the 201 Created status and the Location header in one call. Accepting ObjectResponse<ResponseEntity<T>> rather than ObjectResponse<T> is the way to pass a full ResponseEntity — including custom status codes and headers — through to the HTTP layer while keeping the response body serialisation unchanged.
This is also the easiest incremental migration path from a Spring MVC controller: the ResponseEntity building code moves into the service method with minimal change and the return is replaced by response.send(...).
Exception handler participating in the same transaction
The delete endpoint shows how an exception-handler step also carries govern: [ transaction ] so that it participates in the same transaction as the service step. The service checks whether the article exists and throws a checked ArticleNotFoundException if not. Because the exception is checked it does not automatically mark the transaction for rollback — the handler step decides the outcome.
The exception type used in escalations: must be the fully-qualified class name. OfficeFloor routes the thrown exception to the named handler step, which also runs inside the open transaction:
public class ArticleNotFoundException extends Exception {
private final long id;
public ArticleNotFoundException(long id) {
this.id = id;
}
public long getId() {
return id;
}
}
public class DeleteArticleService {
public void service(@PathVariable(name = "id") Long id,
ArticleRepository repository) throws ArticleNotFoundException {
if (!repository.existsById(id)) {
throw new ArticleNotFoundException(id);
}
repository.deleteById(id);
}
public void notFound(@Parameter ArticleNotFoundException ex,
@HttpResponse(status = 404) ObjectResponse<String> response) {
response.send("Article " + ex.getId() + " not found");
}
}
service:
class: net.officefloor.tutorial.springrestdatajpa.DeleteArticleService
method: service
govern: [ transaction ]
escalations:
net.officefloor.tutorial.springrestdatajpa.ArticleNotFoundException: notFound
notFound:
class: net.officefloor.tutorial.springrestdatajpa.DeleteArticleService
method: notFound
govern: [ transaction ]
DeleteArticleService has two public methods (service and notFound), so every YAML entry that references it must include method:. See the Spring REST HTTP Server tutorial for a full explanation of this rule.
Transaction spanning multiple service methods
When multiple YAML steps all declare govern: [ transaction ], OfficeFloor enrols them in one transaction from the first step through to the last. The replace-all endpoint illustrates this: deleteAll and create are separate methods on the same service class but they run in a single atomic transaction — if create fails for any reason, deleteAll's work is also rolled back.
The next: key names the step to invoke after the current step completes successfully:
service:
class: net.officefloor.tutorial.springrestdatajpa.ReplaceAllArticlesService
method: deleteAll
govern: [ transaction ]
next: create
create:
class: net.officefloor.tutorial.springrestdatajpa.ReplaceAllArticlesService
method: create
govern: [ transaction ]
public class ReplaceAllArticlesService {
public void deleteAll(ArticleRepository repository) {
repository.deleteAll();
}
public void create(@RequestBody ArticleRequest request,
ArticleRepository repository,
@HttpResponse(status = 201) ObjectResponse<ArticleResponse> response) {
Article saved = repository.save(new Article(null, request.getTitle(), request.getContent()));
response.send(new ArticleResponse(saved.getId(), saved.getTitle(), saved.getContent()));
}
}
Read operations — govern: [ readonly-transaction ]
Read-only operations use readonly-transaction governance. The transaction is still started (required for lazy-loaded associations) but the database driver may skip undo logging and other write-path overhead:
public class GetArticleService {
public void service(@PathVariable(name = "id") Long id,
ArticleRepository repository,
ObjectResponse<ArticleResponse> response) {
Article article = repository.findById(id).orElseThrow();
response.send(new ArticleResponse(article.getId(), article.getTitle(), article.getContent()));
}
}
service:
class: net.officefloor.tutorial.springrestdatajpa.GetArticleService
govern: [ readonly-transaction ]
public class ListArticlesService {
public void service(ArticleRepository repository, ObjectResponse<List<ArticleResponse>> response) {
List<ArticleResponse> articles = repository.findAll().stream()
.map(a -> new ArticleResponse(a.getId(), a.getTitle(), a.getContent()))
.collect(Collectors.toList());
response.send(articles);
}
}
service:
class: net.officefloor.tutorial.springrestdatajpa.ListArticlesService
govern: [ readonly-transaction ]
Pagination — @RequestParam page and size
Spring Data's Page<T> and PageRequest work without modification in OfficeFloor service methods. Declare page and size as @RequestParam parameters with a defaultValue; Spring Data handles the query and metadata:
public class ListArticlesPageService {
public void service(
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "10") int size,
ArticleRepository repository,
ObjectResponse<ArticlePage> response) {
Page<Article> result = repository.findAll(
PageRequest.of(page, size, Sort.by("id")));
response.send(new ArticlePage(
result.getNumber(),
result.getSize(),
result.getTotalElements(),
result.getTotalPages(),
result.getContent().stream()
.map(a -> new ArticleResponse(a.getId(), a.getTitle(), a.getContent()))
.collect(Collectors.toList())));
}
}
service:
class: net.officefloor.tutorial.springrestdatajpa.ListArticlesPageService
govern: [ readonly-transaction ]
The ArticlePage response DTO wraps the page metadata alongside the content list so the caller receives everything needed to render a paginated view or navigate to the next page:
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ArticlePage {
private int page;
private int size;
private long totalElements;
private int totalPages;
private List<ArticleResponse> content;
}
Pagination sits naturally under readonly-transaction governance because it is a pure read and it inherits the lazy-loading protection of a started-but-not-written transaction.
Database configuration
Add H2 (or any JDBC driver) as a runtime dependency. The application.properties file configures the in-memory datasource:
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=false
Testing
Tests use standard Spring Boot MockMvc. The ArticleRepository is injected for setup and teardown — OfficeFloor tests do not require any special utilities.
createArticle asserts the 201 Created status and that the Location header points to the new resource. deleteArticleNotFound verifies that the notFound exception-handler step (which also carries govern: [ transaction ]) returns a proper 404 within the same transaction. replaceAllArticles verifies that the two-step replace-all endpoint atomically replaces all existing articles with a single new one. listArticlesPage asserts that the page and size query parameters correctly slice the result set and that the response metadata reflects the total count:
@SpringBootTest
@AutoConfigureMockMvc
public class SpringRestDataJpaTest {
@Autowired
private MockMvc mvc;
@Autowired
private ObjectMapper mapper;
@Autowired
private ArticleRepository repository;
@AfterEach
public void clearData() {
repository.deleteAll();
}
@Test
public void createArticle() throws Exception {
mvc.perform(post("/article")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new ArticleRequest("My Title", "My content"))))
.andExpect(status().isCreated())
.andExpect(header().string("Location", containsString("/article/")))
.andExpect(jsonPath("$.title").value("My Title"))
.andExpect(jsonPath("$.content").value("My content"))
.andExpect(jsonPath("$.id").isNumber());
}
@Test
public void getArticleById() throws Exception {
Article saved = repository.save(new Article(null, "Find Me", "content"));
mvc.perform(get("/article/" + saved.getId()).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Find Me"));
}
@Test
public void listArticles() throws Exception {
repository.save(new Article(null, "Article One", "content one"));
repository.save(new Article(null, "Article Two", "content two"));
mvc.perform(get("/article").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].title").value("Article One"));
}
@Test
public void deleteArticle() throws Exception {
Article saved = repository.save(new Article(null, "To Delete", "content"));
mvc.perform(delete("/article/" + saved.getId()))
.andExpect(status().isNoContent());
mvc.perform(get("/article").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(0)));
}
// Exception handler under govern=[transaction] — participates in the same transaction
@Test
public void deleteArticleNotFound() throws Exception {
mvc.perform(delete("/article/99999"))
.andExpect(status().isNotFound())
.andExpect(content().string(containsString("not found")));
}
// Multi-step transaction: deleteAll and create share the same transaction
@Test
public void replaceAllArticles() throws Exception {
repository.save(new Article(null, "Old One", "old content"));
repository.save(new Article(null, "Old Two", "old content"));
mvc.perform(post("/article/replace-all")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new ArticleRequest("New Article", "new content"))))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value("New Article"));
mvc.perform(get("/article").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1)))
.andExpect(jsonPath("$[0].title").value("New Article"));
}
// Pagination: page and size query params drive Spring Data's PageRequest
@Test
public void listArticlesPage() throws Exception {
for (int i = 1; i <= 5; i++) {
repository.save(new Article(null, "Article " + i, "content " + i));
}
mvc.perform(get("/article/page?page=0&size=3").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(3)))
.andExpect(jsonPath("$.totalElements").value(5))
.andExpect(jsonPath("$.totalPages").value(2))
.andExpect(jsonPath("$.page").value(0))
.andExpect(jsonPath("$.size").value(3));
mvc.perform(get("/article/page?page=1&size=3").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(2)))
.andExpect(jsonPath("$.totalElements").value(5))
.andExpect(jsonPath("$.page").value(1));
}
}
Next
The Spring Actuator tutorial demonstrates that Spring Boot Actuator health, info, and metrics endpoints continue to work unchanged alongside OfficeFloor REST YAML endpoints with no additional configuration.

