REST CRUD Orchestration

The Function tutorial introduced composing an endpoint from functions. This tutorial applies that model to a full CRUD resource. It uses a small vocabulary of single-purpose functions. The vocabulary reads the same way for every endpoint.

  • Producers obtain an entity and publish it. LoadArticle loads an existing row by id. BuildArticle constructs a new entity from the request body.
  • Actions operate only on entities. These are ApplyArticle, SaveArticle and DeleteArticle.
  • Responders turn an entity back into a DTO and send it with a status. These are RespondWithArticle, RespondWithArticleCreated and RespondWithNoContent.

The guiding rule is DTOs only at the edges. A request DTO is validated and turned into an entity at the first function. Every function in the middle works only with the entity. The responder is the one place the entity becomes a response DTO. State flows between functions as variables. A function uses Out to publish a value. It uses @Val to consume one. So each function declares only the entities it needs.

Tutorial Source

The domain

A single JPA entity, with a request DTO for input and a response DTO for output. Mapping between DTO and entity is deliberately explicit. It is the boundary the pattern is built around.

@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;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ArticleRequest {

	@NotBlank(message = "title is required")
	private String title;

	private String content;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ArticleResponse {

	private Long id;
	private String title;
	private String content;
}

Maven dependencies

		<dependency>
			<groupId>net.officefloor.springboot</groupId>
			<artifactId>officefloor-rest-spring-boot-4-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>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-validation</artifactId>
		</dependency>

Read: GET /article/{id}

A read is a producer followed by a responder.

load:
  class: net.officefloor.tutorial.springrestcrud.LoadArticle
  govern: [ readonly-transaction ]
  next: respond

respond:
  class: net.officefloor.tutorial.springrestcrud.RespondWithArticle
  govern: [ readonly-transaction ]

LoadArticle fetches the row or throws NotFoundException. It publishes the entity as a variable.

public class LoadArticle {

	public void service(@PathVariable(name = "id") Long id,
			ArticleRepository repository,
			Out<Article> loaded) throws NotFoundException {
		Article article = repository.findById(id)
				.orElseThrow(() -> new NotFoundException(id));
		loaded.set(article);
	}
}

RespondWithArticle reads that entity with @Val and maps it to the response DTO.

public class RespondWithArticle {

	public void service(@Val Article article,
			ObjectResponse<ResponseEntity<ArticleResponse>> response) {
		ArticleResponse dto = new ArticleResponse(article.getId(), article.getTitle(), article.getContent());
		response.send(ResponseEntity.ok(dto));
	}
}

The two functions never reference each other. LoadArticle publishes an Article. RespondWithArticle consumes an Article. The variable type is the contract between them.

Not found: office-level escalation

LoadArticle simply throws. A handler registered under officefloor/escalation/ maps the exception to a 404. This applies to every endpoint that loads. There is no per-endpoint error wiring.

handle:
  class: net.officefloor.tutorial.springrestcrud.NotFoundExceptionHandler
public class NotFoundExceptionHandler {

	public void handle(@Parameter NotFoundException ex,
			ObjectResponse<ResponseEntity<Void>> response) {
		response.send(ResponseEntity.notFound().build());
	}
}

List: GET /article

Listing has no id and no 404. It stays a single function that queries and responds.

service:
  class: net.officefloor.tutorial.springrestcrud.ListArticles
  govern: [ readonly-transaction ]
public class ListArticles {

	public void service(ArticleRepository repository,
			ObjectResponse<ResponseEntity<List<ArticleResponse>>> response) {
		List<ArticleResponse> articles = repository.findAll().stream()
				.map(article -> new ArticleResponse(article.getId(), article.getTitle(), article.getContent()))
				.toList();
		response.send(ResponseEntity.ok(articles));
	}
}

Create: POST /article

Create builds a new entity from the validated body. It then saves the entity and responds 201.

build:
  class: net.officefloor.tutorial.springrestcrud.BuildArticle
  govern: [ transaction ]
  next: save

save:
  class: net.officefloor.tutorial.springrestcrud.SaveArticle
  govern: [ transaction ]
  next: respond

respond:
  class: net.officefloor.tutorial.springrestcrud.RespondWithArticleCreated
  govern: [ transaction ]

BuildArticle is the producer for a create. As the first function it carries the @Valid @RequestBody. Validation runs before anything is persisted.

@Validated
public class BuildArticle {

	public void service(@Valid @RequestBody ArticleRequest request,
			Out<Article> built) {
		built.set(new Article(null, request.getTitle(), request.getContent()));
	}
}

SaveArticle is a pure persistence function. It works only on the entity. RespondWithArticleCreated maps the saved entity to a DTO. It adds the 201 status and the Location header.

public class SaveArticle {

	public void service(@Val Article article, ArticleRepository repository) {
		repository.save(article);
	}
}
public class RespondWithArticleCreated {

	public void service(@Val Article article,
			ObjectResponse<ResponseEntity<ArticleResponse>> response) {
		ArticleResponse dto = new ArticleResponse(article.getId(), article.getTitle(), article.getContent());
		response.send(ResponseEntity
				.created(URI.create("/article/" + article.getId()))
				.body(dto));
	}
}

Update: PUT /article/{id}

Update is the fullest pipeline. It shows why validation is its own function.

validate:
  class: net.officefloor.tutorial.springrestcrud.ValidateArticle
  govern: [ transaction ]
  next: load

load:
  class: net.officefloor.tutorial.springrestcrud.LoadArticle
  govern: [ transaction ]
  next: apply

apply:
  class: net.officefloor.tutorial.springrestcrud.ApplyArticle
  govern: [ transaction ]
  next: save

save:
  class: net.officefloor.tutorial.springrestcrud.SaveArticle
  govern: [ transaction ]
  next: respond

respond:
  class: net.officefloor.tutorial.springrestcrud.RespondWithArticle
  govern: [ transaction ]

The order matters. The request body can be read only once. @Valid runs before its function's body. If Load ran first, an invalid body against a missing id would return 404 before the body was checked. So ValidateArticle runs first. It validates the body and publishes it as a variable for a later function. The body is consumed exactly once.

@Validated
public class ValidateArticle {

	public void service(@Valid @RequestBody ArticleRequest request,
			Out<ArticleRequest> validated) {
		validated.set(request);
	}
}

LoadArticle is reused unchanged from the read. ApplyArticle copies the validated request onto the loaded entity. It reads two variables and mutates the entity in place.

public class ApplyArticle {

	public void service(@Val Article article, @Val ArticleRequest request) {
		article.setTitle(request.getTitle());
		article.setContent(request.getContent());
	}
}

SaveArticle and RespondWithArticle are reused from create and read. The same 200 responder serves both reading and updating an article.

Invalid input: escalation to 400

Validation failures escalate the same way not-found does. A handler for Spring's MethodArgumentNotValidException maps it to a 400. The Validate and Build functions stay free of error handling.

handle:
  class: net.officefloor.tutorial.springrestcrud.ValidationExceptionHandler
public class ValidationExceptionHandler {

	public void handle(@Parameter MethodArgumentNotValidException ex,
			ObjectResponse<ResponseEntity<String>> response) {
		String message = ex.getBindingResult().getFieldErrors().stream()
				.map(error -> error.getField() + " " + error.getDefaultMessage())
				.findFirst().orElse("Invalid request");
		response.send(ResponseEntity.badRequest().body(message));
	}
}

Delete: DELETE /article/{id}

Delete loads the row, deletes it, then responds 204. It reuses LoadArticle and its 404. It has no body. The responder is the shared RespondWithNoContent. Every no-content endpoint uses it.

load:
  class: net.officefloor.tutorial.springrestcrud.LoadArticle
  govern: [ transaction ]
  next: delete

delete:
  class: net.officefloor.tutorial.springrestcrud.DeleteArticle
  govern: [ transaction ]
  next: respond

respond:
  class: net.officefloor.tutorial.springrestcrud.RespondWithNoContent
  govern: [ transaction ]
public class DeleteArticle {

	public void service(@Val Article article, ArticleRepository repository) {
		repository.delete(article);
	}
}
public class RespondWithNoContent {

	public void service(ObjectResponse<ResponseEntity<Void>> response) {
		response.send(ResponseEntity.noContent().build());
	}
}

Testing

Every endpoint is exercised through the running application. This includes the validate-before-load (400) and not-found (404) escalation paths.

@SpringBootTest
@AutoConfigureMockMvc
public class SpringRestCrudTest {

	@Autowired
	private MockMvc mvc;

	@Autowired
	private ObjectMapper mapper;

	@Autowired
	private ArticleRepository repository;

	@AfterEach
	public void clearData() {
		repository.deleteAll();
	}

	// POST: Build -> Save -> RespondWithArticleCreated
	@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("$.id").isNumber());
	}

	// Validation runs in Build before persistence: a blank title is a 400, not a saved row
	@Test
	public void createArticleInvalid() throws Exception {
		mvc.perform(post("/article")
				.accept(MediaType.APPLICATION_JSON)
				.contentType(MediaType.APPLICATION_JSON)
				.content(mapper.writeValueAsString(new ArticleRequest("", "content"))))
				.andExpect(status().isBadRequest())
				.andExpect(content().string(containsString("title")));
	}

	// GET /article/{id}: Load -> RespondWithArticle
	@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"));
	}

	// Load throws NotFoundException; the office-level escalation handler maps it to 404
	@Test
	public void getArticleNotFound() throws Exception {
		mvc.perform(get("/article/99999").accept(MediaType.APPLICATION_JSON))
				.andExpect(status().isNotFound());
	}

	// GET /article: single ListArticles step
	@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)));
	}

	// PUT: Validate -> Load -> Apply -> Save -> RespondWithArticle
	@Test
	public void updateArticle() throws Exception {
		Article saved = repository.save(new Article(null, "Before", "old"));

		mvc.perform(put("/article/" + saved.getId())
				.accept(MediaType.APPLICATION_JSON)
				.contentType(MediaType.APPLICATION_JSON)
				.content(mapper.writeValueAsString(new ArticleRequest("After", "new"))))
				.andExpect(status().isOk())
				.andExpect(jsonPath("$.title").value("After"))
				.andExpect(jsonPath("$.content").value("new"));
	}

	// Validate runs BEFORE Load: an invalid body is a 400 even when the id does not exist
	@Test
	public void updateArticleInvalidBodyBeforeLoad() throws Exception {
		mvc.perform(put("/article/99999")
				.accept(MediaType.APPLICATION_JSON)
				.contentType(MediaType.APPLICATION_JSON)
				.content(mapper.writeValueAsString(new ArticleRequest("", "new"))))
				.andExpect(status().isBadRequest());
	}

	// DELETE: Load -> Delete -> RespondWithNoContent
	@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)));
	}

	@Test
	public void deleteArticleNotFound() throws Exception {
		mvc.perform(delete("/article/99999"))
				.andExpect(status().isNotFound());
	}
}

Next

The Orchestration Patterns and Naming reference collects the function naming conventions and the data-flow rules used in this tutorial. It also covers the one-shot request body and the @Val reference semantics.