NordBytes: To måneder med at bygge et ERP-system fra bunden

NordBytes er i aktiv alpha-udvikling. Systemet kører og er tilgængeligt, men er ikke klar til produktionsbrug. Hvis du er stødt på dette projekt og er nysgerrig - eller arbejder i et konsulenthus der kæmper med præcis de problemer beskrevet her - er du velkommen til at række ud. Vi tager ikke imod betalende kunder endnu, men vi tager gerne en snak om hvad der mangler før vi gør.


Jeg har brugt de sidste to måneder på at bygge NordBytes. Det er et multi-tenant SaaS ERP-system rettet mod skandinaviske SMV'er - den slags firmaer der i dag kæmper med en blanding af regneark, e2invoice og Dinero. Jeg skriver dette nu fordi projektet er nået et naturligt vejskifte: vi har en fungerende kerne, men vi er langt fra færdige. Og ærlighed kræver at man skriver om begge dele.

Dette er ikke en triumfblog. Det er en teknisk statusopdatering midt i et projekt der stadig er i gang.


Det startede med dokumentation

Inden der blev skrevet en eneste linje kode var der en mappe. NordBytes-Forretning/ indeholder en fuld forretningsplan dateret 24. januar 2026: markedsanalyse, to kundesegmenter (konsulenthuse og freelancere), tre pricing-tiers (SOLO 149 kr/md, TEAM 299 kr/bruger/md, BUSINESS 399 kr/bruger/md), break-even-analyse, exit-strategi og en arkitekturbeskrivelse der allerede specificerede RabbitMQ som kommunikationsbus.

Der var wireframes. Der var et glossar. Der var et MoSCoW-prioriteringsdokument over features.

Det er ikke en detalje - det er grunden til at kodebasen hænger sammen. Arkitekturbeslutningerne var truffet på papir før de blev implementeret i Python. Når man læser om fejlene nedenfor - in-memory event router, threading vs. multiprocessing, schema-drift - er det værd at have i baghovedet: de fejl opstod ikke fordi vi ikke havde tænkt os om. De opstod fordi implementering altid afslører ting dokumentation ikke kan forudsige.

Kerneidentiteten fra dag 1 holdt: "Alt det vi alligevel gør - bare samlet og uden bullshit." Det er stadig den rigtige beskrivelse.


Kontekst: Hvad er NordBytes?

NordBytes er et fuldt multi-tenant SaaS-system med følgende moduler i varierende grad af færdiggørelse:

  • CRM - unified Actor-model: kunder, leverandører og leads som én entitet. Kommunikationsfeed der samler events på tværs af fakturaer, projekter og kontrakter tilknyttet en aktør.
  • Fakturering - fuld faktura-livscyklus (draft → sent → paid/overdue), kreditnoter med korrekt negation, leverandørfakturaer, EAN-support og automatisk total-beregning fra linjer. Faktura-beskeder (intern kommunikation på fakturaen).
  • Projekter - Kanban-board, milepæle med budgettering, opgavehåndtering med tilknytning til kunder og kontrakter. Projekt-tildelinger.
  • Tidsregistrering - timeregistrering mod projekter og opgaver, med fakturerbarhed.
  • Planlægning - ressourcekapacitet og allokering på tværs af projekter.
  • Udgifter - udgiftsregistrering med bilag.
  • Tilbud - tilbudsmodul med kontraktgenerering.
  • Kontrakter - kontraktlivscyklus tilknyttet kunder og projekter. Kontraktskabeloner.
  • Abonnementer - løbende aftaler med automatisk fakturering.
  • Notifikationer + aktivitetsfeed - realtids event-stream på tværs af alle moduler.
  • Dashboard - overblik over nøgletal på tværs af moduler.
  • Produkter + produktkategorier - produktkatalog med kategorisering.
  • Indkøbsordrer - purchase orders mod leverandører.
  • Salgsordrer - sale orders workflow.
  • Møder - møderegistrering tilknyttet aktører og projekter.
  • Opgaver - task management på tværs af projekter.
  • Dunning - rykkerhåndtering for forfaldne fakturaer.
  • Bankkonti - bankkontostyring pr. tenant.
  • Lager - inventory management (under udvikling).
  • Rapportering - rapporteringsmodul (skelet).
  • Support tickets - ticket-system (under udvikling).
  • Revisionslog - audit trail på tværs af alle moduler.
  • Indstillinger - tenant-konfiguration, betalingsbetingelser, brugerpræferencer.

Stack: Next.js 15 (TypeScript, Tailwind, shadcn/ui) som frontend, FastAPI + Python 3.12 som backend, RabbitMQ som kommunikationsbus, PostgreSQL 15 med Row Level Security som database.

Tal fra git-historikken per 12. april 2026: - 917 commits over ~77 dage - ~40.400 linjer Python (backend) - ~50.800 linjer TypeScript/TSX (frontend)

Testdækning i tal

Testniveau Antal
BDD feature-filer (Behave over RabbitMQ) 23
BDD scenarier 352
BDD step-definitioner 206
pytest-moduler 15
pytest-testfunktioner 153
Playwright E2E-specs 27
Playwright persona-tests 6 (Anne Marie, Lars, Mette, Peter, Sofie, Thomas)
Automatiseringsscripts 15+

I alt: ~530 BDD + pytest scenarier på tværs af to backend-lag, plus 27 Playwright-specs der tester UI og brugerflows — foruden scripts der automatisk genererer testdata, kører state machine-workflows, bencher throughput og eksporterer schemas fra RabbitMQ.

En ting der er værd at nævne: vi har dedikerede bulk-benchmarks der kører 8.000 operationer per scenarie mod en live stack (RabbitMQ + PostgreSQL + Python workers). Ikke 100. Ikke 500. 8.000 per modul - og 0 fejl. Med 14 scenarier svarer det til over 112.000 operationer per testkørsel. Kunder, projekter, fakturaer (oprettelse + finalisering separat), tilbud, udgifter, tidsregistreringer, kontrakter, planlægning, brugere, tenants og møder kører alle fulde bulk-suiter med individuelle P95-latenstærskler.

Det mixed-scenarie - 8.000 operationer fordelt på kunder, projekter og fakturaer kørende parallelt - gennemfører konsistent uden tabte beskeder, deadlocks eller transaction rollbacks. Det er ikke selvfølgeligt når man har en async Python worker-pool, en RabbitMQ-bus og PostgreSQL Row Level Security i stakken.

Det kører som en del af make pre-merge. Man kan ikke merge kode der får benchmarks til at fejle.


Hvor meget kan det egentlig håndtere?

En sjov konsekvens af at have præcise benchmark-tal er at man kan lave et rimeligt kapacitetsestimat.

Fakturering er det langsomste modul i mixed real-world brug - det er også det mest komplekse: counter-tildeling, linjeoprettelse, total-beregning, snapshot af kunde-data. Efter weekendens performance-arbejde kører det nu stabilt ved 659 ops/sec for oprettelse og 1.564 ops/sec for finalisering (var henholdsvis 276 og 395).

En aktiv ERP-bruger laver måske 1-2 write-operationer per minut i reel brug. Det giver:

659 ops/sec × 60 sek = 39.540 faktura-oprettelser per minut
÷ 2 operationer per bruger per minut
= ~19.770 samtidige aktive brugere

Der er ca. 250.000 SMV'er i Danmark. Hvis 1% af dem brugte NordBytes simultant med 5 brugere per virksomhed er det 12.500 samtidige brugere.

Vi er med god margen over det — på en hjemmeserver, i en VM, uden en eneste cloud-tjeneste involveret.

Ingen AWS. Ingen Azure. Ingen managed database. Nomad som orkestrator, Gitea som CI, egne PostgreSQL- og RabbitMQ-instanser på egen hardware. Hvis vi skulle skalere horisontalt er det et spørgsmål om at tilføje flere worker-instanser og en read replica - ikke en arkitekturændring.

Det er ikke et tal vi designede efter. Det er et tal vi opdagede ved at måle.

Hvad det ikke siger: benchmarks tester ikke read-heavy load (dashboards, lister), WebSocket-forbindelser, eller burst-trafik på tværs af alle moduler på én gang. Det reelle tal er sandsynligvis lavere under mixed real-world load. Men "skalerer til et fuldt dansk SME-marked på én server" er ikke en overdrivelse baseret på hvad vi faktisk har målt.


Arkitekturen - og hvorfor vi valgte den

Den centrale beslutning var at al domænelogik kommunikerer via RabbitMQ, ikke direkte HTTP til backend. Det ser sådan ud:

Next.js (BFF-lag via API routes)
    ↓ REST
FastAPI (modtager kommandoer, sender til RabbitMQ)
    ↓ aio-pika (async AMQP)
RabbitMQ (topic exchange, én kø per modul: commands.<module>)
    ↓
Module Workers (Python multiprocessing, én worker-manager per instans)
    ↓
PostgreSQL 15 (multi-tenant via RLS + tenant_id kolonne på alle tabeller)

Tanken er at frontenden ikke taler direkte med databasen, og at modulerne er isolerede. Et invoicing.create-kald er en besked til RabbitMQ - ikke en HTTP-request til et endpoint der direkte udfører SQL.

Var det den rigtige beslutning? Til dels. Det har givet os ægte modul-isolering, og det gør det trivielt at skalere workers uafhængigt. Men det har også kostet os noget vi undervurderede: latency. En simpel oprettelse af en faktura er mindst 3-4 hops: HTTP → FastAPI → RabbitMQ → Worker → DB → Result-kø → HTTP-respons. Under normal load er det usynligt. Under benchmarks - mere om det nedenfor - kan det brænde.

Multi-tenancy via RLS

Alle tabeller har en tenant_id-kolonne. PostgreSQL Row Level Security sørger for at en query aldrig kan returnere data fra en anden tenant - selv hvis der er en fejl i applikationskoden. Det er en stærk garanti, men det kræver at man aldrig glemmer at sætte SET LOCAL app.tenant_id = ... i sessionen.

Vi har en TenantMixin på alle SQLAlchemy-modeller og en EventContext-dataclass der bærer tenant_id og user_id igennem alle lag. Det fungerer. Det er ikke elegant - det er "pass det overalt" - men det er præcist.


Hvad der gik rigtig galt i starten

Anti-pattern: In-memory event router

Det første store arkitektur-fejl vi ryddede op i var en event_router.py - et in-memory pub/sub-system der skulle sende events fra workers til WebSocket-handlers.

Problemet: Workers kører som separate processer via Python multiprocessing. FastAPI kører i main-processen. In-memory objekter deles ikke på tværs af processer. Alle 40+ event_router.publish()-kald var stille no-ops. Ingen fejl. Ingen warnings. Bare ingenting der skete.

Det tog et stykke tid at finde fordi koden korrekt ud. Vi slettede ~1.737 linjer og erstattede det med ren RabbitMQ-kommunikation. Lektionen er enkel: cross-process kommunikation kræver IPC - ikke delte objekter.

Threading vs. multiprocessing

Den originale Worker-infrastruktur brugte threading. Det fungerede på overfladen, men GIL'en betød at vi reelt ikke fik parallelisme. Vi skiftede til multiprocessing + RabbitMQ som IPC-lag. Det medførte at vi ikke mere kunne dele database-sessions, caches eller noget andet på tværs af workers. Alt skal serialiseres over køen.

Schema-drift

Tidligt i projektet var der flere tilfælde af modeller der havde felter som ikke fandtes i databasen - eller omvendt. Vi fandt det ved at fejl som column "billing_amount" of relation "milestones" does not exist dukkede op i produktion. Alembic-migrationer var ikke synkroniserede.

Løsningen var at køre alembic check som en del af vores pre-merge pipeline. Men det burde have været der fra dag 1. Den slags fejl bør aldrig nå en kørende instans.


Pydantic-performance og validering

Vi bruger Pydantic v2 til at validere al input til @command-dekorerede metoder. Det er fedt på papiret - men Pydantic v2's model-validering har en real cost, og vi mærkede den under benchmark-kørslerne.

Vi brugte mode="before" validators som standard, selvom mode="after" er hurtigere i mange tilfælde. Vi brugte også field_validator på UUID-felter overalt, selvom Pydantic v2 allerede validerer UUIDs nativt. Da vi fjernede de redundante validatorer, faldt overhead mærkbart.

Det mere fundamentale problem er at vi satte for aggressive benchmark-tærskler tidligt - 300 ops/sec P95 under 2000ms - og brugte uforholdsmæssig tid på at justere dem i stedet for at løse rodårsagerne. Benchmarks er et signal, ikke et mål.


Actor-konsolideringen: at simplificere et datamodel

En af de store strukturelle beslutninger var at slå Customer-, Vendor- og Actor-modellerne sammen til én. Vi startede med tre separate tabeller: actors, customers, og vendor_profiles. Det betød at man skulle slå op i 2-3 tabeller for at forstå hvem en forretningspartner var.

Løsningen var at flytte alt ind i actors med is_customer: bool og is_vendor: bool flag.

  1. Tilføj nye kolonner til actors (is_customer, is_vendor, customer_number, vendor_number, customer_status, default_payment_terms, default_hourly_rate, is_vat_registered, vat_number, bank_details, custom_fields)
  2. Migrer data fra customers og vendor_profilesactors
  3. Drop begge gamle tabeller

Det er en irreversibel migration. Vi lavede en fuld downgrade() i Alembic alligevel - den genopliver begge tabeller og kopierer data tilbage. Det er god praksis selvom vi aldrig forventer at bruge den.

Hvad lærte vi? Model-simplificering bør ske tidligt, inden for mange steder i kodebasen afhænger af den komplekse model. Vi slæbte på tre modeller i for lang tid.


Test-strategien: Tre lag i praksis

Vi har tre slags tests, og de finder tre fundamentalt forskellige typer fejl:

BDD (Behave) - adfærdstests over RabbitMQ. De tester at en kommando som invoicing.create faktisk opretter en faktura med korrekte data i databasen. De er langsomme (~2-3 min), men de er den eneste test der validerer hele stakken end-to-end på backend-siden. De finder schema-drift, manglende validering, og broken state machines.

pytest (integration) - unit/integration tests på controller-niveau med en rigtig test-database. De er hurtige og præcise. De finder regressions i forretningslogik. Det vigtige: de kører mod en separat test-database (TEST_DATABASE_URL) - ikke dev-databasen. Det lærte vi den hårde vej: test-data forurener dev-miljøet, og count-assertions fejler tilfældigt.

Playwright (E2E) - browser-tests mod en kørende instans. Vi endte med 25 specs der dækker 6 user personas: Anne Marie (bogholderske), Lars (projektleder), Mette (CEO), Peter (konsulent), Sofie (salgsleder), Thomas (administrator). Disse tests fandt UI-fejl der ingen af de andre tests opdagede: fejlagtige redirects, formularer der postede til forkerte endpoints, manglende loading-states.

Playwright-testsene er de mest fragile. En ændring i et CSS class-navn eller en testid kan bryde 10 tests på én gang. Vi har ikke løst dette fundamentalt - vi har bare accepteret at de kræver vedligeholdelse.

Hvad vi ikke har: En test der kører alembic check automatisk. Det er en banalitet at tilføje, og vi burde have gjort det.


Faktura-validering: en fejl der gik for langt

Et konkret eksempel på utilstrækkelig validering: det var muligt at oprette en faktura på 76.737,73 kr. uden en eneste fakturalinje. total_amount var et fritekst-felt man bare sendte med. _compute_totals i controlleren havde eksplicit en kommentar:

# If no line items are provided, existing values in data are preserved
# (allows creating invoices/credit notes with pre-set totals).

Det er en bevidst fejl. Logikken var lavet for at gøre det nemmere at seede testdata. Men resultatet er at en faktura kan have en total der ikke afspejler dens linjer.

Løsningen er tre lags håndhævelse:

  1. Schema-niveau: line_items: list[InvoiceLineCreate] = Field(min_length=1) - Pydantic afviser requests uden linjer
  2. Controller-niveau: Eksplicit check i create() og bulk_create() - ValidationError hvis items er tom
  3. DB-reconciliation: _recompute_totals_from_db() læser de faktiske InvoiceItem-rækker og overskriver totals - manuelt angivne totals strippes altid

Der er nu også en invoicing.recompute_totals-kommando der kan bruges til at healere fakturaer der er oprettet med forkerte totals.


Hvad der stadig mangler

Det er her ærlighed er vigtig. NordBytes er ikke et færdigt produkt. Her er en ærlig opgørelse:

RBAC/Autorisering er delvis. Vi har en check_permission(role, permission)-funktion og roller i databasen. Men rettigheder valideres ikke konsekvent i alle moduler. En bruger med "viewer"-rollen kan potentielt kalde write-kommandoer hvis de rammer de rigtige RabbitMQ-beskeder direkte.

Tilbudsmodulet mangler næsten alt. Vi har datamodellen og CRUD-operationerne, men ingen fuld forretningslogik: ingen produktliste-integration, ingen kontraktgenerering fra tilbud, ingen PDF-eksport.

Udgifter mangler actor-reference. Man kan oprette en udgift men ikke tilknytte den til en kunde, et projekt eller en leverandør. Det er en betydelig mangel - uden det kan man ikke spore udgifter meningsfuldt.

Planlægningsmodulet har en null-constraint-fejl ved oprettelse af ressourcekapaciteter. Det er fejlrapporteret men ikke rettet.

Milepæle mangler billing_amount-kolonnen i databasen selvom modellen forventer den. Dette er præcis den type schema-drift vi burde have fanget tidligere.

Ingen PDF-generering i produktion. Fakturaer kan eksporteres som JSON, men ikke som PDF med korrekt dansk layout og EAN-support.

Rapporteringsmodulet eksisterer kun som skelet. Der er en placeholder, men ingen reelle rapporter.

Kundeportalen er scaffolded men ikke funktionel. Vi har oprettet routes og komponenter til en ekstern kundeportal (magic link login, faktura­visning, projektoversigt), men backend-integration mangler.


Hvad der faktisk fungerer godt

Det er ikke alt der er halvfærdigt. Kernen af systemet er solid:

Faktureringsmodulet er det mest polerede. Det håndterer faktura-livscyklus korrekt (draft → sent → paid/overdue), kreditt­noter med korrekt negation, leverandørfakturaer, automatisk beregning af totals fra linjer, og EAN-nummer-support. Bogføringsloven-compliance er ikke komplet, men grundlaget er rigtigt.

CRM/Actor-modulet er nu simpelt og korrekt. Én model for alle forretningspartnere. is_customer, is_vendor, actor_type (organization/person/system). Kommunikationsfeed der viser events på tværs af fakturaer, projekter og kontrakter tilknyttet en actor.

Worker-infrastrukturen er stabil. BaseModuleWorker, GenericCRUDController, @command-dekoratoren og EventContext er gennemtænkte abstraktioner. At tilføje et nyt modul er nu en forudsigelig øvelse: definer model, definer controller med @command-metoder, registrer worker.

Multi-tenancy er korrekt implementeret. RLS fungerer. Tenant-kontekst propageres korrekt igennem alle lag. Det er testet i Playwright-testsene.

Test-infrastrukturen er moden. make pre-merge kører BDD + benchmarks + Playwright i rækkefølge. Det tager ~8 minutter og fanger det meste. 153 pytest-funktioner passerer, 27 Playwright specs passerer.


Den arkitektoniske ting jeg fortryder mest

Vi valgte at lade Next.js API routes agere som BFF (Backend For Frontend). Det vil sige at al kommunikation fra frontend går igennem /app/api/**/*.ts filer der laver fetch-kald til FastAPI.

Fordelen: vi kan transformere data, håndtere auth-cookies, og skjule backend-URL'er fra browseren.

Ulempen: vi har nu to lag af fejlhåndtering, to lag af validering og to lag af serialisering for hvert endpoint. Når noget fejler, skal man finde ud af om fejlen er i Next.js-laget eller FastAPI-laget. Det er ikke et stort problem nu, men det vokser eksponentielt med systemets størrelse.

Alternativet - direkte calls fra React til FastAPI med CORS - er enklere, men eksponerer backend. Jeg er ikke sikker på vi valgte rigtigt.


RabbitMQ som kommunikationsbus: den reelle cost

RabbitMQ giver os ægte modul-isolering og er nem at skalere. Men den reelle pris er developer experience.

At debugge en fejl kræver at man checker: (1) HTTP-response fra Next.js, (2) FastAPI-log, (3) RabbitMQ-beskeden, (4) Worker-log. Fejlbeskeder kan gå tabt hvis en worker crasher uden at ACK beskederne. Vi har job_tracker til at tracke asynkrone jobs, men det kræver at man aktivt poller for status.

For et team af én (eller to) er dette en ikke-triviel overhead. Et simpelt REST API med direkte DB-adgang ville have givet os 80% af funktionaliteten med 40% af kompleksiteten. RabbitMQ giver mening hvis vi skalerer til separate micro-services. Vi er ikke der endnu.


Hvad sker der fremover

Det er et åbent spørgsmål. De mest presserende ting er:

  1. RBAC - rettighedstjek i alle moduler, ikke bare nogle
  2. Udgifter - actor-reference, bilagsupload
  3. Tilbud - produktintegration, kontraktgenerering
  4. Milepæle - schema-fix, test der fanger det automatisk
  5. PDF - faktura-eksport til dansk-formateret PDF
  6. Rapportering - mindst én brugbar rapport

Det der ikke sker i første omgang er en landing-page-drevet launch. NordBytes er ikke klar til rigtige kunder endnu. Det vidste vi da vi startede, og det gælder stadig.


En note om AI-assisteret udvikling

Copilot (Claude Sonnet) har fungeret som sparringspartner - primært når vi ledte efter optimeringer eller alternative tilgange. Det er relevant at nævne - og det er værd at være præcis om hvad det betyder i praksis.

Det er ikke "vibe coding". Jeg har ikke promptet mig til en kodebase jeg ikke forstår. Hver arkitektonisk beslutning i dette dokument - RabbitMQ som bus, multiprocessing over threading, RLS i PostgreSQL, Actor-konsolideringen - er truffet af mig, ikke af en AI.

Forskellen er vigtig. AI er ekstremt god til at skrive kode til en beslutning der allerede er truffet. Den er dårlig til at træffe beslutningerne selv - ikke fordi den ikke kan generere et svar, men fordi den genererer et svar der lyder rigtigt uden nødvendigvis at være det. Den in-memory event router er det bedste eksempel: teknisk korrekt Python, arkitektonisk fuldstændig forkert. Den fejl kræver domænekendskab at opdage - ikke kodekendskab.

Der er dog én tilbagevendende frustration: når man forsøger noget der afviger fra mainstream-tilgangen, vil AI aktivt trække i retning af "det normale". Det klassiske eksempel i dette projekt er RabbitMQ som kommunikationsbus. I stedet for at implementere en besked til køen ville AI gentagne gange foreslå et REST-endpoint som "den enklere løsning". Det kræver eksplicit og vedvarende instruktion at holde arkitekturen på sporet - og det er en friktion man ikke forventer af et udviklingsværktøj. AI optimerer for det mest sandsynlige, ikke det mest rigtige.

AI er også dårlig til at huske på tværs af sessioner. Hvert nyt Copilot-workspace starter fra nul. Svaret har været en levende arkitekturdokumentation - ufravigelige principper og regler der dækker alt fra modulgrænser og import-boundaries til testkonventioner og forretningslogik. Den er skrevet til at kunne loades i starten af en ny session og give AI'en fuld kontekst med det samme - via vores resume_copilot-setup, opdelt på frontend og backend. Det tvinger os også til at artikulere beslutningerne præcist og skriftligt. Det er faktisk en utilsigtet fordel: kodebasen er bedre dokumenteret end den ville have været uden AI.

Konklusionen: AI-assisteret udvikling skalerer godt hvis du ved hvad du vil have. Det er et værktøj der forstærker din arkitektoniske intuition - ikke erstatter den. Har du ikke den intuition, får du hurtigere produceret noget der ikke holder.


Konklusion

NordBytes er et ambitiøst projekt i en tidlig men funktionel tilstand. Kernearkitekturen er solid. Nogle moduler er produktionsklare. Andre er skelet. Der er kendte fejl vi ikke har rettet. Der er designvalg vi ville have truffet anderledes.

Det er en ærlig beskrivelse af to måneder med intensiv softwareudvikling.

Næste opdatering når der er noget konkret nyt at berette.


Opdatering: 12. april 2026 — Weekenden hvor invoicing fik en ordentlig omgang

Siden den første version af denne artikel er der sket noget konkret nok til at det fortjener en sektion for sig selv.

Vi har haft en weekend med intensiv performance-arbejde på faktureringsmodulet. Udgangspunktet var benchmark-tallene der konsekvent viste invoicing som det langsomste modul — 276 ops/sec for faktura-oprettelse og 395 ops/sec for finalisering, mens alle andre moduler lå på 900-1400 ops/sec. Det var ikke tilfældigt. Det var tre arkitektoniske problemer der summerede sig op.

Problem 1: N×db.refresh() i bulk_create

GenericCRUDController.bulk_create() kaldte db.refresh(entity) på hvert objekt efter INSERT — en SELECT-per-række for at hente genererede felter (id, created_at osv.). Med en batch på 500 fakturaer var det 500 separate database-ture efter selve INSERT-batchen. Løsningen: én SELECT WHERE id IN (...) efter bulk-INSERT. Én tur, uanset batch-størrelse.

Problem 2: Tax-submodule fan-out på alle oprettelser

Vores submodule-arkitektur lader workers kalde hinanden: InvoicingWorker sender til TaxWorker ved oprettelse for at beregne moms. I sig selv fornuftigt — men call_submodules_bulk() er altid synkron: den sender 500 beskeder til tax.create-køen, opretter en temp-callback-kø, og venter på alle 500 svar. TaxController.create() er ren beregning — ingen database — men RabbitMQ-round-trippen kostede alligevel ~1.5 sekunder per batch af 500.

Løsningen var at indse at alle faktura-oprettelser altid er drafts. InvoiceController.create() tvinger status = "draft" uanset hvad der sendes ind. Skatteberegning er ikke relevant for en draft — den skal ske ved finalisering. Vi tilføjede en should_skip_submodules_for_operation()-hook til BaseModuleWorker som InvoicingWorker overskriver: spring tax-fan-out over hvis entity.status == "draft". Tax-data beregnes stadig korrekt — bare på det rigtige tidspunkt.

Problem 3: Ingen batch-handler for finalize

invoicing.finalize-kommandoen havde ingen batch-handler registreret. Det betød at 8000 finaliserings-beskeder håndterades én ad gangen via process_message — 8000 individuelle UPDATE ... SET finalized_at = now() WHERE id = ?-kald med COMMIT per styk.

Løsningen: InvoiceController.bulk_finalize() med én enkelt UPDATE WHERE id IN (...) AND finalized_at IS NULL RETURNING *. Registreret som batch-handler for invoicing.finalize i register_batch_handlers(). Én SQL-sætning per batch, uanset størrelse.

Resultatet

Scenarie Før Efter Delta
Faktura-oprettelse (8000 ops) 276 ops/sec 659 ops/sec +139%
Faktura-finalisering (8000 ops) 395 ops/sec 1564 ops/sec +296%

Invoicing gik fra det langsomste modul til midt i feltet — og finalisering er nu det hurtigste scenarie i hele benchmark-suiten.

En asyncio task-lækage vi ikke vidste om

Under benchmarks dukkede der konsekvent ERROR:asyncio:Task was destroyed but it is pending!-warnings op efter testkørsler. Det var ikke en funktionel fejl — tests passerede — men det var et tegn på ressource-lækage.

Rodårsagen: i vores poll-loop i AsyncRabbitMQClient.send_command() kaldte vi asyncio.shield(job._event.wait()) i hvert loop-iteration for at vente op til 50ms. shield(coroutine) opretter en ny Task internt hver gang den kaldes — men Tasks kræver eksplicit cancellation for at blive ryddet op. Med 8000 concurrent jobs × N iterationer per job = titusindvis af Tasks der aldrig blev cancelled.

Fix: opret wait-task'en én gang med asyncio.ensure_future() uden for loop'en, genbrugt den med shield(wait_task) i iterationerne, og cancel den i en finally-blok.

Det er den slags fejl der ikke bryder noget i dag men kan give uforudsigelige problemer under langvarig drift — memory-vækst, event-loop overhead, ressource-udtømning. Den er fikset.

En refleksion om benchmark-drevet udvikling

Det der gjorde disse fixes mulige var at vi vidste præcist hvad der var langsomt — ikke fordi vi gættede, men fordi vi har benchmarks der kører 8000 operationer per scenarie og rapporterer P50/P95/P99-latenser.

Uden det ville vi have haft en mavefornemmelse om at "invoicing er lidt langsom". Med det vidste vi: 276 ops/sec vs. 1171 for projects. Det er et signal, ikke en antagelse. Og det signal peger direkte på at der er noget arkitektonisk galt — ikke bare at vi har brug for en hurtigere server.

Det er den reelle fordel ved præcise benchmarks: ikke at nå et tal, men at have et måleinstrument der gør rodårsagsfinding mulig.

Mens backend brændte: frontend fik et design system

Backend var ikke det eneste der skete i weekenden. Parallelt kørte vi en systematisk oprydning af frontenden — 86 commits på tre dage.

Udgangspunktet var klassisk teknisk gæld: samme STATUS_COLORS-mapping gentaget i 24 forskellige filer, native <input>- og <textarea>-elementer spredt ud over skemaer i stedet for designsystemets komponenter, Record<string, any> og løse any-casts i næsten alle moduler.

Vi kørte det igennem i otte trin:

Konsolidering af statusbadges og farver. En enkelt StatusBadge-komponent erstatter 24 separate farvedefinitioner. Konsistens på tværs af fakturaer, projekter, kontrakter og CRM — ikke fordi det ser pænere ud, men fordi det var umuligt at ændre en farve ét sted.

Formularkomponenter. FormField og CreateFormLayout er nu delte abstraktioner der bruges på tværs af alle modulers oprettelsesflows. Formularer er standardiserede til max-w-4xl. Det er den slags detalje der er usynlig for brugeren men som gør fremtidigt arbejde forudsigeligt.

Kanban og listevisninger. Projekters Kanban-board er et fungerende visningsskifte — ikke bare en placeholder. Tidsregistrering, CRM og udgifter har tilsvarende liste/tabel-skift der persisterer på tværs af sidenavigation.

Dashboard KPI-sparklines. Nøgletal på dashboardet har nu mini-trendlinjer bygget på rigtige backend-data — ikke mock-data. Det kræver et lille dataapi-kald per widget, men resultatet er at dashboardet faktisk fortæller noget.

Fuld type-safety-sweep. Record<string, any> og any-casts er elimineret på tværs af CRM, tidsregistrering, fakturering, tilbud, udgifter, planlægning, rapportering og abonnementer. Det er ikke glamourøst arbejde — det er det arbejde man gerne vil have bag sig inden man skalerer kodebasen.

Avatar-upload. Brugere og aktører kan nu have avatarbilleder. Lagret som base64 i PostgreSQL. Måske ikke den bedste langsigtede løsning, men det virker og kræver ingen ekstern storage-service.

InvoiceChatter. Fakturaer har nu en kommunikationsfane — intern beskedtråd tilknyttet fakturaen, tilsvarende hvad CRM og projekter allerede havde. Det er konsistens, ikke ny funktionalitet.

Playwright-testsene fangede undervejs tre UI-regressions der opstod fra formulærændringerne — fejlagtige testid-selektorer, en strict mode-fejl på en dropdown, og en knap der skiftede fra role=button til et link-element. Alle tre fundet og rettet inden merge.

Det er ikke det mest spændende at skrive om. Men det er det arbejde der adskiller en kodebase man kan arbejde i fra én man undgår at røre.


NordBytes er i aktiv alpha-udvikling. Systemet kører og er tilgængeligt, men er ikke klar til produktionsbrug. Hvis du er stødt på dette projekt og er nysgerrig - eller arbejder i et konsulenthus der kæmper med præcis de problemer beskrevet her - er du velkommen til at række ud. Vi tager ikke imod betalende kunder endnu, men vi tager gerne en snak om hvad der mangler før vi gør.


Revisioner

  • 12. apr 2026 — Tilføjet sektion om invoicing-performance: faktura-oprettelse +139%, finalisering +296%, asyncio task-lækage fix. Tilføjet sektion om frontend design system: StatusBadge konsolidering (24 filer), formularkomponenter, Kanban, dashboard sparklines, type-safety sweep, avatar-upload, InvoiceChatter. Opdateret moduloversigt (23 brugervendte moduler + 14 interne), git-statistik (749 → 917 commits), testtal og kapacitetsestimat.