Repository navigation
Coding Guidelines
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.
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.
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.
Das ist nicht der Weg zum Selbsthosten — der steht unter Installation und braucht nur Docker.
| 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 | shDuofy braucht eine eigene Datenbank. Teilt man eine mit einem anderen Projekt, kollidieren die Migrationsstände.
CREATE DATABASE duofy;cd backend
cp .env.local.example .env.localIn .env.local die Postgres-Werte eintragen und ein JWT_SECRET erzeugen:
openssl rand -hex 32Diese 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 --reloadDas 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.
cd frontend
cp .env.local.example .env.local
npm install
npm run devLä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
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 buildGenau diese sechs Befehle laufen auch in der CI. Wer sie vorher durchlässt, wartet nicht auf einen roten Haken.
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.
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 keineHTTPException - 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.
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 headAlembic 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.
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.
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 = summeKein 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.
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.
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.
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.
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).