Compare commits

...

7 Commits

Author SHA1 Message Date
Marcel
7906373053 docs(adr): ADR-006 synchronous domain events inside the publisher's transaction
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m16s
CI / OCR Service Tests (pull_request) Successful in 1m34s
CI / Backend Unit Tests (pull_request) Failing after 4m14s
CI / Unit & Component Tests (push) Failing after 3m29s
CI / OCR Service Tests (push) Successful in 50s
CI / Backend Unit Tests (push) Failing after 3m43s
Markus #4 (PR #366 review). PersonDisplayNameChangedEvent is the first
custom application event in this codebase — the prior @EventListener
(OcrTrainingService.recoverOrphanedRuns) consumed Spring's built-in
ApplicationReadyEvent. The pattern is load-bearing for future cross-domain
decoupling and warrants a documented decision rather than a comment buried
in the listener.

Captures: synchronous-by-default rationale, package layout (event in
publisher's model/, listener in consumer's service/), saveAllAndFlush vs
saveAll for exception surfacing, the migration path to @TransactionalEvent
Listener + @Async if archive growth forces it, and the rejected
alternatives (direct call, DB trigger, Hibernate entity listener).

Refs #362 #366

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:42:03 +02:00
Marcel
2d48821f95 refactor(test): TranscriptionServiceTest uses DTO @Builder instead of @AllArgsConstructor
Felix self-review / Sara (PR #366 review). The trailing-`List.of()` pattern
introduced when mentionedPersons was added to the DTOs is brittle: every
future field forces another grep-and-edit pass across this file. Switch
the 8 call sites (1 Create, 7 Update) to .builder() so the test only
specifies the fields it cares about — future DTO growth is invisible to
tests that don't touch the new field.

Refs #362 #366

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:40:29 +02:00
Marcel
0def9e9b9d test(transcription): mirror displayName length-cap regression on PUT endpoint
Sara #4 (PR #366 review). The 400-on-201-chars regression guard previously
only covered POST /api/documents/{id}/transcription-blocks. The same @Valid
cascade applies to PUT /api/documents/{id}/transcription-blocks/{blockId}
via UpdateTranscriptionBlockDTO, but no test asserted it — meaning a
silent removal of @Valid on the PUT @RequestBody parameter would slip past
CI. Mirror the test for symmetry.

Refs #362 #366

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:39:13 +02:00
Marcel
acffcc8516 refactor(transcription): listener @Component → @Service
Markus #6 (PR #366 review). The class lives in service/ and is service-tier
business logic — wire-by-stereotype consistency calls for @Service. Both
annotations participate in @ComponentScan equivalently, so the bean
registration is unchanged.

Refs #362 #366

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:38:06 +02:00
Marcel
48492330a7 test(person): optimistic-lock test exercises real listener saveAllAndFlush path
Sara #3 / Felix #5 (PR #366 review). The previous version stubbed
eventPublisher.publishEvent to throw, which proved the catch-and-translate
syntax but skipped the listener entirely. The test could not have detected
a regression where the listener swallowed the exception or re-wrapped it
with a non-OptimisticLocking type.

Replace with a real PersonMentionPropagationListener instance backed by a
mocked TranscriptionBlockRepository whose saveAllAndFlush throws
ObjectOptimisticLockingFailureException (the actual Spring exception
Hibernate raises). The publisher mock routes the event to the real
listener via doAnswer so the call chain is the production one:
PersonService.updatePerson → publishEvent → listener.onPersonDisplayNameChanged
→ blockRepository.saveAllAndFlush throws → exception bubbles through the
synchronous event dispatcher → PersonService catches → DomainException.

Refs #362 #366

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:36:54 +02:00
Marcel
d924d9059c refactor(transcription): drop dead existsById orphan guard from listener
Felix #2 / Markus #1 (PR #366 review). In the synchronous-transactional
path the existsById check could never return false — the rename and the
propagation share one transaction, so the renamed Person is guaranteed to
still exist when the listener runs. The check was forward-protection for
an eventual @Async refactor but its presence today is misleading: it
suggests a runtime branch that no test could reach against the real flow.

Delete the call, drop the PersonService dependency from the listener, drop
the now-unused PersonService.existsById, and remove the orphan-guard test
(it asserted a behaviour that the synchronous path cannot produce). When
async is added later the guard re-enters the codebase deliberately as part
of that refactor.

Refs #362 #366

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:35:15 +02:00
Marcel
99aee777de fix(transcription): word-boundary regex prevents single-word displayName corruption
Felix #1 / Markus #5 / Sara #1 (PR #366 review). The naive
text.replace("@" + old, "@" + new) silently corrupted any composite mention
that began with the renamed single-name person — e.g. renaming the
single-name "Hans" turned "@Hans Müller" into "@Henry Müller", obliterating
the historical reference to Hans Müller without warning.

Replace with a regex matching "@OldName" only at a token boundary: not
followed by a letter/digit/hyphen (catches @Hans-Peter) and not followed by
"<space><uppercase>" (catches @Hans Müller). False negatives — e.g.
sentence-initial "@Hans Bekam" — are accepted as the conservative
trade-off; corruption is irrecoverable, missed renames are not.

The new failing test reproduced the reviewer scenario exactly: two persons
("Hans Müller" + single-name "Hans"), one block referencing both, rename
Hans → Henry. Pre-fix output corrupted "@Hans Müller" to "@Henry Müller";
post-fix preserves the composite mention and only updates the standalone.

The existing partial-name guard test (Hans-Peter Müller / Hans Müller) and
multiple-occurrences test still pass — the regex is a strict superset of
the boundary constraints already covered.

Refs #362 #366

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:33:15 +02:00
7 changed files with 150 additions and 54 deletions

View File

@@ -7,10 +7,12 @@ import org.raddatz.familienarchiv.model.PersonMention;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Transcription-domain consumer of {@link PersonDisplayNameChangedEvent}. When
@@ -25,22 +27,16 @@ import java.util.List;
* {@code @TransactionalEventListener(AFTER_COMMIT) + @Async} — one annotation
* change.
*/
@Component
@Service
@RequiredArgsConstructor
@Slf4j
public class PersonMentionPropagationListener {
private final TranscriptionBlockRepository blockRepository;
private final PersonService personService;
@EventListener
@Transactional
public void onPersonDisplayNameChanged(PersonDisplayNameChangedEvent event) {
if (!personService.existsById(event.personId())) {
log.warn("Skipping mention propagation for non-existent personId {}", event.personId());
return;
}
List<TranscriptionBlock> blocks =
blockRepository.findByMentionedPersons_PersonId(event.personId());
if (blocks.isEmpty()) {
@@ -49,10 +45,18 @@ public class PersonMentionPropagationListener {
String oldNeedle = "@" + event.oldDisplayName();
String newNeedle = "@" + event.newDisplayName();
// Match @OldName only at a token boundary: not followed by a letter/digit/hyphen
// (catches @Hans-Peter when renaming Hans) AND not followed by " <Uppercase>"
// (catches @Hans Müller when renaming the single-name @Hans). False negatives —
// e.g. "@Hans Bekam" where Bekam is sentence-initial — are accepted as the
// conservative trade-off; the alternative (corruption) is irrecoverable.
Pattern boundary = Pattern.compile(
Pattern.quote(oldNeedle) + "(?![\\p{L}0-9\\-]| (?=\\p{Lu}))");
String replacement = Matcher.quoteReplacement(newNeedle);
for (TranscriptionBlock block : blocks) {
if (block.getText() != null) {
block.setText(block.getText().replace(oldNeedle, newNeedle));
block.setText(boundary.matcher(block.getText()).replaceAll(replacement));
}
for (PersonMention mention : block.getMentionedPersons()) {
if (mention.getPersonId().equals(event.personId())) {

View File

@@ -51,10 +51,6 @@ public class PersonService {
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
}
public boolean existsById(UUID id) {
return personRepository.existsById(id);
}
public List<Person> findCorrespondents(UUID personId, String q) {
if (q != null && !q.isBlank()) {
return personRepository.findCorrespondentsWithFilter(personId, q);

View File

@@ -251,6 +251,21 @@ class TranscriptionBlockControllerTest {
.andExpect(jsonPath("$.label").value("Anrede"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String longName = "A".repeat(201);
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns404_whenBlockDoesNotExist() throws Exception {

View File

@@ -26,9 +26,6 @@ import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@@ -42,16 +39,13 @@ class PersonMentionPropagationListenerTest {
@Autowired EntityManager em;
private PersonMentionPropagationListener listener;
private PersonService personService;
private UUID documentId;
private UUID annotationId;
@BeforeEach
void setUp() {
personService = mock(PersonService.class);
when(personService.existsById(any())).thenReturn(true);
listener = new PersonMentionPropagationListener(blockRepository, personService);
listener = new PersonMentionPropagationListener(blockRepository);
Document doc = documentRepository.save(Document.builder()
.title("Letter").originalFilename("letter.pdf")
@@ -123,6 +117,32 @@ class PersonMentionPropagationListenerTest {
org.assertj.core.groups.Tuple.tuple(hansId, "Hans Schmidt"));
}
@Test
void doesNotCorruptCompositeMention_whenRenamingSingleWordPerson() {
UUID hansMüllerId = savedPersonId("Hans", "Müller");
UUID hansId = savedPersonId(null, "Hans");
TranscriptionBlock saved = saveBlock(
"@Hans Müller schrieb. Auch @Hans hat geschrieben.",
List.of(
new PersonMention(hansMüllerId, "Hans Müller"),
new PersonMention(hansId, "Hans")));
em.clear();
listener.onPersonDisplayNameChanged(
new PersonDisplayNameChangedEvent(hansId, "Hans", "Henry"));
blockRepository.flush();
em.clear();
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getText())
.isEqualTo("@Hans Müller schrieb. Auch @Henry hat geschrieben.");
assertThat(reloaded.getMentionedPersons())
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
.containsExactlyInAnyOrder(
org.assertj.core.groups.Tuple.tuple(hansMüllerId, "Hans Müller"),
org.assertj.core.groups.Tuple.tuple(hansId, "Henry"));
}
@Test
void rewritesAllOccurrences_whenSameMentionAppearsTwiceInBlock() {
UUID personId = savedPersonId("Auguste", "Raddatz");
@@ -144,28 +164,6 @@ class PersonMentionPropagationListenerTest {
.containsExactly("Augusta Raddatz");
}
@Test
void noOps_whenPersonIdNoLongerExists_orphanedSidecarGuard() {
UUID orphanId = UUID.randomUUID();
when(personService.existsById(orphanId)).thenReturn(false);
TranscriptionBlock saved = saveBlock(
"Stale reference to @Ghost Name should not be rewritten.",
List.of(new PersonMention(orphanId, "Ghost Name")));
em.clear();
listener.onPersonDisplayNameChanged(
new PersonDisplayNameChangedEvent(orphanId, "Ghost Name", "Resurrected Name"));
blockRepository.flush();
em.clear();
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getText()).isEqualTo("Stale reference to @Ghost Name should not be rewritten.");
assertThat(reloaded.getMentionedPersons())
.extracting(PersonMention::getDisplayName)
.containsExactly("Ghost Name");
}
@Test
void propagatesAcross200Blocks_inUnderTwoSeconds_latencyFloor() {
UUID personId = savedPersonId("Auguste", "Raddatz");

View File

@@ -13,15 +13,19 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
import org.raddatz.familienarchiv.model.PersonMention;
import org.raddatz.familienarchiv.model.PersonNameAlias;
import org.raddatz.familienarchiv.model.PersonNameAliasType;
import org.raddatz.familienarchiv.model.PersonType;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -300,16 +304,38 @@ class PersonServiceTest {
}
@Test
void updatePerson_throwsConflict_whenListenerSignalsOptimisticLockFailure() {
void updatePerson_throwsConflict_whenBlockSaveAllAndFlushHitsOptimisticLock() {
// Wire a real PersonMentionPropagationListener with a mocked block repository
// that throws on saveAllAndFlush. The publisher mock routes events to the
// listener so the catch path traverses the same call chain as production:
// PersonService → publishEvent → listener → saveAllAndFlush throws → catch.
UUID id = UUID.randomUUID();
Person existing = Person.builder()
.id(id).firstName("Auguste").lastName("Raddatz")
.personType(PersonType.PERSON).build();
TranscriptionBlock referencingBlock = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(UUID.randomUUID()).annotationId(UUID.randomUUID())
.text("Brief von @Auguste Raddatz").sortOrder(0)
.mentionedPersons(new ArrayList<>(List.of(new PersonMention(id, "Auguste Raddatz"))))
.build();
TranscriptionBlockRepository blockRepo = mock(TranscriptionBlockRepository.class);
when(blockRepo.findByMentionedPersons_PersonId(id))
.thenReturn(List.of(referencingBlock));
when(blockRepo.saveAllAndFlush(any()))
.thenThrow(new ObjectOptimisticLockingFailureException(
TranscriptionBlock.class, referencingBlock.getId()));
PersonMentionPropagationListener realListener =
new PersonMentionPropagationListener(blockRepo);
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
doThrow(new OptimisticLockingFailureException("simulated concurrent block save"))
.when(eventPublisher).publishEvent(any(PersonDisplayNameChangedEvent.class));
doAnswer(inv -> {
realListener.onPersonDisplayNameChanged(inv.getArgument(0));
return null;
}).when(eventPublisher).publishEvent(any(PersonDisplayNameChangedEvent.class));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setPersonType(PersonType.PERSON);

View File

@@ -98,7 +98,9 @@ class TranscriptionServiceTest {
return b;
});
CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null, java.util.List.of());
CreateTranscriptionBlockDTO dto = CreateTranscriptionBlockDTO.builder()
.pageNumber(1).x(0.1).y(0.2).width(0.3).height(0.4)
.text("hello").build();
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
@@ -168,7 +170,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null, java.util.List.of());
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("new text").build();
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
@@ -189,7 +191,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede", java.util.List.of());
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("text").label("Anrede").build();
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
@@ -208,7 +210,7 @@ class TranscriptionServiceTest {
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
TranscriptionBlock result = transcriptionService.updateBlock(
docId, blockId, new UpdateTranscriptionBlockDTO("new", null, java.util.List.of()), UUID.randomUUID());
docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new").build(), UUID.randomUUID());
assertThat(result.getSource()).isEqualTo(BlockSource.MANUAL);
}
@@ -226,7 +228,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).sender(sender).build());
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null, java.util.List.of()), UUID.randomUUID());
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
verify(senderModelService).checkAndTriggerTraining(senderId);
}
@@ -242,7 +244,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).build());
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null, java.util.List.of()), UUID.randomUUID());
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
verify(senderModelService, never()).checkAndTriggerTraining(any());
}
@@ -477,7 +479,7 @@ class TranscriptionServiceTest {
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
when(annotationRepository.findById(annotId)).thenReturn(Optional.of(annotation));
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("new text", null, java.util.List.of()), userId);
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new text").build(), userId);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
@@ -502,7 +504,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("same text", null, java.util.List.of()), userId);
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("same text").build(), userId);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}

View File

@@ -0,0 +1,55 @@
# ADR-006: Synchronous domain events inside the publisher's transaction
## Status
Accepted
## Context
Issue #362 introduced the first cross-domain side-effect in this codebase: when a Person's display name changes, every transcription block that mentions the person must be rewritten — both `block.text` (the literal `@OldName` substring) and the `mentionedPersons` sidecar (the `displayName` field on the matching `PersonMention`). The rewrite is bidirectionally referential — Person depends on Transcription to make the rename atomic, and Transcription depends on Person to know what the new display name is.
A direct method call from `PersonService` into `TranscriptionBlockService` would invert the existing dependency arrow (Document → Person, not Person → Transcription) and introduce a runtime-circular reference at the package level. Avoiding the cycle while keeping the rename atomic is the constraint this ADR addresses.
Two prior pieces of infrastructure constrain the solution:
- `transcription_blocks.version` (JPA `@Version`) — concurrent autosave on a referenced block must roll back the rename instead of silently overwriting the autosave.
- `OcrTrainingService.recoverOrphanedRuns` is the only existing `@EventListener` and it consumes Spring's built-in `ApplicationReadyEvent` — no precedent for a custom domain event in this codebase before now.
## Decision
`PersonService.updatePerson` publishes `PersonDisplayNameChangedEvent(personId, oldDisplayName, newDisplayName)` via `ApplicationEventPublisher` whenever `Person.getDisplayName()` flips between the pre-save snapshot and the post-save value. `PersonMentionPropagationListener` (in the transcription package's `service/` layer) handles the event with `@EventListener @Transactional`, finds blocks via `findByMentionedPersons_PersonId`, rewrites text + sidecar, and calls `saveAllAndFlush`.
**Synchronous on purpose.** Spring's default event dispatcher invokes listeners on the publishing thread, inside the publisher's transaction. The propagation runs as part of the same `@Transactional` boundary as the rename — `OptimisticLockingFailureException` from a referenced block bubbles back up, the surrounding transaction rolls back, and `PersonService.updatePerson` translates it to `DomainException(PERSON_RENAME_CONFLICT, 409)`.
**Pattern for future cross-domain decoupling:**
1. Event record in `model/` of the publishing domain (e.g. `PersonDisplayNameChangedEvent`).
2. Listener in `service/` of the consuming domain (e.g. `PersonMentionPropagationListener`).
3. `@EventListener @Transactional` on the listener method — no `@TransactionalEventListener` unless the work genuinely doesn't need to commit with the publisher.
4. `saveAllAndFlush` (not `saveAll`) on any write where exceptions must surface inside the listener call so the publisher can catch and translate them — `saveAll` defers exceptions to commit time, after the publisher's `try` block has exited.
5. Audit log line at `INFO` level on the listener method — historical-text mutation needs an audit trail.
## Alternatives Considered
| Alternative | Why rejected |
|---|---|
| `PersonService` calls `TranscriptionBlockService.propagateDisplayNameChange(...)` directly | Inverts the dependency arrow. Person becomes runtime-coupled to Transcription; future domains that also care about renames (Comments, Notifications) compound the coupling. Events keep Person agnostic of who consumes them. |
| `@TransactionalEventListener(AFTER_COMMIT) + @Async` | The propagation would run after the rename commits, on a separate transaction. A failed propagation could leave block text out of sync with the renamed person until manual repair. Atomic transactional coupling is the safer default for historical-text mutation; switch to async only when the block count makes sync latency unacceptable (rough threshold: tens of thousands of blocks per renamed person). |
| Database trigger on `persons.last_name` | PL/pgSQL trigger would have to reach into `transcription_block_mentioned_persons` and `transcription_blocks.text`, smearing domain logic across SQL and Java. JPA's `@Version` would also be invisible to the trigger, so concurrent block autosaves would race silently. |
| Hibernate entity listener (`@PostUpdate` on Person) | Couples to Hibernate internals; harder to test in isolation; mixes lifecycle hooks with cross-domain side effects. Spring's `ApplicationEventPublisher` keeps the integration declarative and unit-testable. |
## Consequences
**Easier:**
- Person domain stays free of any compile-time dependency on Transcription. Future consumers (Comments, Notifications) subscribe to the same event without `PersonService` knowing they exist.
- Rename + propagation share one transaction → no half-applied state visible to readers, no orphaned rewrites if the rename fails after propagation, no "eventually-consistent" window for an archive that prizes historical fidelity.
- Concurrent autosaves on referenced blocks raise a structured 409 the frontend can render meaningfully (`error_person_rename_conflict`) instead of a generic 500.
- The pattern itself (record event in `model/`, listener in consumer's `service/`, sync `@EventListener @Transactional`, `saveAllAndFlush`) is reusable for the next cross-domain side effect.
**Harder:**
- Listener latency adds to the rename request's response time. The 200-block latency floor (< 2 s) is a merge-blocking regression test; if archive growth pushes it up, the migration path is one-annotation: switch to `@TransactionalEventListener(AFTER_COMMIT) + @Async` and add a manual-repair tool for propagation failures.
- Tests for the listener path require routing the publisher mock through a real listener (see `PersonServiceTest#updatePerson_throwsConflict_whenBlockSaveAllAndFlushHitsOptimisticLock`). Slightly more setup than a pure-Mockito test, but exercises the production call chain.
- `saveAllAndFlush` is mandatory in any synchronous listener that must surface JPA exceptions to the publisher's `try`-block. `saveAll` alone defers the flush to transaction commit, which happens after the publisher returns.
## Future Direction
If a single rename starts touching tens of thousands of blocks, switch the listener to `@TransactionalEventListener(phase = AFTER_COMMIT)` paired with `@Async` and add (a) an idempotency key to the event so a retry doesn't double-rewrite, (b) an admin tool that scans for sidecar entries whose `displayName` doesn't match the current `Person.getDisplayName()` and repairs them. At that point the orphan-guard path (existsById check before the rewrite) re-enters the listener as a deliberate piece of the async machinery rather than dead code.