ADR-021: Vedlikeholdte projeksjonskolonner på Sak (behandlingsform, regelstatus, gjennomgått)⚓︎
Behandlingsform,SamletRegelstatusogGjennomgangsstatusblir persisterte kolonner påSak, vedlikeholdt av eksplisitte domenehendelser —RegelresultatOpprettet,BeslutningRegistrert,StadiegjennomgangEndret,SakStatusEndret— dispatchet post-commit av den allerede eksisterendeDomainEventDispatchingInterceptor. Ingen ny infrastruktur-interceptor: hendelsene raises i aggregatenes egne metoder og håndteres av navngitteIDomainEventHandler<T>. Dispatch er en liten, egeneid mekanisme (ikke Mediator) —Enova.Kai.Domainmister sin eneste Mediator-referanse i samme slag. Dette erstatter dagens per-forespørsel-utledning iDbCaseQueries, som tvingerGET /casesogGET /cases/summarytil å laste og prosessere hele sakssett i minnet før paginering/telling.
Kontekst⚓︎
GET /cases og GET /cases/summary i Kai Platform er observert trege i test:
| Kall | Tid |
|---|---|
/cases?behandlingsform=krever_gjennomgang&gjennomgatt=false&stadium=soknad&limit=25 |
10,4 s |
/cases?behandlingsform=kan_godkjennes_direkte&gjennomgatt=false&stadium=soknad&limit=25 |
11 s |
/cases?behandlingsform=krever_gjennomgang&gjennomgatt=true&stadium=soknad&limit=25 |
3,5 s |
/cases?behandlingsform=kan_godkjennes_direkte&gjennomgatt=true&stadium=soknad&limit=25 |
3,2 s |
/cases/summary?stadium=soknad |
11,3 s |
Rotårsak: Behandlingsform/SamletRegelstatus/HarNøytraltUtfall/Gjennomgangsstatus finnes ikke som
kolonner på Sak — de utledes ved hver forespørsel fra Regelresultater/Beslutninger/
Stadiegjennomganger via Samlestatuspolicy/Regelrisiko (DbCaseQueries.cs). Fordi
behandlingsform/regelstatus ikke er SQL-filtrerbare, laster ListAsync hele det filtrerte
sakssettet og projiserer det (kostbart per-rad-arbeid) før paginering skjer i C# — og siden
frontend alltid sender behandlingsform sammen med gjennomgatt (fire faner: manuell/AI ×
aktiv/gjennomgått), er dette normalveien, ikke unntaket. SummaryAsync gjør det samme uforbeholdent
for hele stadiet, uansett gjennomgatt, for å telle fram 4 badge-tall. Full utredning:
docs/superpowers/specs/2026-09-04-sak-projeksjonskolonner-design.md.
Bekreftet i kodegjennomgang: Samlestatuspolicy.Utled/Regelrisiko.Utled er rene,
per-sak-beregninger — ingen kryss-sak-tilstand kreves. Det gjør inkrementell rekalkulering ved
skriving (fremfor en periodisk batch/materialized view) mulig og korrekt.
Revidert etter PR-gjennomgang (Bjørn Kristian Punsvik): første utkast av denne ADR-en foreslo en
generisk SaveChangesInterceptor som inspiserte ChangeTracker for Regelresultat/Beslutning/
Stadiegjennomgang + en Sak.Status-diff. Tilbakemeldingen var at en slik interceptor virker, men
skjuler koblingen for utviklere som ikke kjenner den fra før — «ikke åpenbart at de blir kjørt når
man leser koden». Samtidig fantes allerede ferdig bygget, men ubrukt, domenehendelse-infrastruktur i
Enova.Kai.Domain/Common/ (Aggregate<TId> med Raise/DomainEvents, IHasDomainEvents,
DomainEvent) og en DomainEventDispatchingInterceptor som allerede dispatcher enhver
DomainEvent post-commit — men ingen kode i repoet kaller Raise(...) i dag. Å bruke denne i
stedet gir samme mekanisme (post-commit, best-effort) med koblingen synlig der en utvikler faktisk
leser koden (aggregatets egen metode + en navngitt handler), til omtrent samme kostnad. En
fullstendig aggregate-root-sammenslåing (Sak eier Regelresultat/Beslutning/Stadiegjennomgang
i én transaksjonsgrense) ble også diskutert og bevisst utsatt — se Ikke-mål.
Videre revidert (samme PR-diskusjon): DomainEvent/DomainEventDispatchingInterceptor var
opprinnelig bygget mot Mediator (DomainEvent : INotification, dispatch via Mediators
IPublisher). Siden Enova.Kai.Domain allerede har som prinsipp å være fri for infrastruktur-
/rammeverksavhengigheter, og siden det denne fiksen faktisk trenger av Mediator er minimalt (type
→ handler-oppslag + sekvensiell await, ingen pipeline-behaviors — de gjelder kun
kommando-dispatch, ikke notification-publish), erstattes Mediator-koblingen på hendelsessiden med en
liten, egeneid IDomainEventHandler<T> + IDomainEventDispatcher (§ Beslutning under).
Enova.Kai.Domain.csproj mister dermed sin eneste Mediator-referanse. ICommandHandler/ISender
(regelmotorens kommando-dispatch) er utenfor scope — beholdes på Mediator som i dag, ingen
ambisjon om å fjerne det i denne fiksen.
Beslutning⚓︎
- Fire nye kolonner på
saker:behandlingsform,samlet_regelstatus,har_noeytralt_utfall,gjennomgangsstatus— norske egenskapsnavn påSak(Behandlingsform,SamletRegelstatus,HarNøytraltUtfall,Gjennomgangsstatus, jf. ADR-015). Oppdatert etter sak-godkjent/avvist (#22207):gjennomgangsstatusble opprinnelig lagt til som en boolskgjennomgaatt-kolonne under denne ADR-en, men er siden erstattet av en enum-backetinteger-kolonne med tre tilstander (IkkeGjennomgått/Godkjent/Avvist) — ikke forveksle med API-spørreparameteretgjennomgatt, som er en egen, uavhengig wire-navngivingsbeslutning. Ny indeksIX_saker_status_behandlingsform_gjennomgangsstatuspå(status, behandlingsform, gjennomgangsstatus). Behandlingsform/AggregateRuleStatusflyttes fraEnova.Kai.Application.Ports.EnumstilEnova.Kai.Domain.Cases(samme navn, samme medlemmer — kun namespace/assembly endres). Oppdaget under implementasjonsplanlegging:Sak(Domain) kan ikke ha egenskaper typet med disse enumene mens de ligger i Application —Domain_DoesNotReference_Application(Enova.Kai.Architecture.Tests/LayerDependencyTests.cs) håndhever at Domain aldri refererer Application. Begge enumene er ubiquitous-language-begreper (CONTEXT.md: "Saksnivå-utledning eid av Samlestatuspolicy") som hører hjemme i Domain uansett — flyttingen er en ren namespace-endring (kompilator-verifisert på hvert bruksted, ~30-40 filer får en nyusing-linje), ikke en logikkendring.Samlestatuspolicy/Regelrisikoselv (de rene domenetjenestene som utleder disse verdiene) flyttes ikke — det er et eget, større arkitekturspørsmål, bevisst utsatt, ikke avvist, på linje med aggregate-root-diskusjonen over.- Én delt, ren kalkulator (
SakProjeksjonKalkulator) trekkes ut av dagensDbCaseQueries.ComputeRuleStatus+ gjennomgått-oppslaget, og brukes av både backfill-seeder og domenehendelse-handlerne under. - Fire nye domenehendelser, raised i aggregatenes/entitetenes egne metoder:
RegelresultatOpprettet(RegelresultatId, SakId CaseId, DateTimeOffset OccurredAt)— raised iRegelresultat.Create(arver alleredeRaise(...)fraAggregate<RegelresultatId>).BeslutningRegistrert(Guid BeslutningId, string CaseKey, string StageKey, DateTimeOffset OccurredAt)— raised iBeslutning.Ny.StadiegjennomgangEndret(Guid StadiegjennomgangId, string CaseKey, string StageKey, DateTimeOffset OccurredAt)— raised iStadiegjennomgang.Ny.SakStatusEndret(SakId CaseId, SaksnummerId Saksnummer, int GammelStatus, int NyStatus, DateTimeOffset OccurredAt)— raised iSak.OppdaterFraReplika(ikkeAnvendselv), kun nårfelter.Status != Statussammenlignet førAnvendkalles.OppdaterFraReplikakjører kun for eksisterende saker (nye saker går viaFraReplika, som aldri raiser — det finnes ingen tidligere projeksjon å ha blitt stale i forhold til). Løser på domenenivå den samme fallgruven som opprinnelig plan måtte løse i en EF-property-sjekk:AnvendsetterStatus(og stemplerLastSyncedAt) på hver synkede sak hver dag, uansett om status faktisk endret seg — uten denne guarden ville hver daglige synk trigget rekalkulering for hele tabellen.Beslutning/StadiegjennomgangimplementererIHasDomainEventsdirekte (et enkelt interface uten ID-type-binding — de trenger ikke bliAggregate<TId>, kun en liten_domainEvents-liste +Raise/ClearDomainEvents, samme mønster somAggregate<TId>allerede har).RegelresultatogSakarver dette allerede viaAggregate<TId>.- Ingen ny interceptor. Den eksisterende
DomainEventDispatchingInterceptor(src/Enova.Kai.Infrastructure/Persistence/Interceptors/, registrert i sammeKaiDbContextsom all skriving går gjennom) samler alle fire hendelsestypene uendret viaChangeTracker.Entries<IHasDomainEvents>()— den trenger ingen kjennskap til de nye hendelsene. Eneste endring i interceptoren: den dispatcher via en nyIDomainEventDispatcheri stedet for MediatorsIPublisher(se under). - Egeneid dispatch, ikke Mediator. Ny
IDomainEventHandler<TEvent>(Application,Enova.Kai.Application/Common/) — vårt eget interface, ikke MediatorsINotificationHandler<T>. Ett per hendelse:RegelresultatOpprettetHandler,BeslutningRegistrertHandler,StadiegjennomgangEndretHandler,SakStatusEndretHandler(Enova.Kai.Application/Cases/Projection/). Hver slår opp berørtSak(viaCaseIdellerCaseKey) gjennom ny portISakProjeksjonOppdaterer(Application, Infrastructure-adapter), som kjørerSakProjeksjonKalkulatorog skriver de 4 kolonnene i et lite, målrettetSaveChangesAsync. En nyIDomainEventDispatcher/DomainEventDispatcher(Infrastructure, ved siden av interceptoren) ruter hver hendelse til riktigIDomainEventHandler<TEvent>viaIServiceProvider.GetServices<T>()(typerouting løst meddynamic-dispatch inne i dispatcheren — ett sted, ingen sentral switch å huske å oppdatere når en femte hendelse legges til). Alle fire handlere/dispatcheren registreres eksplisitt i DI (services.AddScoped<IDomainEventHandler<X>, XHandler>()per hendelse) — ingen reflection-basert assembly-scanning. - To lag med feilhåndtering, ikke ett. Hver handler fanger og logger egne feil internt (App
Insights-konvensjon) og svelger dem — uten dette ville en feilet rekalkulering kunnet feile hele
den utløsende forespørselen (beslutning/gjennomgang/evaluering/synk), siden verken
DomainEventDispatchingInterceptorellerMediators standardForeachAwaitPublisher(bekreftet: renforeach+await, ingen try/catch) fanger unntak selv.IDomainEventDispatcherlegger i tillegg et sikkerhetsnett rundt hvert handler-kall — fanger og logger som en tydelig markert feil («handler brøt kontrakten om å fange egne feil») i stedet for å la den propagere og stanse dispatch av øvrige ventende hendelser i sammeSaveChangesAsync-batch. Best-effort, ikke transaksjonelt koblet til den utløsende skrivingen — en stale cache retter seg selv ved neste hendelse for saken. DbCaseQueries.ListAsync/SummaryAsyncforenkles til rene SQLWHERE/GROUP BY-spørringer over de nye kolonnene — ingen in-memory full-bøtte-lasting for noen kombinasjon av filtre.- Én-gangs backfill-seeder (samme konvensjon som
RuleConfigurationSeeder), kjører synkront ved oppstart, batcher gjennom saker medNULL-kolonner, idempotent. Migrasjon + domenehendelser + handlere + seeder + omlagt lesevei rulles ut i samme release (seederen blokkerer oppstart til backfill er ferdig).
Ikke-mål⚓︎
- Omdøpe enum-typen
AggregateRuleStatustil et norsk typenavn — kun den nye egenskapen (SamletRegelstatus) påSakfår norsk navn nå; selve enum-typen brukes bredt i regelmotoren og omdøpes eventuelt som egen, senere opprydding. - Periodisk reconciliation-/drift-jobb. Vurdert og bevisst valgt bort: hendelsene dekker alle kjente skrivestier, og enhver avdrift retter seg selv ved neste hendelse for den konkrete saken. Revurderes hvis avdrift faktisk observeres i drift.
- Full aggregate-root-sammenslåing — å la
SakeieRegelresultat/Beslutning/Stadiegjennomgangsom barn-entiteter i én transaksjonsgrense, med énISakRepositorysom laster/lagrer hele grafen. Bevisst utsatt, ikke avvist — dette kan godt være riktig retning for domenemodellen på sikt, og bør i så fall vurderes som et eget initiativ (med egen design-diskusjon om konsistensgrense og konkurransemodell), ikke smettes inn som en bieffekt av denne ytelsesfiksen. Grunnen til å ikke ta det med her:BeslutningogStadiegjennomgangskrives i dag med hver sin optimistiske konkurranse-håndtering, men ikke samme mønster:DbDecisionStore.RecordDecisionAsyncfanger unik-konflikten (Postgres23505) og kasterBeslutningConflictException— ingen retry, klienten må selv prøve på nytt — mensDbStadiegjennomgangStore.SetStatusAsyncløser samme konflikt med en intern retry-på-konflikt-løkke (inntil 3 forsøk).Regelresultatskrives med et enkelt insert uten noen tilsvarende konflikthåndtering (RegelresultatRepository.Add+SaveChangesAsync, ingen versjonering) — alle tre har altså ulikt skrivemønster i dag, så en aggregate-rot som skal dekke alle tre må selv designe én felles konsistens-/konkurransemodell, ikke bare flytte eksisterende kode. Det er et reelt design-spørsmål (kontensjon dersom hver append-only-skriving må laste/låse heleSak-grafen, fare for en «God aggregate» hvis grensa trekkes feil) som fortjener egen tid, ikke et argument mot at det er en dårlig idé. Domenehendelsene over gir den eksplisitte, oppdagbare koblingen denne PR-en trenger nå, uten å forhåndsbestemme svaret på det større spørsmålet. - Endre
GjennomgåttAv,ReviewedAt,UnderArbeid,PipelineGittOppeller andre rene visningsfelt — disse er ikke brukt iWHERE/GROUP BYog forblir side-skalerte oppslag iProjectAsync. - Forenkling av
GetByKeyAsynctil å lese de nye kolonnene er valgfri, ikke del av denne beslutningen.
Konsekvenser⚓︎
GET /casesogGET /cases/summaryblir O(sidestørrelse) / O(indeksert aggregering) i stedet for O(bøtte-/stadiestørrelse) — fjerner rotårsaken til observert 3-11 s responstid.- Koblingen mellom skriving og projeksjonsoppdatering blir synlig i koden man faktisk leser:
Raise(new RegelresultatOpprettet(...))i aggregatets egen metode, ogIDomainEventHandler<RegelresultatOpprettet>som en navngitt, grep-bar, eksplisitt DI-registrert klasse — i motsetning til en generisk interceptor som inspisererChangeTrackerfor urelaterte typer. Enova.Kai.Domainmister sin eneste Mediator-referanse (Mediator.Abstractions) —DomainEventslutter å implementereINotification. Domenelaget er dermed fritt for infrastruktur-/rammeverksavhengigheter igjen, i tråd med det uttalte prinsippet ("no infra deps").ICommandHandler/ISenderi Application-laget (regelmotorens kommando-dispatch) er urørt og fortsatt på Mediator — kun hendelsessiden flyttes.- Ny, liten infrastrukturflate å vedlikeholde:
IDomainEventHandler<T>+IDomainEventDispatcherer kode teamet selv eier og må forstå (typerouting viadynamic, sikkerhetsnett-try/catch) i stedet for å lene seg på et bibliotek. Motvekt: overflaten er reelt liten (én dispatcher-klasse, ingen pipeline/behaviors), og fjerner en avhengighet fremfor å legge til en. - Ny vedlikeholdsflate, nå på domenenivå i stedet for infrastrukturnivå: enhver ny kode som
oppretter en
Regelresultat/Beslutning/Stadiegjennomgangutenom deres egne fabrikkmetoder (Regelresultat.Create,Beslutning.Ny,Stadiegjennomgang.Ny), eller som mutererSak.StatusutenomSak.OppdaterFraReplika, raiser ingen hendelse og blir ikke fanget opp. I dag er disse fabrikkmetodene allerede eneste inngang (ingen annen kode konstruerer disse entitetene direkte), så risikoen er lav, men verdt å nevne som en invariant å bevare. - Inkonsistens-vinduet er kortvarig (sub-forespørsel) for de tre entitetshendelsene
(
RegelresultatOpprettet/BeslutningRegistrert/StadiegjennomgangEndret): rekalkulering skjer i et eget, påfølgendeSaveChangesAsyncetter den utløsende commiten, innenfor samme forespørsel — ikke atomisk med den, men samme post-commit-timing somDomainEventDispatchingInterceptorallerede bruker for alt annet den dispatcher. For en ferskSakfra Mimir-synkroniseringen er vinduet derimot ikke sub-forespørsel:FraReplikaraiser ingen hendelse (se over), så den faktiske evalueringen ikke er reflektert før Workerens separate, time-syklede discovery+evaluate-pipeline faktisk behandler saken — realistisk fra minutter til rundt en time senere. Fast-follow (denne branchen):Sak.FraReplikaseeder nå samme «ingen evaluering ennå»-default somSamlestatuspolicy.Utled([])selv ville gitt (Krever gjennomgang/Undetermined) i stedet for å la kolonnene stånull, så en fersk sak i det minste er synlig under riktig standard-fane med én gang — men avviker den faktiske evalueringen fra defaulten (f.eks. saken viser seg å kunne godkjennes direkte), er det ikke reflektert før evalueringen er ferdig. Begge vinduene er vurdert akseptable — systemet har allerede et større asynkront gap i dag (re-evaluering etter en beslutning køes fire-and-forget til worker). - Filtrering/telling (
ListAsync/SummaryAsync) og visning (ProjectAsync) er nå to atskilte beregninger i stedet for én, og kan i sjeldne, forbigående tilfeller derfor vise ulik klassifisering for samme sak:ListAsync/SummaryAsyncfiltrerer/teller på de persisterte projeksjonskolonnene, mensProjectAsyncfortsatt rekalkulererBehandlingsform/SamletRegelstatusosv. live, per forespørsel, fra de samme underliggende kilderadene (evalueringer/beslutninger/gjennomganger), for å bygge selve visningsdataene for siden. Under normal drift er de enige (samme input, samme logikk), men to hendelser kan skille dem en kort stund: (a) en handler som fanger og logger sin egen feil i stedet for å propagere den (bevisst valgt, se over) etterlater den persisterte kolonnen stale mens «ville-vært»-live-beregningen allerede har flyttet seg videre; (b) en fremtidig regelendring iRisikopolicy/Samlestatuspolicyendrer hva live-beregningen ville gitt, mensSakProjeksjonBackfillSeederkun treffer rader medSamletRegelstatus IS NULLog aldri regner om allerede tilbakefylte rader (kunBulkReevaluationJob, en egen eksisterende mekanisme, trigger det). I begge vinduene kan en sak vises i én filtrert fane (styrt av den persisterte kolonnen) mens dens egen detaljvisning/ badge (styrt av live-rekalkuleringen) viser en annen klassifisering — strukturelt umulig før denne ADR-en, siden én og samme beregning drev begge. I tråd med prinsippet over («retter seg selv ved neste hendelse») er dette ikke en ny korrekthetsrisiko som krever en reconciliation-jobb — bare en dokumentasjonsluke: denne konkrete avdriftsformen var ikke tidligere nevnt. - Fire nye kolonner + én ny indeks på
saker; én engangs-backfill ved neste oppstart (kan øke oppstartstid — radantall i test/prod ikke verifisert ved skrivetidspunkt, sjekk før prod-utrulling). - Forkastet: periodisk materialized-view/batch-refresh (introduserer synlig staleness som ikke
passer UX-en der en saksbehandlers handling — beslutning/gjennomgang-markering — forventes
reflektert umiddelbart); købasert asynkron rekalkulering via TickerQ (unødvendig kompleksitet for
en beregning som allerede er billig og ren per sak); atomisk in-transaction-rekalkulering i
SavingChangesAsync(krever å lese allerede committerte relaterte rader midt i en pågåendeSaveChanges, mer skjørt mot EFs lokale/lager-spørringsnyanser enn en enkel post-commit-oppfølger); en generiskSaveChangesInterceptorsom inspisererChangeTrackerdirekte (opprinnelig plan i denne ADR-en — erstattet av domenehendelser etter PR-tilbakemelding, se Kontekst). Full aggregate-root-sammenslåing er ikke i denne listen — den er bevisst utsatt, ikke forkastet; se Ikke-mål.
Verifisering⚓︎
- [x] Migrasjon legger til
behandlingsform,samlet_regelstatus,har_noeytralt_utfall,gjennomgangsstatus(enum-backetinteger, tre tilstander — se oppdatering over) påsaker- indeks
IX_saker_status_behandlingsform_gjennomgangsstatuspå(status, behandlingsform, gjennomgangsstatus)— dekket avMigrationGuardTests(Architecture.Tests).
- indeks
- [x]
SakProjeksjonKalkulatorer en ren, testet funksjon brukt av både seeder ogIDomainEventHandler<T>-implementasjonene. - [x] De fire domenehendelsene raises korrekt:
RegelresultatOpprettetfraRegelresultat.Create,BeslutningRegistrertfraBeslutning.Ny,StadiegjennomgangEndretfraStadiegjennomgang.Ny,SakStatusEndretfraSak.OppdaterFraReplika— dekket av domenelag-enhetstester.FraReplika(ny sak) raiser aldriSakStatusEndret. - [x]
Beslutning/StadiegjennomgangimplementererIHasDomainEvents; eksisterendeDomainEventDispatchingInterceptordispatcher dem uendret (ingen ny interceptor lagt til) — via nyIDomainEventDispatcher, ikke MediatorsIPublisher. - [x]
DomainEventimplementerer ikke lengerINotification;Enova.Kai.Domain.csprojhar ingen Mediator-pakkereferanse — dekket avLayerDependencyTests(Architecture.Tests). - [x]
SakStatusEndretraises kun nårStatusfaktisk endres — regresjonstest som simulerer en full daglig synk uten statusendring og bekrefter at ingen hendelse raises / ingen rekalkulering skjer. - [x]
IDomainEventHandler<T>for hver av de fire hendelsene rekalkulerer korrekt og skriver kolonnene — integrasjonstester mot ekte Postgres (Testcontainers), inkludertSak.Status-endring på tvers av stadiegrense (Søknad → Gjennomføring). - [x] Handler-feil logges og svelges internt — feiler ikke den utløsende forespørselen. I tillegg:
IDomainEventDispatchers eget sikkerhetsnett fanger en handler som IKKE fanger sin egen feil, logger det som avvik, og fortsetter å dispatche øvrige ventende hendelser i samme batch (regresjonstest: to hendelser i énSaveChangesAsync, første handler kaster — andre hendelsen skal likevel dispatches). - [x]
DbCaseQueries.ListAsync/SummaryAsynchar ingen gjenværendeToListAsync-før-Skip/Take-gren for behandlingsform/regelstatus. - [x] Backfill-seeder er idempotent (trygg å kjøre flere ganger) og kjører til fullført før appen tar imot trafikk.
- [ ] De 5 opprinnelig trege kallene er re-kjørt mot test og viser vesentlig lavere responstid.
Ikke verifisert ennå: krever brukerens egen tilgang til et faktisk deployet testmiljø med
realistiske datavolum — sporet separat som Task 16 steg 4 i
docs/superpowers/plans/2026-09-07-sak-projeksjonskolonner.md. - [x]
dotnet build Kai.slnxgrønt (0 warnings), og full testsuite (~2262 tester) grønn, inkludert arkitektur-lagavhengighetstestene ogMigrationGuardTests.
Bygger på ADR-011, ADR-015,
ADR-016 (nærmeste eksisterende analogi — daglig batch-replika,
annen mekanisme enn hendelsesdrevet inkrementell oppdatering) og
ADR-019. Design:
docs/superpowers/specs/2026-09-04-sak-projeksjonskolonner-design.md.