Fork me on GitHub

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 exception
  • readonly-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.

Tutorial Source

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,
			@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.

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 ]

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 ]

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.

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"));
	}
}

Next

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.