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.
LoadArticleloads an existing row by id.BuildArticleconstructs a new entity from the request body. - Actions operate only on entities. These are
ApplyArticle,SaveArticleandDeleteArticle. - Responders turn an entity back into a DTO and send it with a status. These are
RespondWithArticle,RespondWithArticleCreatedandRespondWithNoContent.
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.
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.

