Patrons de Guardat Robust (v1)
Estàndard del projecte per garantir que cap pàgina d'edició perdi canvis de l'usuari.
1. Objectiu
Garantir que qualsevol pàgina d'edició:
- ✅ No perdi canvis "en silenci"
- ✅ Sigui previsible per l'usuari
- ✅ Sigui robusta davant autosave, doble clic, xarxa lenta, refresh i crides repetides
2. Patró general de guardat (UI)
2.1 Estats de secció
Cada secció editable té un estat explícit:
| Estat | Significat | Indicador UI |
|---|---|---|
SAVED | Desat correctament | ✓ verd o sense indicador |
DIRTY | Pendent de desar | ● groc/taronja, badge "unsaved" |
SAVING | Desant… | Spinner, botó deshabilitat |
ERROR | Error en desar | ✗ vermell, missatge d'error |
2.2 Regles de comportament
| Mode | Comportament |
|---|---|
| Manual save | DIRTY activa botó "Guardar" + activa leave guard |
| Autosave | Cada canvi entra a SAVING → acaba en SAVED o ERROR (amb retry) |
3. Quan fem Autosave (criteri únic)
Autosave només per dades que són prerequisit per a altres accions immediates.
Exemple: El catàleg de components s'ha de desar abans de poder usar-los a la seqüència.
La resta segueix el patró:
- Botó "Guardar" explícit
- Leave guard si hi ha
DIRTY
Això redueix complexitat i augmenta la confiança de l'usuari.
4. Estàndard tècnic – Responsabilitats i garanties
4.1 Frontend (obligatori)
El frontend implementa el "control de vol":
A) Estat i traçabilitat
// Exemple d'estructura d'estat per secció
interface SectionState {
status: 'SAVED' | 'DIRTY' | 'SAVING' | 'ERROR';
lastError?: string;
lastSavedAt?: Date;
}
- Mantenir
dirtyper secció i global - Mostrar feedback visible (badge, text o iconografia)
- Control d'errors: si falla, no amagar el problema
B) Evitar pèrdues
- Route leave guard: si hi ha canvis
DIRTY, avís abans de sortir - En autosave: si
ERROR, mantenirDIRTYi oferir "Reintentar"
// Exemple de leave guard amb React Router
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [isDirty]);
C) Control de concurrència
- Per secció: no enviar 2 saves en paral·lel (queue o latest wins)
- Debounce
onChangeper autosave (p.ex. 500–1000 ms) quan tingui sentit
// Exemple amb debounce
const debouncedSave = useMemo(
() => debounce((data) => saveToServer(data), 800),
[]
);
D) Source of truth
- Després d'un save OK, el frontend actualitza l'estat local amb la resposta del servidor (IDs, normalitzacions, etc.)
- No "assumir" IDs ni valors; es confia en el backend
4.2 Backend (obligatori)
El backend és el "mur de contenció":
A) Upsert estable
El patró DELETE ALL + INSERT ALL quan hi ha referències externes.
Requisit: Preservar IDs i actualitzar en lloc de recrear.
// ✅ Correcte: upsert preservant IDs
public Component upsertComponent(SpecDance spec, ComponentDTO dto) {
return componentRepository
.findBySpecDanceIdAndCode(spec.getId(), dto.getCode())
.map(existing -> updateComponent(existing, dto)) // UPDATE
.orElseGet(() -> createComponent(spec, dto)); // INSERT
}
B) Idempotència funcional
Si arriba una request repetida o amb estat "vell", el backend ha de:
- Fer update si pot deduir la identitat (p.ex. per
code+spec_dance_id) - O fallar amb 400/409 clar i accionable
Si id=null però el code existeix → recuperar l'entitat existent i actualitzar-la.
C) Concurrència
Per operacions replace/patch sensibles: mecanisme per evitar race conditions.
| Estratègia | Quan usar-la |
|---|---|
Optimistic locking amb @Version | Recomanat a llarg termini |
| Pessimistic lock | Casos crítics, especialment autosave ràpid |
// Exemple optimistic locking
@Entity
public class SpecDance {
@Version
private Long version;
// ...
}
D) Validació de coherència
- Validar duplicats al request
- Validar integritat referencial (no eliminar components en ús)
- Errors amb codis consistents (
ProblemDetail/ error codes)
// Exemple de validació de duplicats
Set<String> codes = new HashSet<>();
for (ComponentDTO dto : request.getComponents()) {
if (!codes.add(dto.getCode())) {
throw new DuplicateCodeException("Component code duplicat: " + dto.getCode());
}
}
E) Respostes consistents
Cada save retorna la representació actual (o un DTO clar) perquè el frontend sincronitzi.
// El PUT retorna l'entitat actualitzada
@PutMapping("/{id}/components")
public ResponseEntity<SpecDanceDTO> updateComponents(
@PathVariable Long id,
@RequestBody ComponentsUpdateRequest request) {
SpecDance updated = specDanceService.updateComponents(id, request);
return ResponseEntity.ok(mapper.toDTO(updated));
}
4.3 Hydrate + Map Pattern (evitar LazyInitializationException)
Objectiu
Evitar LazyInitializationException en endpoints d'escriptura (POST/PUT) que retornen DTOs amb relacions lazy.
El problema
Quan un endpoint d'escriptura retorna un DTO complex amb relacions lazy:
// ❌ Anti-patró: LazyInitializationException
@PutMapping("/{id}")
public SongDetailDto update(@PathVariable Long id, @RequestBody UpdateRequest req) {
Song song = songRepository.findById(id).orElseThrow();
song.setTitle(req.title());
Song saved = songRepository.save(song);
// BOOM! La sessió està tancada quan Jackson serialitza saved.getDanceSongs()
return mapper.toDetailDto(saved); // LazyInitializationException!
}
Causa: La sessió Hibernate es tanca abans que Jackson serialitzi les relacions lazy.
Solució: Hydrate + Map
El patró "Hydrate + Map" garanteix que:
- Tota la lògica de guardat + mapping es fa dins una transacció del servei
- Les relacions es carreguen amb fetch joins abans del mapping
- El DTO es construeix dins la transacció, evitant accessos lazy fora de sessió
// ✅ Correcte: Hydrate + Map dins transacció del servei
@Service
public class SongService {
@Transactional
public SongDetailDto updateAndReturnDetailDto(Long id, UpdateRequest req, String requesterEmail) {
// 1. Carrega amb owner per verificar permisos
Song existing = songs.findByIdWithOwner(id)
.orElseThrow(() -> new NotFoundException("Song not found: " + id));
// 2. Verificar permisos
verifyPermissions(existing, requesterEmail);
// 3. Aplica canvis
existing.setTitle(req.title());
// ... altres camps
// 4. Guarda
songs.save(existing);
// 5. Rehidrata amb fetch joins (CLAU!)
Song hydrated = songs.findDetailById(id)
.orElseThrow(() -> new NotFoundException("Song not found after save"));
// 6. Carrega col·leccions addicionals i mapeja a DTO
List<Link> songLinks = links.findBySong_Id(id);
return mapper.toDetailDto(hydrated, songLinks); // Tot dins transacció!
}
}
Repository amb fetch joins
public interface SongRepository extends JpaRepository<Song, Long> {
/**
* Carrega Song amb totes les relacions per a SongDetailDto.
*/
@Query("""
select distinct s from Song s
left join fetch s.danceSongs ds
left join fetch ds.dance d
left join fetch d.level
left join fetch s.owner
where s.id = :id
""")
Optional<Song> findDetailById(@Param("id") Long id);
/**
* Carrega Song amb owner per verificar permisos.
*/
@Query("""
select s from Song s
left join fetch s.owner
where s.id = :id
""")
Optional<Song> findByIdWithOwner(@Param("id") Long id);
}
Controller sense @Transactional
@RestController
public class SongController {
// ❌ MAI posar @Transactional al controller per "arreglar" lazy loading!
@PutMapping("/{id}")
public SongDetailDto update(@PathVariable Long id, @RequestBody UpdateRequest req) {
// Delega al servei que implementa Hydrate+Map
return service.updateAndReturnDetailDto(id, req, authUtil.getCurrentEmail());
}
}
Checklist
| ✅ | Requisit |
|---|---|
| ☐ | La lògica de guardat està al servei, no al controller |
| ☐ | El mètode del servei és @Transactional |
| ☐ | Després de save(), es fa un fetch join reload (findDetailById) |
| ☐ | El mapping a DTO es fa dins la transacció |
| ☐ | El controller no té @Transactional |
| ☐ | Tests d'integració validen que la resposta JSON és completa |
Test d'integració recomanat
@Test
@DisplayName("PUT returns SongDetailDto with lazy relations loaded")
void updateSong_returnsDtoWithRelations() throws Exception {
mockMvc.perform(put("/api/songs/{id}", songId)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.dances").isArray()) // Proves no LazyInitEx
.andExpect(jsonPath("$.links").isArray());
}
5. Entity Initialization Pattern (Get-or-Create)
Objectiu
Evitar inconsistències i UI bloquejada quan una pantalla d'edició depèn d'una entitat 1:1 "fill" obligatòria (ex: Dance → LineDanceSpec).
Principi clau
El backend garanteix la integritat: si existeix el parent, l'entitat dependent ha d'existir sempre o crear-se de forma idempotent.
Norma vs Fallback
| Escenari | Responsable | Mecanisme |
|---|---|---|
| Nou parent creat | Backend (norma) | Crear dependent dins la mateixa transacció |
| Parent legacy sense dependent | Frontend (fallback) | Cridar endpoint idempotent POST /init al primer accés |
El frontend NO ha de ser responsable de crear dependents per defecte. Només actua com a fallback per entitats legacy.
Backend Standard
Creació atòmica (norma)
// DanceController.java - crear parent + dependent en una transacció
@PostMapping
@Transactional
public DanceSummaryDto create(@RequestBody CreateDanceRequest req) {
Dance saved = service.save(dance);
specService.createDefaultSpec(saved); // Mateixa transacció
return mapper.toSummary(saved);
}
Get-or-create idempotent (fallback)
// LineDanceSpecService.java - operació idempotent per legacy
@Transactional
public LineDanceSpec getOrCreateSpec(Long danceId) {
return specRepo.findById(danceId).orElseGet(() -> {
Dance dance = danceRepo.findById(danceId)
.orElseThrow(() -> new NotFoundException("Dance not found"));
return createDefaultSpec(dance);
});
}
Endpoint per entitats legacy
// LineDanceSpecController.java
@PostMapping("/init") // Path real: POST /api/dances/{danceId}/spec/init
@PreAuthorize("hasAnyRole('ADMIN', 'TEACHER')")
public LineDanceSpecDto initSpec(@PathVariable Long danceId) {
LineDanceSpec spec = service.getOrCreateSpec(danceId);
return mapper.toDto(spec);
}
Garanties d'aquest endpoint:
- Idempotent (múltiples crides retornen el mateix resultat)
- Safe to retry (no genera duplicats ni errors de concurrència)
- Només per legacy (no és la norma per crear dependents)
Frontend Standard (State Machine)
La pantalla d'edició NO pot quedar en spinner infinit. Tots els estats LOADING_* han de tenir sortida clara: READY, ERROR (amb retry) o NOT_FOUND.
Estats obligatoris
| Estat | Descripció | Sortida |
|---|---|---|
LOADING_AUTH | Esperant autenticació | → NO_PERMISSION o següent loading |
LOADING_DANCE | Carregant entitat parent | → DANCE_NOT_FOUND o LOADING_SPEC |
LOADING_SPEC | Carregant entitat dependent | → INITIALIZING_SPEC o READY |
INITIALIZING_SPEC | Creant dependent (fallback legacy) | → INIT_ERROR o READY |
READY | Dades carregades | Mostrar editor |
NO_PERMISSION | Sense permisos | Missatge + botó tornar/login |
DANCE_NOT_FOUND | Parent no existeix | Missatge + link a llista |
INIT_ERROR | Error creant dependent | Missatge + botó reintentar |
ERROR | Error general recuperable | Missatge + botó reintentar |
Implementació recomanada
// Màquina d'estats amb sortida garantida
type PageState =
| "LOADING_AUTH"
| "LOADING_DANCE"
| "NO_PERMISSION"
| "DANCE_NOT_FOUND"
| "LOADING_SPEC"
| "INITIALIZING_SPEC"
| "INIT_ERROR"
| "READY"
| "ERROR";
const getPageState = (): PageState => {
if (!authReady) return "LOADING_AUTH";
if (!me) return "NO_PERMISSION";
if (danceLoading) return "LOADING_DANCE";
if (!dance) return "DANCE_NOT_FOUND";
if (specLoading) return "LOADING_SPEC";
if (initMutation.isPending) return "INITIALIZING_SPEC";
if (initError) return "INIT_ERROR";
if (spec) return "READY";
return "LOADING_SPEC";
};
// Render amb switch exhaustiu (tots els estats coberts)
switch (pageState) {
case "LOADING_AUTH":
case "LOADING_DANCE":
case "LOADING_SPEC":
return <LoadingCard message="Carregant..." />;
case "INITIALIZING_SPEC":
return <LoadingCard message="Preparant especificació..." />;
case "INIT_ERROR":
case "ERROR":
return (
<ErrorCard message={error}>
<Button onClick={handleRetry}>Reintentar</Button>
</ErrorCard>
);
case "NO_PERMISSION":
return <PermissionDeniedCard />;
case "DANCE_NOT_FOUND":
return <NotFoundCard />;
case "READY":
return <Editor dance={dance} spec={spec} />;
}
Query Gating (evitar crides prematures)
// ✅ Queries condicionals per evitar 401 i race conditions
const { data: dance } = useDance(id!, {
enabled: authReady && !!id
});
const { data: spec } = useQuery({
queryKey: ["lineDanceSpec", danceId],
queryFn: () => getLineDanceSpec(danceId),
enabled: authReady && !!danceId && !!dance && canEdit,
retry: false, // Evitar loops si 404
});
// Fallback per legacy: cridar /init només si spec no existeix
const initSpecMutation = useMutation({
mutationFn: () => initLineDanceSpec(danceId), // POST /init
onSuccess: () => queryClient.invalidateQueries(["lineDanceSpec", danceId]),
});
useEffect(() => {
const specNotFound = specQuerySuccess && spec === null;
if (specNotFound && canEdit && !initAttemptedRef.current) {
initAttemptedRef.current = danceId;
initSpecMutation.mutate();
}
}, [spec, specQuerySuccess, canEdit, danceId]);
Responsabilitats (Backend vs Frontend)
| Capa | Responsabilitat | Implementació |
|---|---|---|
| Backend | Integritat de dades | Crear dependent amb parent (transacció atòmica) |
| Backend | Idempotència | Get-or-create segur a retries |
| Backend | Concurrència | Locks o @Version si cal |
| Backend | Font de veritat | Dependent sempre existeix si parent existeix |
| Frontend | UX clara | Màquina d'estats amb sortida per a tots els loading |
| Frontend | Query gating | No cridar abans d'authReady |
| Frontend | Error recovery | Botó reintentar en tots els estats d'error |
| Frontend | Fallback legacy | Cridar /init només si dependent no existeix |
Anti-patterns (evitar)
- Frontend auto-create per defecte: Si el frontend detecta 404 i crea el dependent via PUT, és fràgil (race amb auth, loops, spinner infinit).
- Spinner sense sortida: Qualsevol estat
LOADINGha de tenir timeout o transició aERRORamb retry. - Refetch global en editors multi-secció: Invalidar totes les queries després de guardar una secció trepitja canvis locals d'altres seccions (usar updates locals).
- Queries sense gating: Cridar GET abans d'
authReadycausa 401 i loops de retry. - Assumir que el dependent existeix: Sempre contemplar cas legacy amb fallback idempotent.
Definition of Done (add-ons per editors amb dependents 1:1)
A més dels requisits estàndard de guardat (secció 7), afegir:
- No infinite spinner: Tots els estats
LOADING_*tenen sortida (READY,ERRORamb retry, oNOT_FOUND) - Query gating per auth: Cap GET abans d'
authReady(evita 401 i loops) - Fallback idempotent: Si dependent no existeix (legacy), cridar
/initamb control de retry únic
Veure ADR-003: LineDanceSpec s'ha de crear al backend amb Dance per al raonament detallat d'aquesta decisió.
6. Patrons API recomanats
Per cada pàgina d'edició, endpoints separats per seccions:
PUT/PATCH /resource/{id}/metadata
PUT/PATCH /resource/{id}/components
PUT/PATCH /resource/{id}/sequence
Avantatges
| Benefici | Descripció |
|---|---|
| States per secció | Cada secció pot tenir el seu propi estat DIRTY/SAVING |
| Errors localitzats | Un error en components no afecta metadata |
| UI clara | Un botó guarda una cosa |
7. Quina capa "manda" en robustesa?
┌─────────────────────────────────────────────────────────────────┐
│ Frontend: Qualitat percebuda │
│ - Estats visibles │
│ - Avisos de sortida │
│ - Evitar pèrdues │
│ - Reintents │
└─────────────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ Backend: Robustesa final │
│ - Integritat de dades │
│ - Idempotència │
│ - Concurrència │
│ - No es trenca amb FE imperfecte │
└─────────────────────────────────────────────────────────────────┘
- Backend: No es trenca ni amb un frontend imperfecte
- Frontend: No deixa l'usuari perdre feina ni confondre's
8. Definition of Done per noves pàgines d'edició
Checklist per qualsevol nova pàgina d'edició:
Frontend
- Cada secció editable té estat explícit (
SAVED/DIRTY/SAVING/ERROR) - Indicador visual de l'estat (badge, spinner, icona)
- Leave guard implementat si hi ha canvis sense desar
- Debounce implementat si és autosave
- Després de save OK, s'actualitza estat local amb resposta del servidor
- Gestió d'errors visible i amb opció de reintentar
- No infinite spinner: cada estat té sortida (error + retry)
- Queries gated by auth: cap GET abans d'
authReady - Leave guard global: context + SafeLink +
beforeunload(si no uses data router)
Backend
- Endpoints separats per secció quan té sentit
- Upsert preserva IDs existents
- Validació de duplicats al request
- Validació d'integritat referencial
- Resposta inclou la representació actualitzada
- Mecanisme de concurrència (
@Versiono lock) per operacions crítiques - Entitats dependents 1:1 obligatòries: crear dins la mateixa transacció del parent
- Get-or-create idempotent: per entitats legacy sense dependent
9. Decisions arquitecturals relacionades
Considerem formalitzar les següents decisions com ADRs:
- ADR: Manual-save per defecte - Totes les seccions són manual-save excepte quan es justifica autosave
- ADR: Autosave només en prerequisits - Autosave només quan les dades són prerequisit per accions immediates
- ADR: Upsert + @Version - Upsert amb preservació d'IDs i optimistic locking amb
@Version - ✅ ADR-003: LineDanceSpec s'ha de crear al backend amb Dance - Entitats dependents 1:1 obligatòries es creen amb el parent
Historial de versions
| Versió | Data | Descripció |
|---|---|---|
| v1 | 2026-01-07 | Versió inicial de l'estàndard |
| v1.1 | 2026-01-07 | Afegit "Entity Initialization Pattern" + actualitzat Definition of Done |