Backend-Stack lernen durch Bauen: NestJS, MongoDB und Kafka in einer Analytics-Plattform

Von hyretic

Dieser Beitrag basiert auf dem Artikel „Learning Back-End Stack and Architecture by Building a User Analytics platform” von Othniel Visky. Der Originalartikel beschreibt den vollständigen Weg eines Frontend-Entwicklers zum Backend-Stack NestJS, MongoDB und Kafka.

TL;DR / Key Takeaways

  • Lernen durch Bauen: Ein reales Projekt mit klarer Architektur ist der effektivste Weg, einen neuen Stack zu lernen.
  • Entkopplung: Kafka trennt Event-Ingestion (API) von Event-Verarbeitung (Core-App). Beide Seiten skalieren unabhängig.
  • Monorepo: Fünf Apps und drei Packages in einem TypeScript-Monorepo mit Turborepo halten Typen, Dependencies und Utilities synchron.
  • Domain-Driven Design: MongoDB-Collections, NestJS-Module und Kafka-Topics bilden natürliche Bounded Contexts.

Die Ausgangslage

Othniel Visky ist seit fast 12 Jahren Frontend-Entwickler. Backend-Erfahrung beschränkte sich auf Zuarbeiten in .NET, FastAPI, Bun und Hono. Die Perspektive war immer die des Frontends. Dann kam die Gelegenheit: ein Vorstellungsgespräch für eine Backend-Position mit dem Stack NestJS, MongoDB und Kafka.

Statt nur Dokumentation zu lesen, hat er ein vollständiges Projekt gebaut: eine User-Analytics-Plattform, vergleichbar mit Google Analytics oder PostHog. Der Ansatz zeigt, warum Fundamente wichtiger sind als Tools. Wer den Stack versteht, kann jedes Tool darin einordnen.

Die Architektur: Fünf Apps, eine klare Verantwortungstrennung

Vor der ersten Zeile Code stand die Architektur. Die Plattform besteht aus fünf separaten Anwendungen:

user-analytics-monorepo
    apps/
        analytics-api        # REST-API, dünne Routing-Schicht
        analytics-core       # Herzstück: Geschäftslogik, MongoDB, Kafka
        analytics-dashboard  # Next.js Dashboard für Metriken
        tester-ui            # Next.js Test-App für Event-Tracking
    packages/
        analytics-sdk        # npm-Package mit typisierter capture()-Funktion
        eslint-config        # Gemeinsame Lint-Regeln
        typescript-config    # Gemeinsame TS-Konfiguration

Jede App hat genau eine Aufgabe:

analytics-core ist das Herzstück. Hier laufen MongoDB-Queries, Kafka-Consumer und die gesamte Geschäftslogik. Daten aus der API landen hier zur Speicherung, Abfrage und Verarbeitung.

analytics-api ist eine NestJS-REST-API. Sie nimmt Events vom SDK entgegen und delegiert die Arbeit über Kafka-Messages an die Core-App. Aktuell übernimmt sie sowohl den Inbound-Flow (Events empfangen) als auch den Outbound-Flow (Metriken ans Dashboard liefern). Langfristig bietet sich hier das CQRS-Pattern an: Command-Seite (Events schreiben) und Query-Seite (Metriken lesen) trennen.

analytics-sdk ist ein npm-Package. Es exponiert eine typisierte capture()-Funktion, die Custom Events vom Client an die API sendet. Im Vergleich zu einem einfachen Embed-Script bietet ein SDK Typsicherheit, Code-Completion und Updates über npm.

tester-ui und analytics-dashboard sind Next.js-Anwendungen. Die Tester-UI trackt Nutzeraktionen durch Custom Events. Das Dashboard zeigt die aggregierten Daten.

Warum ein Monorepo mit Turborepo

Fünf Apps und drei Packages in separaten Repositories zu verwalten, erzeugt schnell Synchronisationsprobleme bei TypeScript-Typen, gemeinsamen Utilities und Dependency-Versionen. Ein Monorepo löst das.

Turborepo übernimmt das Build-Management. Caching verhindert unnötige Rebuilds. Das Smart-Build-System erkennt, welche Packages sich geändert haben, und baut nur die betroffenen Teile neu. Der operationelle Overhead (besonders für CI/CD) ist real, aber Turborepos Tooling gleicht den Großteil davon aus.

Der entscheidende Vorteil: eine Single Source of Truth für den gesamten Stack. Wenn sich ein Interface im SDK ändert, sieht die API das sofort. Kein manuelles Synchronisieren zwischen Repositories.

MongoDB: Dokumentenbasierte Speicherung für Event-Daten

MongoDB ist eine NoSQL-Datenbank, die Daten als BSON-Dokumente (Binary JSON) speichert. Die zentrale Abstraktion ist die Collection, vergleichbar mit einer Tabelle in relationalen Datenbanken, aber ohne festes Schema.

Für Analytics-Daten ist das ein natürlicher Fit. Jede Nutzerinteraktion kann unterschiedliche Strukturen haben. Ein Page-View hat andere Felder als ein Button-Click oder eine Form-Submission. Collections bilden logische Gruppierungen eng verwandter Entitäten. Konzeptionell entspricht das den Aggregates aus Domain-Driven Design: Konsistenzregeln und Grenzen werden auf Collection-Ebene verwaltet.

Die aggregate()-Funktion von MongoDB wurde zum zentralen Werkzeug für die Metrik-Berechnung. Sie filtert, transformiert und gruppiert Daten in einer einzigen Pipeline.

// Beispiel: Page Views nach Pathname gruppieren
db.events.aggregate([
  { $match: { type: "page-view" } },
  { $group: { _id: "$pathname", count: { $sum: 1 } } },
  { $sort: { count: -1 } }
])

Kafka: Asynchrone Entkopplung von Producer und Consumer

Kafka ist eine verteilte Event-Streaming-Plattform. Die Kernabstraktion ist das Topic: ein Kanal, in den Producer Records schreiben und aus dem Consumer lesen. Topics sind partitioniert für Skalierbarkeit und über einen Cluster von Brokern verteilt für Fehlertoleranz.

In dieser Architektur entkoppelt Kafka den Datenproduzenten (Client-App über SDK und API) vom Konsumenten (analytics-core). Die API pusht Events auf Kafka-Topics, ohne sich um Speicherung oder Geschäftslogik zu kümmern. Die Core-App konsumiert den Stream asynchron.

// Producer-Seite (analytics-api): Event auf Topic pushen
@Post('events')
async captureEvent(@Body() event: AnalyticsEvent) {
  await this.kafkaProducer.send({
    topic: 'analytics-events',
    messages: [{ value: JSON.stringify(event) }],
  });
}

// Consumer-Seite (analytics-core): Events verarbeiten
@MessagePattern('analytics-events')
async handleEvent(event: AnalyticsEvent) {
  await this.analyticsService.processAndStore(event);
}

Die praktischen Vorteile dieser Entkopplung:

  1. Pufferung bei Lastspitzen: Events werden in der Queue gehalten, wenn die Core-App nicht schnell genug verarbeitet.
  2. Horizontale Skalierung: Mehr Verarbeitungskapazität bedeutet einfach mehr Consumer hinzufügen.
  3. Event-Replay: Kafka persistiert Events. Historische Daten können für neue Aggregationsarten erneut verarbeitet werden.

NestJS: Strukturierte Backend-Architektur

NestJS abstrahiert über Node.js-Frameworks wie Express oder Fastify. Die Architektur ist von Angular beeinflusst und basiert auf vier Kernbausteinen:

Module gruppieren zusammengehörige Controller und Services. Sie bilden Grenzen innerhalb der App-Struktur, vergleichbar mit Bounded Contexts in DDD.

Controller verarbeiten eingehende HTTP-Requests und geben Responses zurück. Sie enthalten keine Geschäftslogik, sondern delegieren an Services.

Services (Provider) enthalten die Geschäftslogik. Sie verbinden sich mit der Persistenzschicht und bieten komponierbare, wiederverwendbare Operationen.

Dependency Injection über TypeScript-Decoratoren verbindet alles. Middleware, Interceptors, Exception-Filter und Guards ergänzen die Architektur.

// Modul: gruppiert Controller und Service
@Module({
  controllers: [AnalyticsController],
  providers: [AnalyticsService],
  imports: [MongooseModule.forFeature([{ name: Event.name, schema: EventSchema }])],
})
export class AnalyticsModule {}

// Controller: nimmt Request entgegen, delegiert
@Controller('analytics')
export class AnalyticsController {
  constructor(private readonly analyticsService: AnalyticsService) {}

  @Get('page-views')
  getPageViews() {
    return this.analyticsService.calculatePageViews();
  }
}

// Service: Geschäftslogik und Datenbankzugriff
@Injectable()
export class AnalyticsService {
  constructor(@InjectModel(Event.name) private eventModel: Model<Event>) {}

  async calculatePageViews() {
    return this.eventModel.aggregate([
      { $match: { type: 'page-view' } },
      { $group: { _id: '$pathname', count: { $sum: 1 } } },
    ]);
  }
}

Analytics-Metriken: Page Views, Dwell Time und Unique Sessions

Die Plattform berechnet drei Kernmetriken:

Page Views werden durch Gruppierung von Events nach Pathname über MongoDB-Aggregationen gezählt.

Time on Page (Dwell Time) wird über die Dauer zwischen separaten page-enter und page-leave Events gemessen. Eine eindeutige pageTransitionId wird bei jedem Routenwechsel generiert und verknüpft die beiden Events bei der Aggregation.

Unique Sessions nutzen zwei Downsampling-Algorithmen für die Visualisierung großer Datensätze:

LTTB (Largest-Triangle-Three-Buckets) teilt die Datenpunkte in drei Bereiche und wählt die Punkte aus, die das Dreieck mit der größten Fläche bilden. Das Ergebnis: visuell wichtige Punkte wie Peaks und Täler bleiben im reduzierten Datensatz erhalten.

Min-Max-Average mittelt die Werte repräsentativer Zeiträume. Beide Algorithmen lösen dasselbe Problem: Wenn du Unique Sessions über ein ganzes Jahr anzeigen willst, kannst du nicht jeden einzelnen Tag auf dem Graphen darstellen. Du brauchst eine Auswahl, die die visuellen Charakteristiken der Daten bewahrt.

Domain-Driven Design als natürliches Ergebnis

Ein interessanter Nebeneffekt dieses Stacks: er legt Domain-Driven Design nahe, ohne es aktiv einzufordern.

MongoDB-Collections bilden logische Aggregate. NestJS-Module definieren Bounded Contexts innerhalb der App-Struktur. Kafka-Topics erzwingen eine klare Trennung zwischen Producer und Consumer. Die Architektur der Adapter-Port-Architektur findet sich hier in anderer Form wieder: die API kennt die Geschäftslogik nicht, sie delegiert über Kafka. Die Core-App kennt die API-Schicht nicht, sie konsumiert einen Stream.

Diese natürliche Passung ist kein Zufall. NestJS zitiert DDD-Konzepte direkt in seiner Dokumentation. MongoDBs Collection-Konzept mappt auf Aggregate. Kafkas Topic-basierte Entkopplung erzwingt Bounded Contexts auf Infrastrukturebene.

Der Lernansatz: Dokumentation, KI und Verifikation

Othniel Visky beschreibt einen Lernansatz, der sich auf drei Säulen stützt:

  1. Offizielle Dokumentation als primäre Quelle für Konzepte und korrekte Verwendung.
  2. KI-Agent als Recherche-Tool, wenn er bei einem spezifischen Aspekt nicht weiterkam.
  3. Gegenprüfung jeder KI-Antwort gegen die offizielle Dokumentation.

Dieser dritte Punkt ist entscheidend. Die Gegenprüfung dient nicht nur der Korrektheit. Sie hilft, die Information tatsächlich zu behalten. Wer eine KI-Antwort nur übernimmt, lernt weniger als jemand, der sie aktiv verifiziert.

Checkliste: Backend-Stack durch ein Projekt lernen

  1. Architektur vor Code: Zeichne die Grenzen zwischen deinen Apps, bevor du die erste Zeile schreibst.
  2. Verantwortung trennen: Jede App hat genau eine Aufgabe. API, Core-Logik, SDK und Frontends leben separat.
  3. Monorepo einsetzen: Turborepo oder Nx halten TypeScript-Typen und Dependencies synchron.
  4. Kafka für Entkopplung: Producer und Consumer unabhängig skalieren. Events persistieren für Replay.
  5. MongoDB-Aggregationen nutzen: Die aggregate()-Pipeline ist das zentrale Werkzeug für Analytics-Metriken.
  6. KI-Antworten gegenprüfen: Jede Information aus einem KI-Tool gegen die offizielle Dokumentation validieren.

Wie lernst du einen neuen Stack am effektivsten?

Häufig gestellte Fragen (FAQ)

Warum fünf separate Apps statt einer einzigen Anwendung?

Jede App hat eine klar definierte Verantwortung. Die API nimmt Events entgegen, die Core-App verarbeitet und speichert sie, das SDK macht die API konsumierbar, und zwei Frontend-Apps dienen als Tester und Dashboard. Diese Trennung folgt dem Single-Responsibility-Prinzip und ermöglicht unabhängige Skalierung.

Welche Rolle spielt Kafka in dieser Architektur?

Kafka entkoppelt den Event-Producer (API) vom Consumer (Core-App). Die API pusht Analytics-Events auf Kafka-Topics, ohne sich um Speicherung oder Geschäftslogik zu kümmern. Die Core-App konsumiert den Stream und führt Aggregationen, Anreicherung und Persistierung in MongoDB durch.

Was sind LTTB und Min-Max-Average bei Analytics-Metriken?

Beide sind Downsampling-Algorithmen für große Datensätze. LTTB (Largest-Triangle-Three-Buckets) teilt Daten in drei Bereiche und wählt Punkte, die das größte Dreieck bilden. So bleiben visuell wichtige Punkte wie Peaks und Täler erhalten. Min-Max-Average mittelt die Werte repräsentativer Zeiträume.

Warum ein Monorepo mit Turborepo statt separater Repositories?

Ein Monorepo bietet eine einzige Quelle für Dependency-Management, TypeScript-Definitionen und gemeinsame Utilities. Turborepo reduziert den operationellen Overhead durch Caching und intelligentes Build-System. Cross-Projekt-Änderungen bleiben automatisch synchron.

Autor hyretic

Senior Full-Stack Developer mit Fokus auf stabiler Software-Architektur, pragmatischem Engineering und der Realität von KI im Entwickler-Alltag. Seine Wurzeln liegen im praktischen Lösen komplexer Probleme unter realen Bedingungen.

github.com/hyretic-dev

Ähnliche Artikel