BudgetBuddy Architektur-Guide: Ein F# Full-Stack Deep Dive
BudgetBuddy Architektur-Guide: Ein F# Full-Stack Deep Dive
Für wen ist dieser Guide? Erfahrene F#-Entwickler, die verstehen wollen, wie ein Full-Stack F# Projekt strukturiert ist, und Web-Entwickler aus anderen Sprachen (React, Redux, Clean Architecture), die die F#-Äquivalente kennenlernen möchten.
Einleitung: Warum F# Full-Stack?
BudgetBuddy ist eine Self-Hosted Web-App, die Banktransaktionen von Comdirect mit YNAB (You Need A Budget) synchronisiert. Die zentrale Architektur-Entscheidung war: F# durchgehend – vom Frontend bis zum Backend.
Warum? Drei Gründe:
-
Geteilte Typen: Domain-Typen werden einmal definiert und in beiden Projekten verwendet. Keine Drift, keine “Contract Tests”, keine JSON-Schema-Synchronisation.
-
Type-Safe RPC: Fable.Remoting ersetzt REST APIs durch typisierte Funktionsaufrufe. Änderst du eine API-Signatur, bricht der Build – nicht erst der Production-User.
-
Konsistentes Mental Model: Pattern Matching, Discriminated Unions, und Immutability überall. Kein Context-Switch zwischen TypeScript und C#.
Teil 1: Die 4-Projekt-Struktur
src/
├── Shared/ → Geteilte Typen (Domain.fs, Api.fs)
├── Server/ → Backend (Giraffe + Fable.Remoting)
├── Client/ → Frontend (Elmish + Feliz)
└── Tests/ → Expecto Tests
Warum diese Aufteilung?
Shared ist der Schlüssel. Jeder Typ, der über die Netzwerkgrenze geht, lebt hier:
// src/Shared/Domain.fs
type TransactionStatus =
| Pending
| AutoCategorized
| ManualCategorized
| NeedsAttention
| Imported
| Skipped
Dieser TransactionStatus wird:
- Im Backend gespeichert und verarbeitet
- Im Frontend angezeigt und geändert
- Über die API hin- und hergeschickt
Eine Definition, null Drift.
Vergleich zu anderen Stacks
| BudgetBuddy (F#) | React + Node | Angular + .NET |
|---|---|---|
| Shared Domain Types | OpenAPI + Codegen | OpenAPI + NSwag |
| Compile-time API | Runtime JSON | Runtime JSON |
| 1 Sprache | 2 Sprachen | 2 Sprachen |
Teil 2: Shared Types & API Contracts
Domain.fs – Das Herzstück
Die wichtigste Datei im Projekt. Hier werden alle Domain-Konzepte modelliert:
// Typisierte IDs verhindern "String-Soup"
type TransactionId = TransactionId of Guid
type RuleId = RuleId of Guid
type YnabCategoryId = YnabCategoryId of Guid
// Discriminated Unions für endliche Zustände
type PatternType =
| Contains // Substring-Match
| Exact // Exakter Match
| Regex // Regulärer Ausdruck
// Records für strukturierte Daten
type Rule = {
Id: RuleId
Name: string
Pattern: string
PatternType: PatternType
CategoryId: YnabCategoryId option
CategoryName: string option
TargetField: TargetField
PayeeOverride: string option
Priority: int
Enabled: bool
CreatedAt: DateTime
UpdatedAt: DateTime
}
Architektur-Entscheidung: Warum typisierte IDs?
Statt Guid überall zu verwenden, haben wir TransactionId, RuleId, etc. Das verhindert:
// ❌ Kompiliert, aber semantisch falsch
let deleteRule (transactionId: Guid) = ...
deleteRule someTransaction.Id // Oops, Transaction statt Rule!
// ✅ Kompiliert nicht
let deleteRule (ruleId: RuleId) = ...
deleteRule someTransaction.Id // Compiler-Error!
Kosten: Etwas mehr Boilerplate. Nutzen: Unmöglichkeit einer ganzen Fehlerklasse.
Api.fs – Die Verträge
// src/Shared/Api.fs
type SettingsApi = {
getSettings: unit -> Async<AppSettings>
saveYnabToken: string -> Async<SettingsResult<unit>>
testYnabConnection: unit -> Async<SettingsResult<YnabBudgetWithAccounts list>>
// ...
}
type SyncApi = {
startSync: unit -> Async<SyncResult<SyncSession>>
categorizeTransaction: TransactionId * YnabCategoryId option * string option -> Async<SyncResult<unit>>
importToYnab: SyncSessionId -> Async<SyncResult<ImportResult>>
// ...
}
Das ist der API-Vertrag. Nicht eine Swagger-Datei, nicht eine Markdown-Dokumentation – sondern F#-Typen, die der Compiler prüft.
Für React-Entwickler: Stell dir vor, dein Backend wäre eine TypeScript-Bibliothek mit perfekten Typen. Kein any, kein unknown, kein as.
Result-Typen für Fehlerbehandlung
Jeder API-Endpunkt verwendet typisierte Fehler:
type SettingsResult<'T> = Result<'T, SettingsError>
type SettingsError =
| YnabTokenInvalid of string
| YnabConnectionFailed of int * string
| ComdirectCredentialsInvalid of string * string
| EncryptionFailed of string
| DatabaseError of string * string
Warum nicht Exceptions? Exceptions sind “invisible return types”. Mit Result<'T, SettingsError> ist klar:
- Diese Funktion kann fehlschlagen
- Diese spezifischen Fehler können auftreten
- Der Aufrufer muss beide Fälle behandeln
Teil 3: Backend-Architektur
Die Schichten
Request → Api.fs → Domain.fs → Persistence.fs → SQLite
↓ ↓
Validation Pure Logic
Api.fs – Der Eintrittspunkt
// src/Server/Api.fs
let settingsApi : SettingsApi = {
saveYnabToken = fun token -> async {
// 1. Validierung
match validateYnabToken token with
| Error errors -> return Error (SettingsError.YnabTokenInvalid (String.concat "; " errors))
| Ok validToken ->
// 2. Externer API-Call (Test ob Token gültig ist)
match! YnabClient.getBudgets validToken with
| Error ynabError ->
return Error (SettingsError.YnabConnectionFailed (0, ynabErrorToString ynabError))
| Ok _ ->
// 3. Persistierung
try
do! Persistence.Settings.setSetting "ynab_token" validToken true
return Ok ()
with ex ->
return Error (SettingsError.DatabaseError ("save_ynab_token", ex.Message))
}
// ...
}
Pattern: Validierung → Business Logic → Side Effects
Domain.fs – Pure Funktionen
// src/Server/Domain.fs
// KEINE I/O-Operationen hier!
let applyRule (rule: Rule) (transaction: BankTransaction) : bool =
let fieldValue =
match rule.TargetField with
| Payee -> transaction.Payee
| Memo -> transaction.Memo |> Option.defaultValue ""
| Combined -> $"{transaction.Payee} {transaction.Memo |> Option.defaultValue ""}"
match rule.PatternType with
| Contains -> fieldValue.Contains(rule.Pattern, StringComparison.OrdinalIgnoreCase)
| Exact -> String.Equals(fieldValue, rule.Pattern, StringComparison.OrdinalIgnoreCase)
| Regex -> Regex.IsMatch(fieldValue, rule.Pattern, RegexOptions.IgnoreCase)
Architektur-Entscheidung: Warum “Pure Domain”?
- Testbarkeit: Keine Mocks nötig. Input → Output, fertig.
- Reasoning: Keine versteckten Abhängigkeiten, keine Seiteneffekte.
- Parallelisierung: Pure Funktionen sind thread-safe by design.
Für Clean-Architecture-Kenner: Das entspricht dem “Use Case Layer” oder “Application Layer” – aber funktional statt OOP.
Persistence.fs – Die Datenbank-Grenze
// src/Server/Persistence.fs
module Settings =
let getSetting (key: string) : Async<string option> = async {
use! conn = getConnection()
let! result = conn.QueryFirstOrDefaultAsync<string>(
"SELECT value FROM settings WHERE key = @Key",
{| Key = key |})
return Option.ofObj result
}
let setSetting (key: string) (value: string) (encrypt: bool) : Async<unit> = async {
let finalValue = if encrypt then Encryption.encrypt value else value
use! conn = getConnection()
do! conn.ExecuteAsync(
"INSERT OR REPLACE INTO settings (key, value, is_encrypted) VALUES (@Key, @Value, @IsEncrypted)",
{| Key = key; Value = finalValue; IsEncrypted = encrypt |}) |> Async.AwaitTask |> Async.Ignore
}
Warum Dapper statt Entity Framework?
- Explizite Queries: Keine Magie, keine N+1-Überraschungen
- F#-freundlich: Funktioniert gut mit Records und Option-Types
- Leichtgewicht: Kein Change-Tracking, keine Migrations-Komplexität
Fable.Remoting – Die Magie
// src/Server/Api.fs
let webApp =
Remoting.createApi()
|> Remoting.withRouteBuilder routeBuilder
|> Remoting.fromValue settingsApi
|> Remoting.fromValue syncApi
|> Remoting.fromValue rulesApi
|> Remoting.fromValue ynabApi
|> Remoting.buildHttpHandler
Das generiert automatisch HTTP-Endpunkte für alle API-Funktionen. Der Client ruft sie so auf:
// src/Client/Api.fs
let settings = Remoting.createApi() |> Remoting.buildProxy<SettingsApi>
// Aufruf (irgendwo im Client)
let! result = Api.settings.saveYnabToken token
Kein REST, keine URLs, keine JSON-Serialisierung – alles automatisch.
Teil 4: Frontend-Architektur (MVU/Elmish)
Das MVU-Pattern
MVU (Model-View-Update) ist das funktionale Äquivalent zu Redux:
| Redux | Elmish/MVU |
|---|---|
| State | Model |
| Action | Msg |
| Reducer | update |
| mapStateToProps | view |
// src/Client/State.fs
type Model = {
CurrentPage: Page
Dashboard: DashboardModel
Settings: SettingsModel
SyncFlow: SyncFlowModel
Rules: RulesModel
Toasts: Toast list
}
type Msg =
| NavigateTo of Page
| UrlChanged of string list
| ShowToast of string * ToastType
| DismissToast of Guid
| DashboardMsg of DashboardMsg
| SettingsMsg of SettingsMsg
| SyncFlowMsg of SyncFlowMsg
| RulesMsg of RulesMsg
Die Update-Funktion
let update (msg: Msg) (model: Model) : Model * Cmd<Msg> =
match msg with
| NavigateTo page ->
let segments = Routing.toUrlSegments page
model, Cmd.navigate(segments |> List.toArray)
| UrlChanged segments ->
let page = Routing.parseUrl segments
if page = model.CurrentPage then
model, Cmd.none
else
let extraCmds =
match page with
| Dashboard -> Cmd.map DashboardMsg (Cmd.ofMsg LoadLastSession)
| SyncFlow -> Cmd.map SyncFlowMsg (Cmd.ofMsg LoadCurrentSession)
| Rules -> Cmd.map RulesMsg (Cmd.ofMsg LoadRules)
| Settings -> Cmd.map SettingsMsg (Cmd.ofMsg LoadSettings)
{ model with CurrentPage = page }, extraCmds
| DashboardMsg dashboardMsg ->
let model', cmd = Dashboard.State.update dashboardMsg model.Dashboard
{ model with Dashboard = model' }, Cmd.map DashboardMsg cmd
// ...
Architektur-Entscheidung: Component-basierte Verschachtelung
Jede “Page” hat eigene Types.fs, State.fs, View.fs:
Components/
├── Dashboard/
│ ├── Types.fs → DashboardModel, DashboardMsg
│ ├── State.fs → init, update
│ └── View.fs → view
├── SyncFlow/
│ └── ...
Die Haupt-State.fs delegiert an die Komponenten und handled nur Cross-Cutting-Concerns (Toasts, Navigation).
RemoteData – Async State Management
// src/Client/Types.fs
type RemoteData<'T> =
| NotAsked // Noch nicht geladen
| Loading // Lädt gerade
| Success of 'T
| Failure of string
Warum nicht einfach 'T option?
option unterscheidet nicht zwischen “noch nicht geladen” und “geladen, aber leer”. RemoteData macht alle vier Zustände explizit:
match model.Transactions with
| NotAsked -> Html.text "Klicke auf Laden"
| Loading -> Loading.spinner MD Teal
| Success [] -> Html.text "Keine Transaktionen"
| Success txns -> TransactionList.view txns dispatch
| Failure err -> ErrorDisplay.card err (Some retry)
Für Redux-Entwickler: Das ist wie { loading: boolean, error: string | null, data: T | null } – aber ohne die Möglichkeit inkonsistenter Zustände (loading=true UND error!=null).
Das Helper-Modul
module RemoteData =
let map (f: 'a -> 'b) (rd: RemoteData<'a>) : RemoteData<'b> =
match rd with
| Success x -> Success (f x)
| Loading -> Loading
| NotAsked -> NotAsked
| Failure err -> Failure err
let isLoading rd = match rd with Loading -> true | _ -> false
let withDefault defaultValue rd = match rd with Success x -> x | _ -> defaultValue
let fromResult result = match result with Ok x -> Success x | Error e -> Failure e
Das DesignSystem
DesignSystem/
├── Tokens.fs → Farben, Abstände, Fonts
├── Primitives.fs → Container, Grid, Stack
├── Button.fs → Button-Varianten
├── Card.fs → Card-Layouts
├── Input.fs → Form-Inputs
├── Modal.fs → Dialog-Komponenten
├── ErrorDisplay.fs → Fehler-Darstellung
└── ...
Architektur-Entscheidung: Warum ein eigenes Design System?
- Konsistenz: Alle Buttons sehen gleich aus, alle Errors werden gleich angezeigt
- Wiederverwendung:
Button.primary "Speichern" onClickstatt 20 Zeilen Tailwind - Typsicherheit: Props sind F#-Records, nicht String-Attributes
// Statt:
Html.button [
prop.className "btn btn-primary bg-orange-500 hover:bg-orange-600 ..."
prop.onClick (fun _ -> dispatch Save)
prop.text "Speichern"
]
// Schreibt man:
Button.primary "Speichern" (fun () -> dispatch Save)
Teil 5: Wie hängt alles zusammen?
Der Datenfluss eines API-Calls
1. User klickt "Speichern" im Frontend
└→ dispatch (SettingsMsg (SaveYnabToken token))
2. State.fs update-Funktion
└→ Cmd.OfAsync.either Api.settings.saveYnabToken token ...
3. Fable.Remoting serialisiert und sendet HTTP-Request
4. Server Api.fs empfängt
└→ validateYnabToken token
└→ YnabClient.getBudgets token
└→ Persistence.Settings.setSetting ...
└→ return Ok ()
5. Fable.Remoting sendet Response zurück
6. Client empfängt Result
└→ dispatch (SettingsMsg (YnabTokenSaved (Ok ())))
7. State.fs update-Funktion
└→ { model with Settings = { model.Settings with SaveStatus = Success () } }
8. View rendert neuen Zustand
└→ "Token gespeichert!" Toast
Wo fängt man an, um X zu verstehen?
| Ich will verstehen… | Starte hier |
|---|---|
| Welche Daten gibt es? | src/Shared/Domain.fs |
| Welche API-Endpunkte? | src/Shared/Api.fs |
| Wie funktioniert Feature X? | src/Client/Components/X/State.fs |
| Wie wird Y gespeichert? | src/Server/Persistence.fs |
| Wie sieht UI-Element Z aus? | src/Client/DesignSystem/Z.fs |
Lessons Learned
Was ich anders machen würde
-
Früher ein Design System: Die ersten UI-Komponenten waren inline Tailwind. Die Migration zu DesignSystem-Komponenten hat Zeit gekostet.
-
Mehr Property-Based Tests: Unit-Tests sind gut, aber FsCheck hätte Edge-Cases früher gefunden.
-
Striktere Trennung von Queries/Commands: Manche API-Funktionen machen beides. CQRS-Trennung wäre sauberer.
Was gut funktioniert hat
-
Shared Types von Anfang an: Nie “Contract Drift” gehabt.
-
Result-Types statt Exceptions: Fehlerbehandlung ist explizit und vollständig.
-
MVU für komplexe Flows: Der Sync-Wizard mit 7 Zuständen wäre mit imperativem Code ein Albtraum.
Key Takeaways
-
Shared Types sind der größte Gewinn von F# Full-Stack: Eine Typdefinition, zwei Projekte, null Synchronisations-Aufwand.
-
MVU/Elmish ist Redux done right: Gleiche Ideen, aber mit Compiler-Support statt Runtime-Checks.
-
Pure Domain Logic zahlt sich aus: Leicht zu testen, leicht zu verstehen, leicht zu ändern.
-
Fable.Remoting eliminiert eine ganze Fehlerklasse: Keine falschen URLs, keine JSON-Parsing-Errors, keine vergessenen Endpunkte.
Dieser Guide wurde geschrieben, um Entwicklern den Einstieg in die BudgetBuddy-Codebase zu erleichtern. Bei Fragen: Issues auf GitHub sind willkommen!