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 optimisationsWhen 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.
<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>
@SpringBootApplication
public class SpringRestDataJpaApplication {
public static void main(String[] args) {
SpringApplication.run(SpringRestDataJpaApplication.class, args);
}
}
@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> {
}
@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;
}
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,
@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()));
}
}
service: class: net.officefloor.tutorial.springrestdatajpa.CreateArticleService govern: [ transaction ]
The @HttpResponse(status = 201) annotation on ObjectResponse changes the default HTTP status from 200 to 201 Created.
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 ]
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()));
}
}
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 ]
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
Tests use standard Spring Boot MockMvc. The ArticleRepository is injected for setup and teardown — OfficeFloor tests do not require any special utilities.
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:
@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(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"));
}
}
The Spring REST Team tutorial demonstrates Thread Injection: assigning specific thread pools to steps based on their dependencies so that socket threads are never blocked by database or other slow I/O operations.