
BajaBox
Ein Boutique-Shop für handgemachte Geschenke — eigenes Backend, eigenes Admin-Panel.
In Produktion (bajabox.pl), vor dem Verkaufsstart ·
- Next.js
- React
- TypeScript
- .NET 10
- PostgreSQL
- Clean Architecture
E-Commerce für handgemachte Unikate. Kein Lager, keine Stückzahlen.
Screenshots
Ziehen Sie die Leiste zur Seite, um zu blättern. Klicken Sie auf einen Screenshot, um ihn zu vergrößern, und wechseln Sie dann mit den Pfeiltasten.Vollständige Beschreibung
BajaBox ist ein produktiver E-Commerce-Shop für eine Marke, die handgefertigte Dekoboxen, Spardosen und Bilderrahmen verkauft — Geschenke für Kinder, von denen jedes einzelne individuell entsteht statt am Fließband. Das Monorepo enthält einen kundenseitigen Shop, ein Admin-Panel für die Inhaberin und die API, die beide bedient.
WAS ES VON EINEM TYPISCHEN SHOP UNTERSCHEIDET
Jedes Produkt ist ein Unikat. BajaBox geht nie davon aus, dass zwei identische Exemplare desselben Produkts existieren, daher kennt der Warenkorb überhaupt keine Stückzahl — ein Produkt lässt sich höchstens einmal hinzufügen, und sein Status (Verfügbar / Reserviert / Verkauft) ist Teil des Domänenmodells, kein nachträglicher Zusatz.
RESERVIERUNG STATT LAGERBESTAND
Eine Bestellung reserviert ein Produkt für das Zahlungsfenster (standardmäßig 15 Minuten) durch ein einziges atomares, bedingtes UPDATE in Postgres — ohne eigene Reservierungstabelle, ohne Sperren auf Anwendungsebene. Eine abgelaufene Reservierung wird bereits beim Lesen erkannt, bevor ein Hintergrundjob sie überhaupt erreicht.
DAS ADMIN-PANEL
Eine einzige Inhaberin meldet sich mit einem kurzlebigen JWT an und verwaltet den Katalog (anlegen, bearbeiten, Status ändern, Foto ersetzen, löschen) sowie Bestellungen (ansehen, als bezahlt markieren, stornieren) — jede dieser Aktionen ist idempotent und sicher gegenüber einer Reservierung, die im selben Moment automatisch im Hintergrund abläuft.
LIEFERUNG UND ZAHLUNG
Kurier oder InPost-Paketautomat, Banküberweisung mit einer serverseitig generierten Bestellnummer im Verwendungszweck. Lieferpreis, Produktname und Zahlbetrag kommen nie vom Client — der Server berechnet alles selbst aus den aktuellen Produktdaten und seiner eigenen Konfiguration.
EIGENE INFRASTRUKTUR
Das .NET-Backend und das Next.js-Frontend laufen auf einem VPS als systemd-Dienste, ausgeliefert durch ein Skript, das alles lokal baut und die fertigen Artefakte per SSH hochlädt — ohne Docker, ohne externe PaaS.
Technologie
Leistungsumfang: Produktdesign, Architektur, Frontend- und Backend-Implementierung, Deployment und Pflege
- Next.js 16 (App Router)
- React 19
- TypeScript
- Tailwind CSS v4
- Redux Toolkit
- Leaflet (parcel locker picker)
- .NET 10
- ASP.NET Core (minimal API)
- EF Core 10
- PostgreSQL
- JWT (HMAC-SHA256)
- MailKit (SMTP)
- xUnit
- Testcontainers
- concurrency & migration tests
- OVHCloud VPS
- systemd
- nginx (reverse proxy + TLS)
- SSH + rsync deploy script
Fallstudie — Architektur und Technologie
12 Min.
Das Projekt in einem Absatz
BajaBox ist ein E-Commerce-Shop für eine Boutique-Marke, die handgefertigte Dekoboxen, Spardosen und Bilderrahmen verkauft — Kindergeschenke, bei denen jedes einzelne Stück anders ist. Das Monorepo enthält drei nach Clean Architecture gebaute Teile: einen öffentlichen Shop und ein Admin-Panel in einer gemeinsamen Next.js-16-App sowie eine ASP.NET-Core-API mit PostgreSQL als einziger Quelle der Wahrheit. Die interessantesten Entscheidungen dieses Projekts kamen nicht aus der Technologie, sondern aus der Natur des Produkts selbst: Sobald jedes Stück in genau einem Exemplar existiert, musste das gesamte Datenmodell — Warenkorb, Reservierung, Bestellung — das von der ersten Codezeile an widerspiegeln.
Ein Stück, kein Lagerbestand
Ein typischer Online-Shop führt Lagerbestände und eine Stückzahl pro Warenkorbzeile. BajaBox hat bewusst beides nicht. Ein Produkt ist ein physisches, handgefertigtes Objekt mit drei möglichen Zuständen — Available, Reserved, Sold — und der Warenkorb kennt keine Stückzahl: Ein Produkt lässt sich höchstens einmal hinzufügen, und der Versuch, etwas bereits Vorhandenes erneut hinzuzufügen, ändert nur die Beschriftung des Buttons auf „Im Warenkorb", statt einen Fehler anzuzeigen. Diese Produktentscheidung (im Briefing wörtlich festgehalten: „BajaBox never assumes two identical units of the same product exist") prägt fast jede spätere Schicht — vom Redux-Warenkorbzustand im Frontend bis dahin, wie das Backend Produkte während des Checkouts reserviert.
Architektur — dieselben Grenzen auf beiden Seiten des Stacks
Das Backend als echte .NET-Projekte, Abhängigkeiten zeigen nach innen:
BajaBox.Domain Entitäten und Enums (Product, Order, OrderItem, Payment...) — keine Abhängigkeiten
BajaBox.Application Use Cases, Validierung, Ports (IProductRepository, IOrderRepository...)
BajaBox.Infrastructure EF Core/Postgres, JWT, Bildspeicher, der Reservierungs-Cleanup-Hintergrundjob
BajaBox.Api minimale API, Request/Response-Contracts, ProblemDetails-Fehler-MappingDas Frontend ist eine einzige Next.js-App-Router-Anwendung, in der Shop (/sklep, /produkt/[slug], /koszyk, /checkout) und Admin-Panel (/admin/*) sich einen Server teilen, aber keinen Zustand — jeder API-Aufruf läuft über eine serverseitige BAJABOX_API_BASE_URL, die niemals direkt an den Browser gelangt, da das Backend kein CORS hat.
Reservierung ohne Reservierungstabelle
Die interessanteste Designentscheidung im gesamten Backend: Es gibt keine eigene `ProductReservation`-Tabelle. Da ein Produkt physisch als ein Exemplar existiert, ist „höchstens eine aktive Reservierung pro Produkt" bereits durch eine einzige nullable Spalte ReservedByOrderId auf der Product-Zeile garantiert — ohne zusätzlichen Unique-Constraint.
Die Nebenläufigkeitsgarantie ist ein einziges atomares, bedingtes UPDATE:
UPDATE products SET status = 'Reserved', reserved_by_order_id = @orderId,
reservation_expires_at = @expiresAt, updated_at = @now
WHERE id = @productId
AND (status = 'Available'
OR (status = 'Reserved' AND reservation_expires_at <= @now))Postgres sperrt die passende Zeile für die Dauer der Anweisung — eine zweite, gleichzeitige Bestellung für dasselbe Produkt wartet, bis die erste committet oder zurückgerollt wird, und dieselbe WHERE-Klausel bewertet dann erneut, was aus der Zeile tatsächlich geworden ist. Ein bereits gewonnenes Rennen lässt sich nicht noch einmal gewinnen. Dieselbe Bedingung (OR reservation_expires_at <= @now) sorgt dafür, dass eine abgelaufene Reservierung im Moment des Lesens erkannt wird, bevor irgendein Hintergrundprozess sie berührt hat — Korrektheit hängt nie davon ab, ob die Bereinigung kürzlich gelaufen ist.
Bei einer Mehrprodukt-Bestellung ist der gesamte Checkout eine Transaktion: Zuerst werden Order und ihre OrderItems eingefügt, dann wird jedes angeforderte Produkt sortiert nach Id reserviert — so versuchen zwei um eine überlappende Produktmenge konkurrierende Bestellungen immer, sie in derselben Reihenfolge zu sperren, was ein Deadlock durch Sperrreihenfolge ausschließt. Lässt sich ein Produkt nicht reservieren, wird die gesamte Transaktion — einschließlich der gerade eingefügten Bestellung — zurückgerollt, und es bleibt nichts zurück.
Aufräumen, das es nie eilig haben muss
Eine Reservierung läuft nach Reservation:DurationMinutes ab (standardmäßig 15). Ein Hintergrundjob (ReservationCleanupService) gibt abgelaufene Reservierungen periodisch frei und setzt die Bestellung PendingPayment → Cancelled — das ist aber reine Aufräumarbeit: Die Logik, die tatsächlich entscheidet, ob eine Reservierung noch gültig ist, steckt in genau derselben SQL-Bedingung, die jeder Lesevorgang ohnehin durchläuft. Der Hintergrundjob kann eine Minute zu spät laufen, ohne dass es im System jemand merkt.
Eine Bestellung als bezahlt markieren, ohne mit dem Ablauf zu wettrennen
Eine Administratorin kann eine Bestellung manuell als bezahlt markieren (POST /api/admin/orders/{id}/mark-as-paid) — ein reales Szenario bei Banküberweisungen, bei denen die Bestätigung asynchronon eintrifft. Diese Aktion nutzt exakt dasselbe Muster wie der automatische Ablauf: ein atomares, bedingtes ExecuteUpdateAsync in einer Transaktion, das Payment auf Succeeded, die Order auf Paid und jedes noch reservierte Produkt auf Sold setzt. Dadurch kann eine manuelle Zahlungsbestätigung nie mit dem automatischen Ablauf der Reservierung in einen gemischten Zustand wettrennen — Payment.Succeeded neben einer bereits Cancelled-Bestellung. Die Aktion ist zudem idempotent: Ein zweiter Aufruf auf einer bereits bezahlten Bestellung ist ein sicheres No-op, kein Fehler und kein zweites Payment.
Preise, die nie vom Client kommen
POST /api/orders akzeptiert nur eine Liste von Produkt-Ids und Lieferdetails — nie einen Preis, einen Produktnamen, Versandkosten oder einen fälligen Betrag. Der Server liest die aktuellen Product-Zeilen selbst, kopiert deren Preis und Namen als Snapshot in jedes OrderItem (dieselbe Regel „Snapshot, nie neu ableiten" gilt für die Versandkosten) und berechnet TotalAmount selbst. Eine spätere Änderung des Lieferpreises in der Konfiguration betrifft eine bereits aufgegebene Bestellung nie — auch nicht durch einen idempotenten Retry, der stattdessen die ursprüngliche Bestellung zurückgibt, statt erneut zu bepreisen.
Die Bestellnummer (BB-2026-000123) stammt aus einer Postgres-Sequenz über nextval() — per Definition atomar, ohne Sperren auf Anwendungsebene.
Idempotenz als eine Spalte, keine Middleware
Ein optionaler Idempotency-Key-Header auf POST /api/orders wird auf der Bestellung unter einem partiellen Unique-Index gespeichert (WHERE idempotency_key IS NOT NULL, sodass sich Requests ohne Schlüssel nie gegenseitig einschränken). Ein wiederholter Request mit demselben Schlüssel gibt die ursprüngliche Bestellung zurück, statt eine doppelte zu erzeugen — vorab geprüft, aber auch als Fallback, falls ein wirklich gleichzeitiger Retry seinen eigenen Reservierungsversuch gegen einen anderen Request mit demselben Schlüssel verliert, und schließlich als letzte Absicherung durch Abfangen der Unique-Constraint-Verletzung der Datenbank. Bewusst auf genau diesen einen Endpunkt beschränkt, statt eine generische Idempotenzschicht.
Eine Paketautomaten-Auswahl ohne offizielle API dahinter
BajaBox hat noch kein InPost-Geschäftskonto (keine eingetragene Firma/Steuernummer), daher ist die Paketautomaten-Auswahl im Checkout eine eigene Benutzeroberfläche, nicht das offizielle InPost-Widget. Sie wird von GET /api/delivery/inpost-points bedient, das die öffentliche, nicht authentifizierte Points-API von InPost aufruft (api-shipx-pl.easypack24.net/v1/points) — ohne Token — und eine Anfrage zuerst nach Punktname, dann Koordinaten, dann Postleitzahl, dann Stadt auflöst. Ein pragmatischer Übergang, bis eine echte Geschäftsintegration existiert.
Das Admin-Panel — ein Konto, keine Rollen
Die Admin-Authentifizierung besteht aus einem einzigen Konto, einem mit HMAC-SHA256 signierten JWT, ohne Refresh-Token — läuft es ab, meldet sich die Administratorin einfach erneut an. Es gibt nirgends im Code Standard-Zugangsdaten: Ein Passwort muss mit einem lokalen Tool (BajaBox.PasswordHasher) erzeugt werden, und nur dessen Hash landet in der Konfiguration. Ein Produktionshost ohne konfiguriertes AdminAuth:JwtSecret startet gar nicht erst — fail-closed, nicht fail-open.
Derselbe JWT schützt auch die Bearbeitung der Seite /o-mnie („Über mich"), die überhaupt nicht im Frontend fest codiert ist — sie lebt als eine einzelne AboutPage-Zeile in Postgres mit einer geordneten Liste von Blöcken (TextSection, Quote, BulletList), gespeichert in einem atomaren Request: Ein einziger ungültiger Block verwirft die gesamte Aktualisierung, sodass die Seite nie halb gespeichert zurückbleibt.
Ausgeliefert ohne Docker und ohne PaaS
Backend und Frontend laufen auf einem OVHCloud-VPS als zwei systemd-Dienste. Das Deploy-Skript baut beide Projekte lokal (dotnet publish, npm run build), verpackt die fertigen Artefakte und lädt sie über eine einzige authentifizierte SSH-Verbindung hoch (ein SSH-ControlMaster — ein Passwort für den ganzen Durchlauf), tauscht dann die Dateien auf dem Server aus und startet den Dienst neu, abgeschlossen mit einem Health-Check. Datenbankmigrationen sind bewusst aus diesem Skript ausgeklammert und werden als separater, von Hand geprüfter Schritt behandelt — die Reihenfolge ist immer dieselbe: erst das Schema nach vorne migrieren, dann den Code austauschen, nie umgekehrt, damit das alte, noch laufende Backend nie eine Spalte berührt, die es nicht kennt.
Was dieses Projekt tatsächlich gelehrt hat
Die stärkste Lektion aus BajaBox: Der Verzicht auf Stückzahlen im Warenkorb und auf eine eigene Reservierungstabelle ist keine Vereinfachung — es ist die konsequente Abbildung der Tatsache, dass ein Produkt als genau ein Exemplar existiert, durchgezogen vom „In den Warenkorb"-Button im Frontend bis zum einzigen bedingten UPDATE, das als einzige Stelle im System entscheidet, ob eine Reservierung erfolgreich war. Das, was ich wiederholen würde: Korrektheit auf eine bei jedem Lesevorgang ausgewertete SQL-Bedingung zu stützen statt auf die Disziplin eines Hintergrundjobs, sorgt dafür, dass das System auch dann korrekt bleibt, wenn im Hintergrund einmal etwas langsamer läuft als es sollte.
