Skip to main content

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:

EstatSignificatIndicador UI
SAVEDDesat correctament✓ verd o sense indicador
DIRTYPendent de desar● groc/taronja, badge "unsaved"
SAVINGDesant…Spinner, botó deshabilitat
ERRORError en desar✗ vermell, missatge d'error

2.2 Regles de comportament

ModeComportament
Manual saveDIRTY activa botó "Guardar" + activa leave guard
AutosaveCada canvi entra a SAVING → acaba en SAVED o ERROR (amb retry)

3. Quan fem Autosave (criteri únic)

Regla d'or

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 dirty per 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, mantenir DIRTY i 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 onChange per 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

Prohibit

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:

  1. Fer update si pot deduir la identitat (p.ex. per code + spec_dance_id)
  2. O fallar amb 400/409 clar i accionable
Auto-heal pattern

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ègiaQuan usar-la
Optimistic locking amb @VersionRecomanat a llarg termini
Pessimistic lockCasos 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:

  1. Tota la lògica de guardat + mapping es fa dins una transacció del servei
  2. Les relacions es carreguen amb fetch joins abans del mapping
  3. 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: DanceLineDanceSpec).

Principi clau

Font de veritat

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

EscenariResponsableMecanisme
Nou parent creatBackend (norma)Crear dependent dins la mateixa transacció
Parent legacy sense dependentFrontend (fallback)Cridar endpoint idempotent POST /init al primer accés
Important

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)

Requisit crític

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

EstatDescripcióSortida
LOADING_AUTHEsperant autenticacióNO_PERMISSION o següent loading
LOADING_DANCECarregant entitat parentDANCE_NOT_FOUND o LOADING_SPEC
LOADING_SPECCarregant entitat dependentINITIALIZING_SPEC o READY
INITIALIZING_SPECCreant dependent (fallback legacy)INIT_ERROR o READY
READYDades carregadesMostrar editor
NO_PERMISSIONSense permisosMissatge + botó tornar/login
DANCE_NOT_FOUNDParent no existeixMissatge + link a llista
INIT_ERRORError creant dependentMissatge + botó reintentar
ERRORError general recuperableMissatge + 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)

CapaResponsabilitatImplementació
BackendIntegritat de dadesCrear dependent amb parent (transacció atòmica)
BackendIdempotènciaGet-or-create segur a retries
BackendConcurrènciaLocks o @Version si cal
BackendFont de veritatDependent sempre existeix si parent existeix
FrontendUX claraMàquina d'estats amb sortida per a tots els loading
FrontendQuery gatingNo cridar abans d'authReady
FrontendError recoveryBotó reintentar en tots els estats d'error
FrontendFallback legacyCridar /init només si dependent no existeix

Anti-patterns (evitar)

Errors comuns
  1. 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).
  2. Spinner sense sortida: Qualsevol estat LOADING ha de tenir timeout o transició a ERROR amb retry.
  3. 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).
  4. Queries sense gating: Cridar GET abans d'authReady causa 401 i loops de retry.
  5. 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, ERROR amb retry, o NOT_FOUND)
  • Query gating per auth: Cap GET abans d'authReady (evita 401 i loops)
  • Fallback idempotent: Si dependent no existeix (legacy), cridar /init amb control de retry únic
Documentació relacionada

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

BeneficiDescripció
States per seccióCada secció pot tenir el seu propi estat DIRTY/SAVING
Errors localitzatsUn error en components no afecta metadata
UI claraUn 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 │
└─────────────────────────────────────────────────────────────────┘
Responsabilitats
  • 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 (@Version o 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:

  1. ADR: Manual-save per defecte - Totes les seccions són manual-save excepte quan es justifica autosave
  2. ADR: Autosave només en prerequisits - Autosave només quan les dades són prerequisit per accions immediates
  3. ADR: Upsert + @Version - Upsert amb preservació d'IDs i optimistic locking amb @Version
  4. 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óDataDescripció
v12026-01-07Versió inicial de l'estàndard
v1.12026-01-07Afegit "Entity Initialization Pattern" + actualitzat Definition of Done