Skip to content
BajaBox
E-commerce

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
Short description

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

Frontend application
  • Next.js 16 (App Router)
  • React 19
  • TypeScript
  • Tailwind CSS v4
  • Redux Toolkit
  • Leaflet (parcel locker picker)
Backend
  • .NET 10
  • ASP.NET Core (minimal API)
  • EF Core 10
  • PostgreSQL
  • JWT (HMAC-SHA256)
  • MailKit (SMTP)
Testing
  • xUnit
  • Testcontainers
  • concurrency & migration tests
Delivery
  • 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 mapping

The 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.


Let's talk about the planned project

The first consultation is free and without obligation. A short description of the process and the expected outcome is enough.

Let's talk about the project