
BajaBox
A boutique handmade-gifts store — its own backend, its own admin panel.
In production (bajabox.pl), ahead of sales launch ·
- Next.js
- React
- TypeScript
- .NET 10
- PostgreSQL
- Clean Architecture
E-commerce for one-of-a-kind handmade products. No stock, no quantities.
Screenshots
Drag the strip sideways to browse. Click a screenshot to enlarge it, then use the arrow keys.Full description
BajaBox is a production e-commerce store for a brand selling handmade decorative boxes, piggy banks and picture frames — gifts for children, each one made individually rather than mass-produced. The monorepo holds a customer-facing storefront, an owner's admin panel, and the API backing both.
WHAT MAKES IT DIFFERENT FROM A TYPICAL STORE
Every product is a one-off. BajaBox never assumes two identical units of the same product exist, so the cart has no concept of quantity at all — a product can be added at most once, and its status (Available / Reserved / Sold) is part of the domain model, not an afterthought.
RESERVATION INSTEAD OF STOCK
Placing an order reserves a product for the payment window (15 minutes by default) through a single atomic, conditional UPDATE in Postgres — no separate reservation table, no application-level locking. An expired reservation is recognized the moment it's read, before a background cleanup job ever gets to it.
THE ADMIN PANEL
A single owner logs in with a short-lived JWT and manages the catalogue (add, edit, change status, replace a photo, delete) and orders (view, mark as paid, cancel) — every one of those actions is idempotent and safe against the reservation expiring automatically in the background at the same moment.
DELIVERY AND PAYMENT
Courier or InPost parcel locker, bank transfer with a server-generated order number in the transfer title. Delivery price, product name and amount due never come from the client — the server computes all of it from the current product rows and its own configuration.
ITS OWN INFRASTRUCTURE
The .NET backend and the Next.js frontend run on one VPS as systemd services, shipped by a script that builds everything locally and uploads the finished artifacts over SSH — no Docker, no external PaaS.
Technology
Scope of work: Product design, architecture, frontend and backend implementation, deployment and upkeep
- 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
Case study — architecture and technology
12 min
The project in one paragraph
BajaBox is an e-commerce store for a boutique brand selling handmade decorative boxes, piggy banks and picture frames — children's gifts where every single piece is different. The monorepo holds three parts built on Clean Architecture: a public storefront and an admin panel sharing one Next.js 16 app, and an ASP.NET Core API with PostgreSQL as the single source of truth. This is a project where the most interesting decisions came not from the technology but from the nature of the product itself: once every item exists in exactly one copy, the entire data model — cart, reservation, order — had to reflect that from the first line of code.
One unit, no stock at all
A typical online store tracks stock levels and a quantity per cart line. BajaBox deliberately has neither. A product is a physical, handmade object with three possible states — Available, Reserved, Sold — and the cart has no concept of quantity: a product can be added at most once, and trying to add something already in the cart just relabels the button "In cart" instead of showing an error. This product decision (spelled out directly in the brief: "BajaBox never assumes two identical units of the same product exist") shapes almost every later layer — from the Redux cart state down to how the backend reserves products during checkout.
Architecture — the same boundaries on both sides of the stack
The backend as real .NET projects, dependencies pointing inward:
BajaBox.Domain entities and enums (Product, Order, OrderItem, Payment...) — zero dependencies
BajaBox.Application use cases, validation, ports (IProductRepository, IOrderRepository...)
BajaBox.Infrastructure EF Core/Postgres, JWT, image storage, the reservation-cleanup background job
BajaBox.Api minimal API, request/response contracts, ProblemDetails error mappingThe frontend is one Next.js App Router application where the storefront (/sklep, /produkt/[slug], /koszyk, /checkout) and the admin panel (/admin/*) share one server but no state — every call to the API goes through a server-side BAJABOX_API_BASE_URL, never exposed straight to the browser, since the backend has no CORS.
Reservation without a reservation table
The single most interesting design decision in the whole backend: there is no separate `ProductReservation` table. Since a product physically exists as one unit, "at most one active reservation per product" is already guaranteed by a single nullable ReservedByOrderId column on the Product row — no extra uniqueness constraint needed.
The concurrency guarantee is one atomic, conditional 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 locks the matching row for the statement's duration — a second, concurrent order for the same product waits until the first commits or rolls back, then the very same WHERE clause re-evaluates against whatever the row actually ended up as. There is no way to win a race someone else already won. The same clause (OR reservation_expires_at <= @now) means an expired reservation is recognized the moment it's read, before any background process has touched it — correctness never depends on cleanup having run recently.
For a multi-item order the whole checkout is one transaction: the Order and its OrderItems are inserted first, then every requested product is reserved sorted by id — so two orders racing over an overlapping product set always attempt to lock them in the same order, which rules out a lock-ordering deadlock. If any product fails to reserve, the whole transaction — including the just-inserted order — rolls back and nothing is left behind.
Cleanup that never has to hurry
A reservation expires after Reservation:DurationMinutes (15 by default). A background job (ReservationCleanupService) periodically releases lapsed reservations and flips the order PendingPayment → Cancelled, but that is pure housekeeping — the logic that actually decides whether a reservation is still valid lives in the very same SQL condition every read already goes through. The background job can run a minute late and nothing in the system notices.
Marking an order paid without racing expiry
An administrator can manually mark an order as paid (POST /api/admin/orders/{id}/mark-as-paid) — a real scenario with bank transfers, where confirmation arrives asynchronously. This action uses exactly the same pattern as automatic expiry: one atomic, conditional ExecuteUpdateAsync inside one transaction that flips Payment to Succeeded, the Order to Paid, and every product it still holds a reservation on to Sold. That makes it impossible for a manual payment confirmation to race automatic reservation expiry into a mixed state — payment Succeeded next to an order that is already Cancelled. The action is also idempotent: a second call on an already-paid order is a safe no-op, not an error and not a second Payment.
Prices that never come from the client
POST /api/orders accepts only a list of product ids and delivery details — never a price, a product name, a shipping cost, or an amount due. The server reads the current Product rows itself, copies their price and name into each OrderItem as a snapshot (the same "snapshot, never re-derive" rule applies to the shipping cost), and computes TotalAmount itself. A later change to the delivery price in configuration never touches an order already placed — not even through an idempotent retry, which returns the original order instead of pricing anything again.
The order number (BB-2026-000123) comes from a Postgres sequence via nextval() — atomic by definition, with no application-level locking.
Idempotency as one column, not a middleware
An optional Idempotency-Key header on POST /api/orders is stored on the order under a partial unique index (WHERE idempotency_key IS NOT NULL, so requests without a key never constrain each other). A retried request with the same key returns the original order instead of creating a duplicate — checked up front, but also as a fallback if a genuinely concurrent retry loses its own reservation attempt to another request carrying the same key, and finally as a last resort by catching the database's own uniqueness violation. Deliberately scoped to this one endpoint rather than a generic idempotency layer.
A parcel locker picker with no official API behind it
BajaBox doesn't have an InPost business account yet (no registered company/tax id), so the parcel-locker picker in checkout is a custom UI, not InPost's official widget. It's backed by GET /api/delivery/inpost-points, which calls InPost's public, unauthenticated points API (api-shipx-pl.easypack24.net/v1/points) — no token required — resolving a query by point name first, then coordinates, then postal code, then city. A pragmatic stand-in until a real business integration exists.
The admin panel — one account, no roles
Admin authentication is a single account, a JWT signed with HMAC-SHA256, no refresh tokens — once it expires, the administrator just logs in again. There are no default credentials anywhere in the code: a password has to be generated with a local tool (BajaBox.PasswordHasher) and only its hash goes into configuration. A production host with no AdminAuth:JwtSecret configured refuses to start — fail-closed, not fail-open.
The same JWT also guards editing the /o-mnie ("about me") page, which isn't hardcoded in the frontend at all — it lives as a single AboutPage row in Postgres owning an ordered list of blocks (TextSection, Quote, BulletList), saved in one atomic request: a single invalid block rejects the entire update, so the page can never be left half-saved.
Shipping it without Docker and without a PaaS
The backend and frontend run on one OVHCloud VPS as two systemd services. The deploy script builds both projects locally (dotnet publish, npm run build), packages the finished artifacts and uploads them over one authenticated SSH connection (an SSH ControlMaster — one password for the whole run), then swaps the files on the server and restarts the service, finishing with a health check. Database migrations are deliberately excluded from that script and treated as a separate, hand-reviewed step — the order is always the same: migrate the schema forward first, swap the code second, never the other way around, so the old, still-running backend never touches a column it doesn't know about.
What this project actually taught me
The strongest lesson from BajaBox: dropping cart quantities and a dedicated reservation table isn't a simplification — it's consistently modelling the fact that a product exists as exactly one unit, carried from the "Add to cart" button on the frontend all the way down to the single conditional UPDATE that is the only place in the system deciding whether a reservation succeeded. The thing I'd repeat: putting correctness on a SQL condition evaluated on every read, rather than on the discipline of a background job, means the system stays correct even when something in the background runs slower than it should.
