Problemstilling
Under udviklingen af QuestGame stødte vi på en problemstilling i vores håndtering af brugerroller. I vores domænemodel har vi en abstrakt User-klasse med to underklasser: Student og Teacher.
Vi benytter Entity Framework Core (EF Core) med Table-per-Hierarchy (TPH), hvor begge brugertyper gemmes i tabellen Users. En discriminator-kolonne, UserType, afgør, hvilken C#-klasse EF Core skal instantiere ved læsning fra databasen.
Problemet opstod, da der blev implementeret funktionalitet til at ændre en brugers rolle fra Student til Teacher. Da EF Core ikke uden videre kan ændre et objekts konkrete C#-type, blev ændringen i stedet gennemført med en direkte SQL-forespørgsel:
var result = await _db.Database.ExecuteSqlInterpolatedAsync($"""
UPDATE "Users"
SET "UserType" = 'Teacher'
WHERE "Id" = {student.Id};
""");
_db.Entry(student).State = EntityState.Detached;Code language: PHP (php)
Løsningen fik mig til at overveje, om vores oprindelige domænemodel egentlig afspejlede det, vi forsøgte at modellere. Student og Teacher repræsenterede først og fremmest systemroller med forskellige rettigheder, men vi har modelleret dem som forskellige entitetstyper.
1. Konkret erfaring
Mit primære ansvarsområde i QuestGame er databasedesign og backendudvikling med fokus på EF Core og integrationen med PostgreSQL.
Jeg har arbejdet med TPH i vores domænemodel, blandt andet til at håndtere forskellige typer QuestItem. Her gav nedarvningen umiddelbart mening, fordi Quiz, Activity og Task har forskellige egenskaber og repræsenterer forskellige former for opgaver.
Jeg havde derfor ikke i første omgang overvejet, at samme tilgang kunne være mindre hensigtsmæssig for vores brugere.
Da jeg gennemgik SQL-forespørgslen til rolleændring, bemærkede jeg især anvendelsen af EntityState.Detached. Det gjorde mig nysgerrig på, hvorfor det var nødvendigt at fjerne objektet fra EF Cores change tracking efter opdateringen.
2. Reflekterende observation
Ved nærmere undersøgelse blev jeg opmærksom på, at en direkte SQL-opdatering ikke automatisk synkroniserer EF Cores change tracker.
Hvis et Student-objekt allerede er indlæst, vil objektet fortsat være af typen Student, selvom discriminator-værdien i databasen ændres til Teacher. Ved at bruge EntityState.Detached ophører EF Core med at tracke objektet, men det ændrer ikke objektets type.
Jeg begyndte også at undersøge konsekvenserne for vores øvrige relationer. Både Submission og QuestSessionStudent refererede direkte til Student. Et rolleskift kunne derfor skabe problemer med EF Cores materialisering af relationer, selvom fremmednøglerne fortsat var gyldige i databasen.
Derudover indeholdt brugerens JWT den oprindelige rolle, hvilket betød, at en databaseopdatering ikke nødvendigvis ville afspejles i brugerens aktuelle rettigheder.
Min vigtigste observation var dog, at problemet muligvis ikke lå i SQL-forespørgslen, men i vores måde at modellere brugerroller på.
3. Abstrakt begrebsdannelse
På baggrund af undersøgelsen begyndte jeg at se nærmere på forskellen mellem nedarvning og associationer i objektorienteret design.
Nedarvning beskriver et er-en-forhold, mens associationer gør det muligt at knytte selvstændige objekter sammen uden at gøre dem til underklasser af hinanden.
Det fik mig til at overveje, om en brugerrolle snarere burde betragtes som en egenskab ved en bruger end som en særskilt type bruger.
Samtidig havde vi et behov for at kunne tilføje rollespecifikke oplysninger, eksempelvis et alias til elever og en jobtitel til lærere.
På den baggrund udarbejdede jeg et forslag til en ny domænemodel:
Userfungerer som fælles brugeridentitet med enUserRole-enum.StudentProfileogTeacherProfileindeholder rollespecifikke oplysninger og er knyttet tilUsergennem 1:1-relationer.QuestSessionParticipantrepræsenterer deltagelsen i en konkret spilsession og indeholder et snapshot af elevens alias.Submissionrefererer tilQuestSessionParticipantfrem for direkte tilUser.
Den sidste ændring udsprang af en anden observation: Historiske spildata bør ikke nødvendigvis være afhængige af, om en brugerkonto stadig eksisterer, eller om eleven efterfølgende ændrer sit alias.
På den måde kunne jeg videreføre principperne fra vores tidligere arbejde med snapshots af QuestSessions.
Det hænger særligt sammen med mit læringsmål om at kunne analysere og begrunde arkitektur- og designvalg. Her fik jeg mulighed for at sammenligne to forskellige måder at modellere brugere på og overveje, hvilke konsekvenser de kunne have for systemets videre udvikling.
4. Aktiv eksperimenteren
For at undersøge, hvordan den nye model fungerer i praksis, gennemførte jeg en refaktorering af kodebasen i en separat Git-branch.
Refaktoreringen omfattede både domæneklasser, EF Core-konfiguration, relationer og eksisterende backend-handlers. Der var altså ikke kun tale om at ændre nogle få klasser, men også om at tilpasse den funktionalitet, som allerede var bygget op omkring den oprindelige model.
Rolleændringer kunne nu håndteres som almindelige EF Core-opdateringer:
user.Role = UserRole.Teacher;
await _db.SaveChangesAsync();Code language: JavaScript (javascript)
Det fjernede behovet for at ændre en discriminator direkte gennem SQL.
Samtidig blev QuestSessionParticipant gjort uafhængig af brugerens fortsatte eksistens ved at gøre UserId nullable. Dermed blev det muligt at bevare besvarelser, point og deltagelseshistorik, selvom en bruger blev slettet.
Jeg undersøgte også, hvordan ændringerne kunne verificeres gennem integrationstest mod PostgreSQL og PostGIS.
Den refaktorerede løsning bestod vores automatiserede build og 19 tests, herunder nye tests af rolleændringer og bevarelse af historiske data.
Selvom resultatet umiddelbart var positivt, blev omfanget af refaktoreringen også tydeligere undervejs. Ændringer i domænemodellen havde konsekvenser for flere dele af systemet, herunder brugerhåndtering, authorization og eksisterende API-endpoints.
Det blev derfor også en praktisk undersøgelse af, hvordan en forholdsvis grundlæggende designbeslutning kan påvirke systemets øvrige komponenter.
Den praktiske refaktorering bidrog samtidig til mit læringsmål om at kunne implementere og vurdere backendfunktionalitet. Jeg fik mulighed for at undersøge, hvordan ændringer i domænemodellen påvirkede API-endpoints, forretningslogik og dataadgang, og hvordan integrationstest kunne bruges til at verificere den alternative løsning.
Konklusion
Arbejdet har især givet mig en større forståelse for sammenhængen mellem domænemodellering, EF Core og databasens relationer.
Jeg har tidligere primært betragtet nedarvning som en måde at organisere beslægtede entiteter på. Denne problemstilling har gjort mig mere opmærksom på, at valget også har konsekvenser for, hvordan entiteter kan ændre sig over tid.
Jeg har desuden fået en bedre forståelse af EF Cores change tracking og forskellen på at ændre data gennem EF Core og gennem direkte SQL.
Efter at have gennemgået refaktoreringen i teamet besluttede vi, at ændringerne var for omfattende at gennemføre på nuværende tidspunkt. Vi har allerede implementeret funktionalitet, der afhænger af den eksisterende domænemodel, og en integration af refaktoreringen ville derfor kræve ændringer i flere dele af systemet.
Jeg har valgt at beholde løsningen i en separat Git-branch som dokumentation for min undersøgelse og læring. Selvom den ikke bliver en del af vores fælles kodebase, har arbejdet været værdifuldt, fordi jeg har kunnet afprøve et alternativt design og undersøge konsekvenserne i praksis.
Beslutningen har også givet mig anledning til at reflektere over, at en teknisk løsning ikke kun skal vurderes ud fra dens arkitektur, men også ud fra projektets omfang, tidsramme og eksisterende implementering.
Den vigtigste læring er måske, at en teknisk udfordring i implementeringen nogle gange kan være et tegn på, at det oprindelige design bør genovervejes.
Jeg tager især den erfaring med mig i mit videre arbejde med backendudvikling: At det kan betale sig at undersøge, om en uhensigtsmæssig implementering skyldes selve teknologien, eller om problemet opstår tidligere i designprocessen. Samtidig har forløbet gjort det tydeligere for mig, hvor stor betydning tidlige beslutninger om systemdesign kan få for mulighederne for at videreudvikle et system.

