Full-Stack E-Commerce Platform
A modular-monolith e-commerce backend and React frontend, with an asynchronous payment pipeline.
Overview
I built a Go backend and React storefront covering product browsing, guest carts, checkout and queued payments. Three coursework phases took the application from basic commerce through concurrency handling, security features and load testing.
- context
- Coursework
- status
- Archived learning project
- role
- Solo
- started
- Jan 2026
- setting
- Containerised coursework application, tested against real PostgreSQL, Redis and RabbitMQ services with synthetic load.
- Go
- Gin
- PostgreSQL
- GORM
- Redis
- RabbitMQ
- +5
Key decisions
- Separate business rules from HTTP and storage
- Handler, Service and Repository modules separate business rules from HTTP and storage. A shared cart interface switches between Redis and PostgreSQL.
- Keep order changes atomic
- A stock-row lock protects inventory while stock deduction, order creation and cart clearing commit in one transaction.
- Move payment work off the request path
- RabbitMQ moves Stripe calls and fulfilment into a worker. Manual acknowledgements support redelivery if processing is interrupted.
Demonstrated results
- One refresh winner under concurrent requests
- Ten concurrent requests originally minted four live tokens. The conditional-update fix produced one winner and nine rejected attempts.
- All checks passed in the corrected 100-user load run
- At 100 virtual users, the corrected run recorded 100% of checks passing and a p90 of 50.52 ms against a 2,000 ms requirement.
- Behaviour exercised against real services
- The suite combines 86 integration test functions against real PostgreSQL, Redis and RabbitMQ services with 13 unit tests. Scenarios include concurrent refresh, price tampering and payment idempotency.
Known limitations
- Encryption-key configuration still needs fixing
- Missing or invalid configuration silently selects a hardcoded key in the archived helper. This later source-review finding remains unresolved.
- The reuse heuristic can revoke legitimate sessions
- A second legitimate refresh of an older token can trigger session revocation. Atomic rotation does not fix this separate heuristic.
- The stress run missed its latency target
- With up to 1,000 virtual users and a two-core, 1 GB backend, p95 reached 12.80 s against a 5 s criterion. These are synthetic-workload results.
Seven modules, three layers
I organised the backend under internal/modules/, with each module split three
ways. The rules keep HTTP handling, business decisions and storage separate:
| Layer | Rule | Role |
|---|---|---|
| Handler | "No business logic or database queries are allowed here." | Parses, validates, returns JSON |
| Service | "No knowledge of HTTP or raw SQL queries." | Business rules, coordinates repositories |
| Repository | "Only deals with Database models and returning data to the Service." | All GORM queries |
CartService shows why I used this separation. It is injected with both a
Redis and a PostgreSQL repository, and routes on whether the caller is
authenticated:
type CartService struct {
redisRepo CartRepository // guest carts
postgresRepo CartRepository // authenticated carts
productRepo *product.ProductRepository
}
if userID != "" {
return s.postgresRepo.AddItem(ctx, userID, item)
}
return s.redisRepo.AddItem(ctx, guestID, item)
The handler never learns which store was used. On login the guest cart is merged into the user's, so the redirect is not visible to the client either.
Refresh-token rotation and concurrent requests
Rotation has two separate concerns: ensuring that only one request can use a valid token, and deciding how to respond when a revoked token is presented again.
Refresh tokens are opaque, 32-byte hex, stored only as a SHA-256 hash, held in an HttpOnly cookie, and rotated on every single use. Each new token records the token that created it, which builds a chain:
RT1 ──▶ RT2 ──▶ RT3 ──▶ RT4
✗ ✗ ✗ ✓ current
revoked revoked revoked
When a refresh arrives, the service hashes it and looks it up. If the token exists and is already revoked, someone is replaying it, and the response depends on one thing:
if storedToken.IsRevoked {
if time.Since(storedToken.CreatedAt) < 2*time.Second {
return "", "", errors.New("concurrent refresh detected, please retry")
}
_ = s.repo.RevokeUserTokens(storedToken.UserID.String())
return "", "", errors.New("security alert: token reuse detected")
}
The window is measured from the token's created_at, not from when it was
revoked. So the check is really "is this token younger than two seconds old?".
That is worth stating precisely, because it is not the same question as "was this
revoked in the last two seconds?".
This is a limited heuristic, not proof of whether reuse is benign or malicious. Two legitimate refresh requests can arrive milliseconds apart for a token that was issued hours ago. If the second request reads it after revocation, the age check takes the theft-alert path and revokes every session for that user. Conversely, replay of a newly issued token can fall inside the retry window.
There is a third layer that the project's own architecture documentation does not describe. Only the journal does. Revocation is a conditional update, and the row count decides the race:
UPDATE refresh_tokens SET is_revoked = true WHERE id = ? AND is_revoked = false
RowsAffected == 1 means this request won and may mint a new token. 0 means
another request revoked it after this request read it as valid; this path returns
the concurrent-refresh error without revoking all sessions. The conditional update
prevents multiple requests from winning rotation for the same token. It does not
remove the false-positive risk in the earlier already-revoked branch. The
concurrency test makes the single-winner behaviour measurable:
| HTTP 200 | HTTP 401 | Live tokens | |
|---|---|---|---|
| Before | 4 | 6 | 4 |
| After | 1 | 9 | 1 |
Ten goroutines released from a barrier against one token. Four simultaneous refreshes each minting a live token is the bug; one winner is the correct answer.
Client-side triggers and test timing
The journal records a client-side false positive and a test-timing issue:
- React 18 Strict Mode double-mounts
useEffectin development, firing two refreshes. The backend read the second as a replay and revoked every session, because the code classified the second request as theft. ArefreshPromisesingleton inAuthContextprevented duplicate refreshes from that client path; it did not change the backend's age heuristic. - The rotation test itself replayed inside the token-age window, so it took the
retry path and failed. Fixed by backdating the test token's
created_atby ten seconds.
Order transactions and stock locking
Isolation is where the concurrency work lives. Placing an order takes a pessimistic row lock:
clause.Locking{Strength: "UPDATE"} // emits SELECT … FOR UPDATE
Two buyers racing for the last unit: one gets 201, the other 400. The
transaction covers stock deduction, the order row, the order items and the cart
clear together, so a failure at any point leaves none of them applied.
Payments do not wait for Stripe
POST /payments validates the order and returns 202 Accepted immediately,
publishing to RabbitMQ. A worker consumes, calls Stripe, updates the order and
sends the email. Two separate AMQP channels keep publishing from blocking on the
consumer, and manual acknowledgements mean an unacked message is redelivered
rather than lost.
Encryption at rest
Exactly two columns, both on orders, both AES-256-GCM through GORM hooks.
BeforeSave encrypts, AfterFind decrypts, so nothing above the model layer
knows:
func (o *Order) BeforeSave(tx *gorm.DB) error {
if o.ShippingAddress != nil {
jsonBytes, _ := json.Marshal(o.ShippingAddress)
encryptedAddr, _ := utils.Encrypt(string(jsonBytes))
o.ShippingAddressEncrypted = encryptedAddr
}
if o.StripePaymentIntentID != "" {
o.StripePaymentIntentID, _ = utils.Encrypt(o.StripePaymentIntentID)
}
return nil
}
That protection depends on selecting the correct key. The fallback below undermines it when the application is misconfigured.
The key fallback
During the later source review, I found a fallback the documentation did not describe:
if len(key) != 32 {
// Fallback for dev, panic in prod
return []byte("12345678901234567890123456789012")
}
The comment says it panics in production. It does not. It returns a hardcoded key
that is published in the repository. A deployment that forgot ENCRYPTION_KEY would
encrypt real shipping addresses with it, and nothing would fail loudly.
The same helper has a second problem. The guide says the key must be exactly 32
bytes base64-encoded, but openssl rand -base64 32 produces 44 characters. That
would fail the length check and silently take the fallback path.
Load testing: correcting the workload
I corrected three problems in the load-test setup before treating latency as a useful result:
| Run | Change | Checks | p90 | Root cause found |
|---|---|---|---|---|
| 1 | baseline | 8.72% | 3.84 ms | IP rate limiter rejecting the load |
| 2 | limiter off | 83.33% | 62.78 ms | shipping_address shape wrong |
| 3 | payload fixed | 86.90% | 50.26 ms | Inventory depletion |
| 4 | stock forced to 10,000 | 100.00% | 50.52 ms | PASS |
Run 1 is the trap. The p90 of 3.84 ms looks like a triumph until you read the check rate:
Ironically, the latency for successful requests was extremely low. However, this is because most requests were being rejected almost instantly.
The limiter was configured at 5 tokens/second, and the entire test came from one IP. Order placement succeeded 0 times out of 83.
In run 3, add-to-cart failed 405 times and order placement 420. Their sum matches
the reported http_req_failed count of 825, supporting the diagnosis that these
failures came from stock depletion rather than a separate, unexplained error spike.
Run 4: 100% checks, 0.00% failures, p90 of 50.52 ms against a 2,000 ms requirement, at 100 concurrent users.
Stress testing: latency under resource limits
Ramping 1 → 1,000 VUs over 12 minutes with the backend capped at 2 CPU cores and 1 GB. The rate limiter was raised to 5,000/s to reduce interference from the limiter:
| Metric | Result |
|---|---|
| Max throughput | ~104 req/s |
| p50 / p90 / p95 | 1.92 s / 8.90 s / 12.80 s |
| max | 61.00 s |
| HTTP failures | 5 of 77,551 (0.00%) |
p95 failed the 5-second criterion. The report identifies several constraints:
- CPU pressure. The report attributes much of the slowdown to bcrypt during authentication and scheduling 1,000 concurrent routines on two cores. Its comparison with a roughly 400 req/s baseline has no raw output in the repository, so that comparison alone does not establish a CPU-only bottleneck.
- Zero 500s and zero dropped database connections at 12-second latency. The system stayed reliable while being far too slow, which is a different problem from the one the criterion measures.
- A hot row. Every one of the 1,000 users bought the same product id, so a single row was contended by the entire fleet and serialised by row-level locking.
The roughly 450 VU breaking point is read off the latency curve, not emitted as a dedicated capacity metric. Together with the shared-product workload, this limits how far I would generalise the result to other traffic patterns.
Why I left out catalogue response caching
I used Redis for guest carts but left the product catalogue without a Redis API response cache. The measured latency did not justify adding cache invalidation across filter combinations, especially where stale prices would affect correctness.
The original rationale also cited 95% bandwidth savings from HTTP caching. That figure was arithmetic on typical header sizes, not a captured byte-count metric. It is not a measured benefit. The documentation's cache-hit latency, database-load and memory figures are likewise projections for a cache that was not implemented.
Testing
99 test functions across 33 files: 86 integration, 13 unit. The integration tests run against real Postgres, Redis and RabbitMQ rather than mocks. That is why a race like the refresh-token one is caught by an assertion rather than by inspection.
The security-named tests are the ones worth naming: IDOR, XSS sanitisation, price tampering, rate-limiter header spoofing, concurrent refresh handling, and payment idempotency.
Other decisions
- VAT is snapshotted at purchase. A 25.5% rate is stored on the order and each line item, so changing the rate later cannot rewrite what a historic order cost.
- Password reset never confirms an account exists. The endpoint returns an identical 200 either way.
- Admin routes re-check the database.
role = adminalone is not enough;is_2fa_enabledis read from the database on every request, so a token minted before 2FA was enabled cannot grant admin access. SetTrustedProxies(nil)meansX-Forwarded-Foris ignored outright for rate limiting, so the header cannot be used to evade it.- Self-signed TLS is generated automatically when
SERVER_PORT == 8443.
Interactive models
Explore the model below, or open another view to inspect the implementation. These are reconstructions from the project, not a live service.
Load-test results
LoadTestChart
4 runs toward a 2,000 ms p90 requirement, then a stress test.
p90 latency against the 2,000 ms requirement. Bar length is not to scale past 200 ms — run 4 is 50.52 ms, not ten times run 1.
requirements
- Sustain 100 concurrent users100 VUs
- Latency under 2s at p9050.52 ms
- Error rate0.00%
- Success rate100.00%
client:visible
Refresh-token rotation and concurrent requests
TokenRotation
Rotation chain, an age-based reuse heuristic, and the conditional update that decides the race.
chain
window measured from created_at
So the check is “is this token younger than 2 seconds old?” — not “was it revoked in the last 2 seconds?” In this already-revoked branch, concurrent requests carrying an older token can trigger session revocation, while replay of a newly issued token can fall inside the retry window. Age alone does not establish intent.
third layer — the row count decides
UPDATE refresh_tokens SET is_revoked = true WHERE id = ? AND is_revoked = false
After reading the token as valid, RowsAffected === 1 wins rotation; 0 means another request revoked it before the update. This path returns a concurrent-refresh error. A request that initially reads an already-revoked token takes the separate age-based branch.
ten goroutines, one token
| build | 200 | 401 | live tokens |
|---|---|---|---|
| before | 4 | 6 | 4 |
| after | 1 | 9 | 1 |
Before the conditional-update lock, four simultaneous refreshes each minted a live token. One winner is the correct answer.
client-side triggers and test timing
- React 18 Strict Mode double-mounting useEffect in development fired two refreshes. The backend classified the second as theft and revoked every session. A refreshPromise singleton in AuthContext prevented duplicate calls from that client path; the backend heuristic was unchanged.
- The rotation test replayed inside the token-age window, so it took the retry path instead of the expected session-revocation path. Fixed by backdating the test token created_at by ten seconds.
client:visible
Tables and encrypted columns
SchemaExplorer
11 tables, 110 columns. Two are encrypted.
Order header. Two columns are encrypted at rest, and the money columns are VAT snapshots.
| column | type | roles |
|---|---|---|
id | uuid | PK |
user_id | uuid | FKindex |
statusDefaults to 'pending_payment'. The state machine lives in application code, not the database. | varchar(50) | |
subtotalNet of VAT. | decimal(10,2) | |
vat_amount25.5% Finnish VAT, snapshotted at purchase so historic orders stay accurate when the rate changes. | decimal(10,2) | |
total_priceGross. | decimal(10,2) | |
stripe_payment_intent_idnullAES-256-GCM, in a BeforeSave hook. | ‖ encrypted ‖ | encrypted |
shipping_addressnullAES-256-GCM as JSON, in a BeforeSave hook. Raw database dumps are useless without the app secret. | ‖ encrypted ‖ | encrypted |
cancellation_reasonnull | text | |
payment_failure_reasonnullThe Stripe error, verbatim. | text | |
paid_atnull | timestamptz | |
processing_atnull | timestamptz | |
shipped_atnull | timestamptz | |
delivered_atnull | timestamptz | |
cancelled_atnullStamped for cancelled, refund_pending and refunded. | timestamptz | |
shipping_method_idMade NOT NULL by a later migration. | uuid | FK |
shipping_costDefaults 0. | decimal(10,2) | |
tracking_numbernull | text | |
created_at | timestamp | |
updated_at | timestamp |
orders.stripe_payment_intent_id — AES-256-GCM in GORM BeforeSave / AfterFind hooks
orders.shipping_address — AES-256-GCM in GORM BeforeSave / AfterFind hooks
* undocumented in the ERD
client:visible
API routes and access tiers
ApiExplorer
49 endpoints, grouped by auth state.
49 of 49 shown
- admin14
- authenticated12
- public23
client:visible
Order lifecycle and side effects
StateMachineExplorer
9 states. Click one for its legal exits and their side effects.
on entering:
- · Stock deducted under a FOR UPDATE row lock
- · Cart cleared in the same transaction
- · Shipping address encrypted
- · VAT snapshotted onto the order and its items
- · A cron job auto-cancels after 15 minutes with the reason "Order expired - auto cancelled after timeout"
legal exits:
whole map
- pending_payment → paid, cancelled
- paid → processing, cancelled, refund_pending
- payment_failed → pending_payment, cancelled
- processing → shipped, cancelled, refund_pending
- shipped → delivered, refund_pending
- delivered → refund_pending
- refund_pending → refunded
- cancelled → (terminal)
- refunded → (terminal)
Everything dimmed is not reachable in one step from pending_payment. An illegal pair returns invalid status transition from %s to %s.
client:visible
Caching layers and measurement limits
CacheWaterfall
3 layers, none of them benchmarked.
- L4Redis catalogue cachenot implemented
stale-while-revalidate
Serves the previous value immediately while refetching in the background.
the caveat
The "304 is about 50 bytes, so caching cuts bandwidth by roughly 95%" claim is arithmetic on typical header sizes. No byte-count metric was ever captured. The reduction is plausible and unmeasured.
- · ~50 bytes on a 304, and the "95% bandwidth reduction" derived from it — Never measured. No byte-count metric was captured — arithmetic on typical header sizes.
- · Redis cache hit latency of 1–2 ms, database load down ~20%, 100–500 MB memory — Projections for code that was never implemented, filed under "Future Considerations".
- · 500 ms API call producing 1.8 s of spinners, versus 0.9 s cached — Illustrative UX numbers, not load-test output.
The project’s own decision not to add the fourth layer cites “diminishing returns — HTTP caching already provides 95% bandwidth savings”. That is the unmeasured number doing the arguing, which is the reason it is worth being explicit about.
client:visible
Stack and source figures
how it fits together
- backend
-
-
Go 1.24 + GinModular monolith: seven modules, each split Handler → Service → Repository, with the layering enforced by convention rather than by the compiler. -
JWT access + opaque refresh15-minute HS256 access token in memory; 7-day 32-byte-hex refresh in an HttpOnly cookie, stored only as a SHA-256 hash and rotated on every single use.
-
- persistence
-
-
PostgreSQL 15 + GORMEleven tables. Order placement runs in one transaction with a SELECT … FOR UPDATE row lock, so two buyers racing for the last unit get 201 and 400 rather than a negative stock count. -
Redis 7Guest carts under a 24h TTL, merged into PostgreSQL when the owner authenticates.
-
- payments
-
-
RabbitMQPOST /payments returns 202 and publishes; a worker calls Stripe, updates the order and sends the email. Manual acks so an unacked message is redelivered rather than lost. -
StripeThe only external dependency, and the request path deliberately avoids it.
-
- security
-
-
AES-256-GCM in GORM hooksTwo columns on orders: shipping address and Stripe payment intent id. Encrypted in BeforeSave and decrypted in AfterFind, so nothing above the model layer knows. -
bcrypt + 2FAbcrypt at DefaultCost; admin routes re-read is_2fa_enabled from the database on every request rather than trusting the token.
-
- frontend
-
-
React 19 + TypeScript + Material UI v7React Query with stale-while-revalidate as the first caching layer.
-
- performance
-
-
k6Four load runs chasing a 2,000 ms p90, then a 12-minute ramp to 1,000 VUs on a deliberately capped 2-core backend.
-
- operations
-
-
Mailhog + pgAdminContainerised email sink and database inspection.
-
- endpoints
- 49 23 public · 12 authenticated · 14 admin
- tables
- 11 Ten are in the project's ERD. shipping_methods arrived in a later migration and was never documented.
- test functions
- 99 86 integration against real services, 13 unit
- p90 latency
- 50.52 ms Against a 2,000 ms requirement, at 100 concurrent users.
- admin 2FA
- required Every admin route checks the database, not the token.
Full issue record · 4 entries
what broke, and what happened
- 2 fixed / 4
- 2 unresolved / 4
Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.
- fixed
The first load test returned low latency while only 8.72% of checks passed
`Ironically, the latency for successful requests was extremely low. However, this is because most requests were being rejected almost instantly.`
then: Rate limiter raised, request payload corrected, then stock forced to 10,000. Run 4 reached 100% of checks at a p90 of 50.52 ms against a 2,000 ms requirement.
- fixed
The rotation chain minted four live tokens when ten clients refreshed at once
`Before: map[200:4 401:6] with 4 valid tokens. After: map[200:1 401:9] with 1 valid token.`
then: Revocation became a conditional UPDATE whose RowsAffected decides the race, so exactly one of ten concurrent refreshes mints a token.
- unresolved
The encryption key helper falls back to a hardcoded key when the environment variable is missing
`12345678901234567890123456789012`. The code comment says "panic in prod" where it actually falls through.
then: Not fixed, and still in the repository. A deployment missing ENCRYPTION_KEY encrypts real shipping addresses with a published key and reports success.
- unresolved
Two documentation bugs still disagree with the code
`docs/Journal.md:2947` marks `GET /orders/shipping-methods` Public while the route is registered on the authenticated group; a documented PATCH is a PUT in the handler.
then: Unreconciled in the source repository. Recorded here instead of being quietly corrected in a private repo nobody can diff against.