Skip to content

Coding Guidelines

tom-schorn edited this page Sep 28, 2026 · 6 revisions

Programmierrichtlinien

Was hier steht, ist nicht Geschmack, sondern die Summe von Fehlern, die schon einmal Zeit gekostet haben. Wer sich daran hält, tritt nicht zweimal in dieselbe Grube.


Sprache

Code, Bezeichner, Kommentare, Commits und Pull-Request-Titel sind englisch. Issues, Wiki und README sind deutsch. Die Oberfläche ist vorerst nur deutsch.

Der Grund für englischen Code: das Warum steht bei uns in den Kommentaren, und Bezeichner und Kommentare sollen für jeden lesbar sein, der später dazukommt. Issues, Wiki und Oberfläche dagegen richten sich an Haushalte in Deutschland; die Fachsprache (Kindergeld, Bürgergeld, Kinderzuschlag, Taschengeldanspruch) ist deutsch, und ein Alleinentwickler pflegt keine zwei Fassungen.

Weitere Sprachen für die Oberfläche sollen andere per Pull Request beisteuern können, ohne Code anzufassen. Deshalb gehören Texte nicht fest in Komponenten: sie stehen als Schlüssel im Katalog src/locales/de.json und werden mit react-i18next gelesen. Das Backend liefert Codes, nie Sätze — das Frontend übersetzt sie. Wie eine neue Sprache dazukommt, steht auf der Seite Neue Sprache beitragen.


Namen

Schreib das Wort aus. position, nicht pos. plan, nicht p. monthly_plan, nicht mp. Auch in kurzen Funktionen und Schleifen — gerade dort, weil man beim Lesen sonst zurückspringen muss, um herauszufinden, was s war.

Erlaubt sind genau zwei Abkürzungen, weil sie überall dasselbe bedeuten: id und url.

Das gilt auch für Dinge, die nicht wie Code aussehen: Schrittnamen in Workflows, Bezeichner in Diagrammen, Dateinamen in Skripten.

Der Name sagt, was drin ist, nicht welchen Typ es hat. positions statt positionList, paid statt boolPaid.

Bei Zahlen gehört die Einheit dazu, wenn sie nicht offensichtlich ist: memory_mb, refresh_lifetime_seconds.

Sammlungen im Plural, einzelne Dinge im Singular. positions ist eine Liste, position ist eine.


Lokal aufbauen

Das ist nicht der Weg zum Selbsthosten — der steht unter Installation und braucht nur Docker.

Voraussetzungen

Version Wofür
Python 3.12+ Backend
uv aktuell Abhängigkeiten des Backends
Node.js 22+ Oberfläche
PostgreSQL 14+ Datenbank

uv ersetzt pip und venv:

curl -LsSf https://astral.sh/uv/install.sh | sh

Datenbank

Duofy braucht eine eigene Datenbank. Teilt man eine mit einem anderen Projekt, kollidieren die Migrationsstände.

CREATE DATABASE duofy;

Backend

cd backend
cp .env.local.example .env.local

In .env.local die Postgres-Werte eintragen und ein JWT_SECRET erzeugen:

openssl rand -hex 32

Diese Datei ist von Git ausgeschlossen und gehört nirgendwo hin außer auf deine Platte.

COOKIE_SECURE=false stehen lassen. Der Entwicklungsserver läuft über http://localhost, und Browser verwerfen als sicher markierte Cookies auf http-Adressen. Steht der Wert auf true, kommt das Sitzungs-Cookie nie an und man ist nach jedem Neuladen abgemeldet — ohne Fehlermeldung, denn abgelehnt hat es der Browser.

uv sync
uv run alembic upgrade head
uv run uvicorn app.main:app --reload

Das Backend läuft auf http://localhost:8000, die API-Dokumentation liegt unter /docs und ist zum Ausprobieren brauchbar — man kann sich dort anmelden und Aufrufe direkt absenden.

Oberfläche

cd frontend
cp .env.local.example .env.local
npm install
npm run dev

Läuft auf http://localhost:5173. In .env.local stehen zwei Werte:

VITE_API_URL=http://localhost:8000/api/v1
VITE_API_PROXY=

Wer nur an der Oberfläche arbeitet und kein Backend lokal laufen lassen will, kann über den Entwicklungsserver auf ein entferntes Backend zeigen. Dann holt der Server die Daten, und CORS greift gar nicht erst:

VITE_API_URL=/api/v1
VITE_API_PROXY=https://adresse-des-backends

Vor jedem Pull Request

cd backend
uv run ruff check app/ tests/
uv run pytest -q

cd frontend
npx tsc --noEmit -p tsconfig.app.json
npm run lint
npm run build

Genau diese sechs Befehle laufen auch in der CI. Wer sie vorher durchlässt, wartet nicht auf einen roten Haken.


Backend

Beträge immer Numeric, nie Float. Bei Geld ist ein Rundungsfehler kein Schönheitsfehler. In Python Decimal, in Pydantic Decimal mit max_digits und decimal_places.

Enums über enum_column() aus app/db/types.py, nie SAEnum direkt. Der Helfer speichert den Wert (monthly), nicht den Namen (MONTHLY). Sonst vergleichen CHECK-Constraints gegen etwas, das nie zutrifft — und greifen stillschweigend nie.

Die Spalte ist VARCHAR mit native_enum=False und ohne Constraint auf die Werte. Deshalb ist eine neue Kategorie eine normale Codeänderung und keine Migration.

Fehler sind Codes, keine Sätze. require(bedingung, "no_edit_granted") aus app/core/permissions.py wirft einen 403 mit {"code": "..."}. Die Oberfläche übersetzt ihn. So bleibt die API sprachfrei, und eine neue Sprache ändert nichts am Backend.

Tabellennamen: Klassenname in snake_case, Plural. Ergibt die Tabelle ohne ihr Elternteil keinen Sinn, kommt das Elternteil als Präfix davor.

households · household_members · commitments · plans · plan_positions

commitments steht ohne Präfix, weil ein Vertrag für sich existiert. plan_positions nicht, weil ein Posten ohne Plan nichts ist.

Eine Datei pro Aggregat. PlanPosition liegt bei Plan, HouseholdMember bei Household.

id und created_at kommen aus app/models/mixins.py, nicht von Hand.

Schemas heißen …Read, …Create, …Update und erben von der Basis in app/schemas/base.py. Die setzt alias_generator=to_camel: nach außen heißt das Feld amountPlanned, in Python amount_planned. Wer die Basis vergisst, baut ein Schema, dessen Felder die Oberfläche nicht findet.

Kein Endpunkt ohne Rechteprüfung, sobald fremde Daten im Spiel sind. require(bedingung, "code") aus app/core/permissions.py, oder eine der Prüfungen darin — granted_level() und viewable_members(). Bei einer App, in der mehrere Haushalte nebeneinander liegen, ist das die Regel, bei der ein Fehler am teuersten ist.


Wo die Logik hinsoll

Heute steckt fast alles im Endpunkt selbst. Das wird umgebaut, in diese Richtung:

  • Endpunkt — nur HTTP. Eingabe entgegennehmen, Rechte prüfen, aufrufen, antworten. Keine Rechnung, keine Regel
  • Dienst (app/services/) — die Regel selbst: einen Monat erzeugen, das Budget ableiten, Salden rechnen. Kennt keine HTTP-Begriffe und wirft keine HTTPException
  • Modell — Struktur, Beziehungen, Constraints. Keine Geschäftslogik

Kein Dienst für Kleinkram. Ein Endpunkt, der eine Zeile lädt und zurückgibt, bleibt wie er ist. Ein Dienst entsteht, wenn eine Regel mehr als einen Aufrufer hat oder mehr als ein paar Zeilen braucht.


Migrationen

Alembic ist eine Hilfe, kein Automat. Drei Dinge übersieht es zuverlässig:

CHECK-Constraints erkennt es nicht. Autogenerate lässt sie weg. Immer von Hand nachtragen, sonst gilt die Regel nur in Python und nicht bei einem Import oder direktem SQL.

„Nicht leer" braucht auf einer gefüllten Tabelle einen Vorgabewert. Ohne server_default scheitert die Migration an den vorhandenen Zeilen. Bei einem Häkchen also server_default=sa.true() oder sa.false().

Fremdschlüssel brauchen einen Namen. Ohne Namen scheitert drop_constraint(None, ...) beim Zurückrollen. Muster: fk_{tabelle}_{spalte}_{zieltabelle}.

Dazu eine Eigenheit des Projekts: der GUID-Import von fastapi-users steht fest in alembic/script.py.mako, weil Autogenerate ihn benutzt, aber nicht importiert.

Eine Änderung, eine Migration. Nicht sammeln — das widerspricht dem Rest (ein Pull Request tut eine Sache) und funktioniert nur bei jemandem, der die Sammlung im Kopf hat.

Probiere das Zurückrollen aus, bevor du den Pull Request aufmachst.

uv run alembic upgrade head
uv run alembic downgrade -1
uv run alembic upgrade head

Alembic erzeugt downgrade() mit, aber niemand führt es je aus — und darum läuft es oft nicht. Genau dort fällt auch der unbenannte Fremdschlüssel von oben auf. Zwei Befehle, und der häufigste Migrationsfehler ist weg, bevor ihn jemand anderes findet.


Oberfläche

Tailwind-Klassen müssen als vollständige Zeichenkette im Code stehen. Der Übersetzer liest die Dateien als Text; was zur Laufzeit zusammengebaut wird, existiert im fertigen CSS nicht:

// falsch — die Klasse entsteht nie
className={`bg-${farbe}`}

// richtig — eine Tabelle mit ausgeschriebenen Klassen
const BAR: Record<Budget, string> = {
  needs: 'bg-chart-1',
  wants: 'bg-chart-2',
}

Das fällt nicht auf: es gibt keine Fehlermeldung, die Fläche bleibt einfach grau.

Kein toISOString() für Datumsangaben. Es rechnet nach UTC um und liefert vor 02:00 den Vortag. Die Helfer in src/lib/dates.ts benutzen — toIsoDay, today, fromIsoDay, langesDatum.

Nimm die Komponenten aus dem Theme. Fortschrittsbalken, Häkchen, Karten, Tabellen, Diagramme — alles liegt unter components/ui. Etwas von Hand zu bauen sieht beim ersten Mal schneller aus und weicht danach für immer vom Rest ab.

Serverdaten über TanStack Query, nichts von Hand im State. Änderungen laufen über useInvalidating aus src/lib/queries.ts: es räumt die betroffenen Abfragen auf und zeigt eine Meldung.

Beträge nie als number rechnen. Sie kommen als Zeichenkette aus dem Backend, weil 0.1 + 0.2 in JavaScript nicht 0.3 ist. Anzeigen und für eine Summe in Number() wandeln ist in Ordnung; was zurück ins Backend geht, bleibt die Zeichenkette, die von dort kam.

Jede Ansicht braucht drei Zustände: lädt, Fehler, leer. QueryState nimmt die ersten beiden, die Empty-Komponente den dritten. Eine Seite, die beim Laden weiß bleibt und bei einem Fehler nichts sagt, sieht kaputt aus, obwohl sie funktioniert.

Diagramme messen beim Drucken nicht neu. Recharts kennt die Papierbreite nicht. Druckdiagramme werden deshalb dauerhaft in fester Breite außerhalb des Bildes montiert und im Druck sichtbar geschaltet.


Kommentare

Erkläre das Warum, nicht das Was. Was der Code tut, steht im Code.

# schlecht — sagt nichts, was man nicht liest
# Setzt den Betrag
position.amount_actual = betrag

# gut — sagt, warum es so und nicht anders ist
# Summe aller zugeordneten Buchungen, nicht der letzte Betrag: an einem Budget
# hängen viele Ausgaben, und das Ist ist deren Summe.
position.amount_actual = summe

Kein auskommentierter Code. Löschen. Git erinnert sich, ein Kommentarblock erinnert nur daran, dass mal jemand unsicher war.

Jedes Modul mit eigenem Zweck bekommt oben einen Block, der ihn erklärt — was das Ding ist, welche Entscheidung dahintersteht, was absichtlich fehlt. Die Dateien im Projekt machen das schon; nimm eine davon als Vorlage.

Wo Logik, Daten oder Verknüpfungen fehlen, gehört ein TODO hin — mit Issue-Nummer, wenn es eine gibt. Ein stiller Platzhalter wird zur Falle für den Nächsten.


Tests

pytest im Backend, vitest mit Testing Library im Frontend. Beides läuft in der Pipeline bei jedem Pull Request.

Die Backend-Tests brauchen ein echtes Postgres, kein SQLite: die Modelle benutzen Dinge, die SQLite nicht kennt (einen Teilindex, timestamptz, CHECK- Constraints), und eine grüne Suite ohne sie würde genau die Regeln nicht prüfen, die die Daten schützen. POSTGRES_DB muss „test“ enthalten, denn die Fixtures löschen alle Tabellen. Migrationen werden zusätzlich vor jedem Pull Request vorwärts und rückwärts ausprobiert.

Was einen Test verdient: alles, wo eine Regel gerechnet wird. Budget-Ableitung, Fälligkeiten aus Abstand und erstem Monat, die Monatsregel für Buchungen, die CHECK-Constraints.

Was keinen verdient: dass ein Endpunkt 200 zurückgibt. So ein Test wird grün und bleibt grün, auch wenn die Zahl darin falsch ist.

Der Testname sagt das Verhalten, nicht die Funktion.

# schlecht
def test_resolve_budget(): ...

# gut
def test_savings_goal_lands_in_savings_whatever_the_category(): ...

Der zweite Name sagt beim Fehlschlag schon, was kaputt ist — ohne dass man den Test liest.

Neue Logik bringt einen Test mit. Das steht auch in der Pull-Request-Vorlage.


Branches und Commits

feat/9-income-commitment-type
fix/12-category-labels
docs/7-installation-page

Muster: typ/issue-nummer-kurzbeschreibung. Typen: feat, fix, docs, refactor, test, chore.

Jeder Commit und der Titel des Pull Requests folgen Conventional Commits, weil aus den Einzelcommits Version und Changelog entstehen:

Betreff Nächste Version
fix: correct the category label Patch
feat: add income as its own type Minor
feat!: remove plan states Major

Ein Commit macht genau eine Sache. „wip“ und fixup! gehören nicht in einen Branch, der gemerged wird.

Jeder Commit braucht eine Signatur: git commit -s. Warum, steht in Mitmachen.

Der Weg:

feat/9-kurzbeschreibung  →  Pull Request (Rebase-Merge)  →  main

Es gibt nur main (Trunk), keine langen Zweige. Gemerged wird per Rebase, nicht per Squash: die Einzelcommits bleiben, deshalb ist jeder Commit nach Conventional Commits geschrieben und macht genau eine Sache. Die Pipeline prüft jeden Betreff und den PR-Titel, damit die Versionsautomatik in jedem Commit ein feat: oder fix: findet.

Releases macht release-please auf main: sein Release-PR erzeugt Tag und Images (:latest und den Versionstag). Jeder Push auf main baut das Abbild :dev.

Die Version steht nur in version.txt. In pyproject.toml und package.json bleibt sie auf 0.0.0 stehen — würde sie dort mitwandern, passten die Sperrdateien nicht mehr dazu und jeder Bau scheiterte.

Der ganze Weg und die Regeln drumherum stehen in Mitmachen.


Entscheidungen festhalten

Langlebige Entscheidungen stehen als ADR (Architecture Decision Record) im Ordner docs/adr/ des Repositories. Das Format und wann eine nötig ist, steht in docs/adr/README.md. Ein Kommentar erklärt eine Stelle, ein ADR erklärt, warum die Architektur so aussieht.

Logging

Geloggt wird mit logging nach stdout, die Stufe kommt aus LOG_LEVEL. Nie Beträge, Namen, E-Mails, IBANs, Tokens oder Anfrageinhalte loggen; bei Fehlern nur Klasse und Stack, nicht die Meldung. Neue Routen mit freiem Pfadteil von höchstens 16 Zeichen im Log ausdrücklich maskieren (app/core/logging.py).

Clone this wiki locally