Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/adr/0001-jwt-stateless-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# ADR-0001: JWT Over Server-Side Sessions

**Status:** Accepted

## Context

Garden serves three distinct clients: the customer storefront (`garden-web`), the admin panel (`garden-admin`), and third-party API consumers via the outbound webhook system. All three need to authenticate requests to the same backend. The backend is designed to be horizontally scalable — multiple instances behind a load balancer — and may eventually serve mobile clients.

Server-side sessions require either sticky load balancing or a shared session store (Redis, database). Both add operational complexity and infrastructure coupling. Spring Security supports both models.

## Decision

Use short-lived JWT access tokens (signed with a symmetric HMAC-SHA256 secret) for request authentication, with a longer-lived refresh token stored in an `HttpOnly` cookie to allow silent re-authentication.

Permissions are encoded directly in the JWT claims (role + explicit permission set). The `JwtAuthFilter` validates the signature and expiry on every request without a database lookup.

## Consequences

**Positive:**
- Stateless — any backend instance can verify any token without shared state.
- Works identically for browser clients (cookie-based refresh) and API consumers (bearer token).
- No session store to operate or scale.

**Negative:**
- Tokens cannot be individually revoked before expiry. Mitigation: short access token TTL (15 min); logout invalidates the refresh cookie client-side.
- Permission changes take up to one TTL window to propagate to existing tokens.
- Secret rotation requires coordinated redeployment.
31 changes: 31 additions & 0 deletions docs/adr/0002-transactional-event-listeners.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# ADR-0002: Spring Domain Events for Post-Commit Side Effects

**Status:** Accepted

## Context

Several order and fulfillment transitions trigger side effects: confirmation emails, cancellation emails, shipping notifications, and auto-tagging. The naive approach is to call these services directly inside the `@Transactional` method (e.g., `emailService.send(...)` at the end of `confirmPayment()`).

The problem: if the email call is slow or throws, it either blocks the transaction or, if caught, silently swallows a real failure. Worse, the email fires even if the database transaction is later rolled back, resulting in a confirmation email for an order that was never committed.

## Decision

Use Spring's `ApplicationEventPublisher` to publish lightweight record events (e.g., `OrderConfirmedEvent`, `FulfillmentShippedEvent`) from within the transactional business logic. Listeners are annotated with `@TransactionalEventListener(phase = AFTER_COMMIT)` + `@Async("emailExecutor")`.

This guarantees:
1. The side effect only fires if the database transaction commits successfully.
2. Email sending happens on a dedicated thread pool, not the HTTP request thread.
3. Services like `OrderService` and `FulfillmentService` depend only on `ApplicationEventPublisher`, not on `EmailService` — a clean inversion.

Listener failures are caught and logged at ERROR level so alerting can pick them up without propagating back to callers.

## Consequences

**Positive:**
- No spurious emails on rollback.
- Email latency is off the hot path.
- Each concern (business logic, email, webhooks) is independently testable.

**Negative:**
- Delivery is best-effort: if the process crashes between commit and listener execution, the email is lost. Acceptable for transactional email; not acceptable for financial events (see ADR-0003 for the webhook approach).
- The async executor must be configured with an appropriate queue size to avoid silent task drops under load.
30 changes: 30 additions & 0 deletions docs/adr/0003-webhook-outbox-pattern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# ADR-0003: Outbox Table + Scheduled Poller for Outbound Webhooks

**Status:** Accepted

## Context

Garden allows merchants to register outbound webhook endpoints that receive real-time notifications for order, fulfillment, and invoice events. The simplest approach is to fire an HTTP POST synchronously when an event occurs. However, this creates a dual-write problem: if the database commits but the HTTP call fails (or vice versa), the subscriber either misses an event or receives a duplicate.

An alternative is to use a message broker (Kafka, RabbitMQ), but that introduces significant operational overhead for a system that does not otherwise require a broker.

## Decision

Use a `webhook_deliveries` table as an outbox. When an event occurs, `OutboundWebhookService.scheduleDelivery()` inserts a `WebhookDelivery` row within the same database transaction as the business operation. A separate `WebhookDispatchService` polls for `PENDING` deliveries on a 30-second schedule (protected by ShedLock to prevent duplicate dispatch across instances) and executes the HTTP POST.

Delivery records store: endpoint URL, HMAC-SHA256 signature, event type, payload, attempt count, last HTTP status, and next retry time. Failed deliveries are retried with exponential backoff (1 min → 1 hour → 1 day) up to 5 attempts.

Response headers include `X-Garden-Signature`, `X-Garden-Event`, and `X-Garden-Delivery` to allow subscribers to verify authenticity and deduplicate replays.

## Consequences

**Positive:**
- Delivery is atomic with the business operation — no dual-write risk.
- Retries with backoff survive transient subscriber downtime.
- Full delivery history is queryable from the admin panel.
- No external broker required.

**Negative:**
- At-least-once delivery: subscribers must be idempotent on `X-Garden-Delivery` ID.
- Up to 30-second dispatch latency (polling interval). Real-time use cases requiring sub-second delivery would need a different approach.
- The `webhook_deliveries` table grows indefinitely without a pruning job.
27 changes: 27 additions & 0 deletions docs/adr/0004-flyway-versioned-migrations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# ADR-0004: Flyway for Schema Evolution

**Status:** Accepted

## Context

Garden's schema spans roughly 60 tables across multiple logical domains (checkout, catalog, b2b, inventory, content). Schema changes need to be: reproducible across local, CI, and production environments; tracked in version control alongside the code that depends on them; and safe to apply without manual DBA intervention.

Options considered: Flyway, Liquibase, Hibernate `ddl-auto=update`, and manual SQL scripts.

## Decision

Use Flyway with versioned SQL migration scripts (`V{n}__{description}.sql`). Hibernate DDL auto-generation is disabled (`ddl-auto=validate`), so Flyway is the single source of truth for schema state. Each migration is a plain SQL file committed alongside the feature branch that requires it.

Testcontainers spins up a real PostgreSQL instance for integration tests, and Flyway runs the full migration chain before each test suite. This ensures migration correctness is continuously verified.

## Consequences

**Positive:**
- Schema history is auditable in git: every change has an author, a PR, and a description.
- `ddl-auto=validate` fails fast on startup if the schema drifts from the entity model, catching mismatches before they hit production.
- Integration tests run against the exact schema production will see.

**Negative:**
- Migrations are irreversible by default (Flyway Community Edition does not support undo scripts). Rollback requires a new forward migration.
- Renaming a column requires a multi-step migration (add new column, backfill, remove old column) rather than a single `ALTER TABLE ... RENAME`.
- With 75+ migrations, cold-start time on a fresh database adds ~2-3 seconds at startup.
29 changes: 29 additions & 0 deletions docs/adr/0005-stripe-checkout-and-tax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# ADR-0005: Stripe Checkout + Stripe Tax

**Status:** Accepted

## Context

Garden needs to collect payment from B2C shoppers and calculate sales tax across multiple jurisdictions. Options range from building a custom payment form (full PCI scope) to delegating to a hosted checkout (minimal PCI scope). Tax calculation could be handled by a third-party service (TaxJar, Avalara) or by Stripe's built-in tax product.

## Decision

Use **Stripe Checkout** (hosted payment page) for the standard checkout path. The backend creates a Checkout Session with line items and redirects the browser to Stripe's hosted page. Payment confirmation arrives via Stripe webhook (`checkout.session.completed`), which drives the order to `PAID`.

A manual reconciliation endpoint (`POST /orders/{id}/sync-payment`) polls Stripe for session status to handle webhook delivery failures.

Use **Stripe Tax** (automatic tax calculation mode enabled on the Checkout Session) rather than integrating a separate tax service. Stripe Tax infers the customer's tax jurisdiction from the shipping address and applies the correct rate at checkout time. The resulting tax amount is stored on the order after payment confirmation.

## Consequences

**Positive:**
- PCI scope is minimal — card data never touches Garden's servers.
- Stripe Tax eliminates a separate vendor integration and keeps tax rates in sync automatically.
- Stripe's SDK handles idempotency, retries, and webhook signature verification.
- `stripePaymentIntentId` enables direct refunds via the Stripe API without storing payment method details.

**Negative:**
- Stripe's hosted page has limited UI customisation compared to a custom form.
- Stripe Tax coverage varies by country; markets not covered require a separate solution.
- Webhook delivery is not guaranteed: the sync-payment fallback adds complexity that must be tested and monitored.
- Stripe fees apply to every transaction; not suitable if the merchant routes payments through a different processor.
35 changes: 35 additions & 0 deletions docs/adr/0006-b2b-quote-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# ADR-0006: B2B Quote → Review → PDF → Accept → Order Flow

**Status:** Accepted

## Context

B2B buyers often need to negotiate pricing before committing to a purchase. Unlike a standard consumer checkout, a B2B transaction may involve: custom line-item pricing, a formal PDF document for procurement review, internal spending-limit approval, and eventual conversion to a net-terms invoice rather than a Stripe payment.

The simplest approach would be to allow admin users to create orders directly with custom pricing. But this bypasses the buyer's own review and approval step, and generates no auditable paper trail.

## Decision

Implement a multi-step quote workflow:

1. **Buyer submits a quote request** from a special quote cart (items + quantities, no pricing). Status: `REQUESTED`.
2. **Admin assigns and prices** each line item individually. Status: `PRICED`.
3. **Admin generates a PDF** (Thymeleaf template → OpenHTMLtoPDF → S3) and sends it to the buyer. Status: `SENT`.
4. **Buyer accepts or rejects** the quote. On acceptance, the system converts the quote to an `Order` with the negotiated line-item prices. Status: `ACCEPTED`.
5. If the resulting order total exceeds the buyer's **spending limit**, it is routed to a company owner/manager for approval before payment proceeds.
6. Payment can be via Stripe Checkout or, for buyers with an approved credit account, **net terms** (invoice issued immediately, payment recorded manually later).

Each status transition emits a timeline event and triggers the appropriate notification email (buyer and internal staff).

## Consequences

**Positive:**
- Buyers receive a formal, signed-off PDF they can submit to their own procurement process.
- Custom pricing is negotiated explicitly rather than implied by price list rules.
- The spending-limit gate enforces company purchasing policies without manual oversight of every order.
- Net terms path lets credit-approved buyers complete checkout without a real-time payment.

**Negative:**
- Adds significant state machine complexity: `QuoteRequest` has 7 statuses, each with its own valid transitions.
- PDF generation is synchronous and CPU-bound; large quotes with many line items can add latency to the `send PDF` step.
- The quote-to-order conversion must carefully handle inventory reservation timing to avoid overselling during negotiation.
12 changes: 12 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Architecture Decision Records

This directory captures significant design decisions made during the development of Garden, a B2B-focused e-commerce platform. Each record follows the lightweight [Nygard format](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions): context, decision, consequences.

| # | Title | Status |
|---|-------|--------|
| [0001](0001-jwt-stateless-auth.md) | JWT over server-side sessions | Accepted |
| [0002](0002-transactional-event-listeners.md) | Spring domain events for post-commit side effects | Accepted |
| [0003](0003-webhook-outbox-pattern.md) | Outbox table + scheduled poller for outbound webhooks | Accepted |
| [0004](0004-flyway-versioned-migrations.md) | Flyway for schema evolution | Accepted |
| [0005](0005-stripe-checkout-and-tax.md) | Stripe Checkout + Stripe Tax | Accepted |
| [0006](0006-b2b-quote-workflow.md) | Quote → review → PDF → accept → order flow | Accepted |
Loading