Resolving References
A request often names a related entity rather than sending it whole. An article carries tag names. The article needs the managed Tag entities, not new ones built from the names. Turning those names into managed entities is a Resolve.
A Resolve is an action, like Apply, but it does a repository lookup rather than copying a field. It runs after Build or Apply and before Save. It enriches the entity with managed references. The same Resolve serves both the create and the update pipeline.
This tutorial builds on the CRUD Orchestration pattern.
The domain
An article has many tags. Tags are a reference entity, shared across articles and pre-seeded. A request names its tags by name.
/**
* Reference entity. Tags are shared across articles and are looked up by name.
* They are pre-seeded (see data.sql), not created per request.
*/
@Entity
@Table(name = "tags")
public class Tag {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
public Tag() {
}
public Tag(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() {
return this.id;
}
public String getName() {
return this.name;
}
}
@Entity
@Table(name = "articles")
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@ManyToMany
@JoinTable(name = "article_tags",
joinColumns = @JoinColumn(name = "article_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id"))
private Set<Tag> tags = new LinkedHashSet<>();
public Article() {
}
public Article(Long id, String title) {
this.id = id;
this.title = title;
}
public Long getId() {
return this.id;
}
public String getTitle() {
return this.title;
}
public void setTitle(String title) {
this.title = title;
}
public Set<Tag> getTags() {
return this.tags;
}
public void setTags(Set<Tag> tags) {
this.tags = tags;
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ArticleRequest {
@NotBlank(message = "title is required")
private String title;
/** Tags are given by name. The managed Tag entities are resolved from these. */
private List<String> tags = new ArrayList<>();
}
The tags are pre-seeded, so a request references existing rows:
-- Pre-seeded reference tags. Requests reference these by name; ResolveTags
-- looks them up so articles share the managed rows rather than creating new ones.
INSERT INTO tags (name) VALUES ('java');
INSERT INTO tags (name) VALUES ('spring');
INSERT INTO tags (name) VALUES ('officefloor');
INSERT INTO tags (name) VALUES ('testing');
Why a Resolve is needed
Without it, saving an article whose tags were built from the request names would try to persist new Tag rows. That duplicates tags or violates the unique-name constraint. The managed tags must be looked up first. That lookup is the Resolve.
So the mutating functions divide the work. Build and Apply set the scalar fields only. ResolveTags handles the references.
/**
* Builds a new article from the request. It sets the scalar fields only. The
* tags are references to other entities, so they are handled by ResolveTags.
*/
public class BuildArticle {
public void service(@Val ArticleRequest request, Out<Article> built) {
built.set(new Article(null, request.getTitle()));
}
}
/**
* Applies the request to a loaded article. Like BuildArticle, it sets the
* scalar fields only. ResolveTags handles the tag references.
*/
public class ApplyArticle {
public void service(@Val Article article, @Val ArticleRequest request) {
article.setTitle(request.getTitle());
}
}
The Resolve function
ResolveTags reads the tag names from the request, looks up the managed tags, and sets them on the article. It mutates the article's collection in place, so the following Save persists managed references and nothing transient is ever saved.
/**
* Resolve function. It enriches the article by looking up the managed Tag
* entities named in the request and setting them on the article.
*
* <p>It is an action, like Apply, but it does a repository lookup rather than
* copying a field. It runs after Build or Apply and before Save. It mutates the
* article's tag collection in place, so the following Save persists the managed
* references. The same function serves both the create and the update pipeline.
*
* <p>Mutating the collection in place (clear then add) rather than replacing it
* keeps the managed entity's collection tracked by the persistence context.
* The looked-up tags are already managed, so nothing transient is ever saved.
*/
public class ResolveTags {
public void service(@Val Article article, @Val ArticleRequest request,
TagRepository tagRepository) {
List<Tag> managed = tagRepository.findByNameIn(request.getTags());
article.getTags().clear();
article.getTags().addAll(managed);
}
}
It mutates the @Val article, so the later Save sees the change. This is the same reference behaviour that variables give any function that takes an entity.
Create: POST /article
The Resolve sits between Build and Save.
validate:
class: net.officefloor.tutorial.springrestresolve.ValidateArticle
govern: [ transaction ]
next: build
build:
class: net.officefloor.tutorial.springrestresolve.BuildArticle
govern: [ transaction ]
next: resolveTags
resolveTags:
class: net.officefloor.tutorial.springrestresolve.ResolveTags
govern: [ transaction ]
next: save
save:
class: net.officefloor.tutorial.springrestresolve.SaveArticle
govern: [ transaction ]
next: respond
respond:
class: net.officefloor.tutorial.springrestresolve.RespondWithArticleCreated
govern: [ transaction ]
Update: PUT /article/{id}
The same ResolveTags sits between Apply and Save. One function, two pipelines.
validate:
class: net.officefloor.tutorial.springrestresolve.ValidateArticle
govern: [ transaction ]
next: load
load:
class: net.officefloor.tutorial.springrestresolve.LoadArticle
govern: [ transaction ]
next: apply
apply:
class: net.officefloor.tutorial.springrestresolve.ApplyArticle
govern: [ transaction ]
next: resolveTags
resolveTags:
class: net.officefloor.tutorial.springrestresolve.ResolveTags
govern: [ transaction ]
next: save
save:
class: net.officefloor.tutorial.springrestresolve.SaveArticle
govern: [ transaction ]
next: respond
respond:
class: net.officefloor.tutorial.springrestresolve.RespondWithArticle
govern: [ transaction ]
validate runs first so an invalid body is rejected before the load, exactly as in the CRUD tutorial. load then apply set the title. resolveTags replaces the tags with managed ones. save persists. The read endpoint GET /article/{id} is the usual load then respond.
Testing
ResolveTags takes an entity, the request, and a repository, no Out. So it unit tests as a plain object with a stubbed repository, following the Testing Functions tutorial.
/**
* ResolveTags takes the entity, the request and a repository, no Out. So it
* unit tests with plain objects and a stubbed repository. No Spring, no server.
*/
public class ResolveTagsUnitTest {
@Test
public void setsTheManagedTagsNamedInTheRequest() {
Article article = new Article(1L, "Title");
ArticleRequest request = new ArticleRequest("Title", List.of("java", "spring"));
// Repository returns the managed tags (with ids) for those names
TagRepository repository = mock(TagRepository.class);
when(repository.findByNameIn(anyCollection()))
.thenReturn(List.of(new Tag(10L, "java"), new Tag(20L, "spring")));
new ResolveTags().service(article, request, repository);
// The article now holds the managed tags, with ids
Set<Long> ids = article.getTags().stream().map(Tag::getId).collect(Collectors.toSet());
assertEquals(Set.of(10L, 20L), ids);
}
}
The integration test drives the running application. It proves the payoff directly: creating two articles that share tags does not create new tag rows, and updating re-resolves against the seeded tags.
@SpringBootTest
@AutoConfigureMockMvc
public class ArticleResolveIntegrationTest {
@Autowired
private MockMvc mvc;
@Autowired
private ObjectMapper mapper;
@Autowired
private ArticleRepository articleRepository;
@Autowired
private TagRepository tagRepository;
@AfterEach
public void clearArticles() {
articleRepository.deleteAll();
}
// Create resolves the tag names to the pre-seeded managed tags
@Test
public void createResolvesTags() throws Exception {
mvc.perform(post("/article")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new ArticleRequest("Guide", List.of("java", "spring")))))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.tags[0]").value("java"))
.andExpect(jsonPath("$.tags[1]").value("spring"));
}
// The payoff: resolving reuses the four seeded tags. No new tag rows are created.
@Test
public void resolveReusesTagsAndDoesNotDuplicate() throws Exception {
long tagsBefore = tagRepository.count();
mvc.perform(post("/article")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new ArticleRequest("One", List.of("java", "spring")))))
.andExpect(status().isCreated());
mvc.perform(post("/article")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new ArticleRequest("Two", List.of("java", "testing")))))
.andExpect(status().isCreated());
assertEquals(tagsBefore, tagRepository.count(), "resolving must not create new tag rows");
}
// The same ResolveTags runs on update. Verify through the repository that the
// update ran and reused the managed tags rather than creating new ones.
@Test
public void updateReResolvesTags() throws Exception {
long tagsBefore = tagRepository.count();
Article saved = articleRepository.save(new Article(null, "Before"));
mvc.perform(put("/article/" + saved.getId())
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new ArticleRequest("After", List.of("officefloor")))))
.andExpect(status().isOk());
assertEquals("After", articleRepository.findById(saved.getId()).orElseThrow().getTitle());
assertEquals(tagsBefore, tagRepository.count(), "update must reuse managed tags, not create new");
}
@Test
public void updateMissingArticleIsNotFound() throws Exception {
mvc.perform(put("/article/99999")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new ArticleRequest("X", List.of("java")))))
.andExpect(status().isNotFound());
}
@Test
public void createInvalidIsBadRequest() throws Exception {
mvc.perform(post("/article")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new ArticleRequest("", List.of("java")))))
.andExpect(status().isBadRequest());
}
}
Next
The Problem Detail tutorial gives errors a structured RFC 7807 response shape via escalation handlers.

