DifabelZone
1. High-Level Architecture
DifabelZone is a single deployable Spring Boot application organized in strict layers: HTTP entry points on top, business logic in the middle, and persistence at the bottom. Every request crosses the same security boundary before reaching a controller.
- Request flow. A JWT from the
Authorization: Bearerheader or an HttpOnly cookie is parsed byAuthTokenFilter, which loads the user, validates the token against the blacklist, and seedsSecurityContext. TheRateLimitInterceptorthen guards throughput before controllers run. - Security boundary placement. Token validation, rate limiting, and exception translation all sit in the filter chain — before any business logic, so unauthorized or abusive traffic never touches the service layer.
- Async surfaces. WebSocket handlers under
/ws/notificationsand/ws/chatpush real-time updates (persisted first, then broadcast), and SendGrid sends transactional email asynchronously.
2. Project Overview & Context
The Problem. Standard e-commerce platforms do not surface accessibility information, do not distinguish disability-owned businesses, and give donors no structured way to support accessibility-focused campaigns. Verification of business owners, accessibility attributes of products, and donation tracking are all ad-hoc and manual.
The Goal. Build a secure, documented, scalable REST API — under /api/v1 with OpenAPI/Swagger UI — that runs the full inclusive-commerce loop: catalog, cart, orders, reviews, DOB verification, donation crowdfunding, and real-time notifications, with hardened auth and observability built in from the start.
3. Core Backend Logic & Features
Role-Based Access Control (RBAC)
Three roles — ROLE_USER, ROLE_SELLER, ROLE_ADMIN — are seeded with a @JoinTable user_role many-to-many relation. URL-level rules are enforced in SecurityConfig (/admin/** admin-only, /auth/** and GET /public/** open, everything else authenticated), while fine-grained ownership rules are enforced in the service layer: only the seller who owns a product can edit it, only the owner of a review/address can mutate it, and only the owner of a wishlist can modify it.
Business logic
- Order placement (checkout). A single
@Transactionalmethod validates the address, snapshots price and discount intoOrderItem, decrements stock per line, clears the cart, and writes anOrderStatusHistoryrow plus an email + notification — all-or-nothing. - Donation crowdfunding. Donating to an active wishlist increments
collectedAmount; when it reaches the target the wishlist auto-transitions toFUNDEDand the beneficiary gets a notification. Self-donation to your own wishlist is rejected. - Flash-sale scheduling. Start/end times are validated and overlapping sales for the same product are rejected so a product never has two concurrent promotions.
- Review integrity. One review per (product, user) is enforced at the database level (
findByProductProductIdAndUserUserId) — duplicates are rejected and updates must go through PUT.
Security & auth
- JWT pair — 30-minute access token (Bearer header or HttpOnly cookie) + 7-day refresh token with rotation; login revokes the user's old refresh tokens.
- Token blacklist — logout blacklists the JWT in Redis (TTL = remaining lifetime) in the
dockerprofile, or an in-memory map onh2/dev. - Brute-force protection —
FailedLoginTrackerrequires reCAPTCHA after 3 failed logins;RateLimitInterceptorenforces 120 req/min general and 10 req/min on auth endpoints (HTTP 429). - Observability — a
CorrelationIdFilterpushes a trace ID into the MDC for every request, and Prometheus metrics are exposed via Actuator.
4. Database & Data Modeling
Database choice. PostgreSQL 16 in production (with Flyway-managed migrations) because of its relational integrity for order/transaction data, plus H2 for fast in-memory dev/test. Redis 7 is added as a cache and token-blacklist backend only where it is needed.
Key relationships (ERD highlights):
| Relation | Cardinality | Note |
|---|---|---|
| User ↔ Role | M:N | join table user_role |
| User → Cart / Wishlist / RefreshToken | 1:1 | one per user |
| Product → Category | N:1 | category_id |
| Product → ProductImage | 1:N | isPrimary flag |
| Product ↔ AccessibilityAttribute | M:N | core accessibility feature |
| Order → OrderItem | 1:N | price snapshot at purchase time |
| Order → Payment | 1:1 | payment-gateway fields stored |
| DonationWishlist → Donation | 1:N | aggregates collectedAmount |
Performance strategy.
- Pagination everywhere via
Pageable+ aPagedResponse<T>envelope — never return unbounded lists. - Dynamic filtering with
JpaSpecificationExecutor(keyword, category, price range, stock, accessibility attributes). - 23+ indexes added through Flyway migrations on the hot FK/lookup columns.
- Anti-N+1 with
@EntityGraph(roles)andLEFT JOIN FETCHon orders. - Optimistic locking via
@VersiononProduct,Order, andCartto keep concurrent writes consistent.
5. Tech Stack & Engineering Trade-offs
| Concern | Choice |
|---|---|
| Language / Framework | Java 17 + Spring Boot 3.4 |
| Persistence | PostgreSQL 16 + Hibernate 6 + Flyway |
| Cache | Redis 7 (Spring Data Redis) |
| Auth | Spring Security 6 + JJWT 0.12.6 |
| API docs | SpringDoc OpenAPI / Swagger UI |
| SendGrid | |
| Realtime | Raw Spring WebSocket |
| Deploy | Docker Compose + Kubernetes + GitHub Actions |
Trade-off decisions.
- Spring Boot + Java over a lighter stack: the ecosystem gives us first-class transaction management, declarative security filters, and mature JPA — critical for an app where money (orders, donations) moves through transactions.
- Redis-backed blacklist behind an interface (
TokenBlacklistService) with profile-based implementations (in-memory vs Redis): production-grade invalidation without slowing down local dev, and the choice is a single bean — not a rewrite. - Raw WebSocket over STOMP: fewer moving parts for simple push-notification delivery; the trade-off is that we implement session management and auth ourselves.
- Record-based DTOs + ModelMapper: immutable payloads with less boilerplate at the cost of a small reflection overhead, acceptable at this request volume.
6. Engineering Challenges & Solutions
The Challenge — keeping stock consistent under concurrent orders. Two orders checking stock and decrementing it in the same window could both pass validation. Read-then-write inside a transaction is safe against partial writes, but not against lost updates.
The Solution. @Version optimistic locking on Product, Order, and Cart — the entity version is incremented on every update, and a conflicting write fails with ObjectOptimisticLockingFailureException instead of silently overwriting. Combined with snapshotting price/discount into OrderItem, order history stays correct even if the catalog price later changes.
The Challenge — invalidating tokens and scattered joins. Logout needs the JWT to die immediately, and lazy loading could hammer the DB with N+1 queries on user/order reads.
The Solution. A dual-backend blacklist service (Redis with TTL, in-memory fallback for dev) plus @EntityGraph/JOIN FETCH on the read-heavy aggregates. @Cacheable("productById") / ("categories") with @CacheEvict on writes keeps reads cheap, and disableCachingNullValues() avoids caching empty lookups.
The Challenge — hard limits on abuse. Open auth endpoints are a magnet for brute-force login attempts.
The Solution. Layered hardening: failed-login tracking that escalates to reCAPTCHA after 3 tries, per-IP sliding-window rate limiting that returns structured HTTP 429 responses, and a CorrelationIdFilter so every 429/error in the logs can be traced back to a single request across services.
Honest limitations. Flash-sale discounts are currently display/scheduling-only (not yet applied to checkout pricing), and a couple of aggregation-heavy paths load data before slicing in memory. These are flagged in the codebase as the next optimization targets.
7. Deployment & Configuration
All configuration files live in the repo, so everything below can be copied and adapted. The project runs on a docker Spring profile in production-like setups, with h2/dev for local work.
backend/src/main/resources/application.yml — base
spring:
profiles:
active: h2
springdoc:
default-produces-media-type: application/json
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui/index.html
urls[0]:
name: DifabelZone API
url: /api/v1/api-docs
show-actuator: false
writer-with-order-by-keys: true
server:
servlet:
context-path: /api/v1
Sets the global API prefix /api/v1 and wires Springdoc (Swagger UI + JSON export).
backend/src/main/resources/application-docker.yml — production-like
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://db:5432/difabelzone}
username: ${DB_USERNAME:difabelzone}
password: ${DB_PASSWORD}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: validate
show-sql: false
database: postgresql
database-platform: org.hibernate.dialect.PostgreSQLDialect
flyway:
enabled: true
baseline-on-migrate: true
baseline-version: 0
locations: classpath:db/migration
cache:
type: redis
data:
redis:
host: ${REDIS_HOST:redis}
port: ${REDIS_PORT:6379}
timeout: 2000
application:
security:
jwt:
secret-key: ${JWT_SECRET}
expiration: 1800000
jwtCookieName: difabelZoneToken
refresh-cookie-name: difabelZoneRefresh
refresh-expiration: 604800000
sendgrid:
api-key: ${SENDGRID_API_KEY}
from-email: noreply@difabelzone.com
server:
port: 8088
app:
rate-limit:
enabled: true
default-max: 120
auth-max: 10
captcha:
enabled: true
secret-key: ${RECAPTCHA_SECRET_KEY}
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
Key points: secrets come from env vars, ddl-auto: validate fails fast on schema drift (Flyway owns the schema), Redis is the cache + token-blacklist backend, and Prometheus is exposed via Actuator.
backend/src/main/resources/application-h2.yml — tests / CI
spring:
datasource:
url: jdbc:h2:mem:difabelzone;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa
driver-class-name: org.h2.Driver
h2:
console:
enabled: true
path: /h2-console
jpa:
hibernate:
ddl-auto: create-drop
database-platform: org.hibernate.dialect.H2Dialect
flyway:
enabled: false
cache:
type: simple
application:
security:
jwt:
secret-key: ${JWT_SECRET:fw9o87DcKWuto23KkrukJk+BxsEDvclFO15jzGigFEHhUFIA5IBv0iuLs6x3Sbx3}
expiration: 1800000
secure-cookie: false
server:
port: 8088
app:
rate-limit:
enabled: false
captcha:
enabled: false
In-memory H2, no Flyway, simple cache, and rate limiting/CAPTCHA turned off — this is the profile the CI smoke test boots with.
Dockerfile — multi-stage build
# Build stage
FROM maven:3.9.9-eclipse-temurin-17 AS build
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests -B
# Run stage — minimal JRE with busybox for healthcheck
FROM eclipse-temurin:17-jre-alpine
ARG PROFILE=dev
ARG APP_VERSION=0.0.1-SNAPSHOT
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=build /build/target/backend-*.jar /app/app.jar
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8088
ENV JAVA_OPTS="-Xms256m -Xmx512m"
ENV SPRING_PROFILES_ACTIVE=${PROFILE}
VOLUME /app/logs
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD wget -q --spider http://localhost:8088/api/v1/public/categories || exit 1
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar --spring.profiles.active=$SPRING_PROFILES_ACTIVE"]
Stage 1 compiles the jar (dependency:go-offline caches dependencies); stage 2 is a slim JRE running as a non-root appuser, with a healthcheck that polls the public categories endpoint.
docker-compose.dev.yml — local stack
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: difabelzone
POSTGRES_USER: difabelzone
POSTGRES_PASSWORD: difabelzone_dev
ports:
- "5433:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U difabelzone -d difabelzone"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6380:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
app:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8088:8088"
environment:
SPRING_PROFILES_ACTIVE: docker
JWT_SECRET: dev-jwt-secret-key-for-development-only
RECAPTCHA_SECRET_KEY: ""
SENDGRID_API_KEY: ""
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
develop:
watch:
- action: rebuild
path: ./backend/src
- action: sync+restart
path: ./backend/target/classes
target: /app/classes
volumes:
pgdata:
depends_on: condition: service_healthy guarantees the app only starts after Postgres and Redis pass their healthchecks; the develop.watch block enables hot-reload during development.
docker-compose.prod.yml — server deployment
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: difabelzone
POSTGRES_USER: difabelzone
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U difabelzone -d difabelzone"]
redis:
image: redis:7-alpine
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
backend:
image: ghcr.io/${GITHUB_REPOSITORY:-hendrowunga/difabelzone}/backend:${IMAGE_TAG:-main}
restart: unless-stopped
ports:
- "8088:8088"
environment:
SPRING_PROFILES_ACTIVE: docker
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
DB_URL: jdbc:postgresql://db:5432/difabelzone
DB_USERNAME: difabelzone
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
REDIS_HOST: redis
REDIS_PORT: "6379"
RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY:-}
SENDGRID_API_KEY: ${SENDGRID_API_KEY:-}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
labels:
- "com.centurylinklabs.watchtower.enable=true"
watchtower:
image: containrrr/watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: --interval 60 --label-enable --cleanup
profiles:
- auto-update
volumes:
pgdata:
The ${VAR:?required} syntax fails fast when secrets are missing. Watchtower (in the auto-update profile) redeploys the backend within 60s of a new GHCR image.
.github/workflows/ci.yml — build + smoke test
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
JAVA_VERSION: '17'
JAVA_DISTRIBUTION: 'temurin'
jobs:
compile:
name: Compile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }}
cache: maven
- name: Compile
run: cd backend && ./mvnw compile -q -B
test:
name: Test
runs-on: ubuntu-latest
needs: compile
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }}
cache: maven
- name: Run tests
run: cd backend && ./mvnw test -B
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: backend/target/surefire-reports/
build:
name: Build JAR
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }}
cache: maven
- name: Build JAR
run: cd backend && ./mvnw clean package -DskipTests -B
- name: Upload JAR artifact
uses: actions/upload-artifact@v4
with:
name: backend-jar
path: backend/target/backend-*.jar
docker:
name: Docker Build & Smoke Test
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Download JAR
uses: actions/download-artifact@v4
with:
name: backend-jar
path: backend/target/
- name: Build Docker image
run: |
docker build --build-arg APP_VERSION=0.0.1-SNAPSHOT \
-t difabelzone-backend:${{ github.sha }} -t difabelzone-backend:latest backend/
- name: Smoke test
run: |
docker run -d --name difabelzone-test -p 8088:8088 \
-e SPRING_PROFILES_ACTIVE=h2 \
-e JWT_SECRET=${{ secrets.JWT_SECRET || 'test-secret-key-for-ci-32chars-minimum-length!!' }} \
difabelzone-backend:${{ github.sha }}
sleep 20
curl -sf http://localhost:8088/api/v1/public/categories | head -c 100
curl -sf http://localhost:8088/api/v1/v3/api-docs | head -c 100
curl -sf -X POST http://localhost:8088/api/v1/auth/signin \
-H "Content-Type: application/json" \
-d '{"username":"user1","password":"password1"}' | head -c 100
curl -sf http://localhost:8088/api/v1/public/donation-wishlists | head -c 100
docker stop difabelzone-test
Pipeline stages: compile → test → build JAR → on main, build the image and run a live smoke test (categories, OpenAPI docs, real login, donation wishlists) against the h2 profile.
.github/workflows/deploy.yml — GHCR + SSH deploy
name: Deploy
on:
workflow_dispatch:
inputs:
environment:
type: choice
options: [staging, production]
push:
branches: [main]
tags: ['v*']
jobs:
build-and-push:
name: Build & Push Docker Image
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: maven
- name: Run tests
run: cd backend && ./mvnw test -B
- name: Build JAR
run: cd backend && ./mvnw clean package -DskipTests -B
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GHCR_PAT }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/backend
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=,format=short
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: backend
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: build-and-push
environment: ${{ inputs.environment || 'staging' }}
if: ${{ always() }}
steps:
- uses: actions/checkout@v4
- name: Deploy via Docker Compose over SSH
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.DEPLOY_HOST }}
port: ${{ secrets.DEPLOY_PORT || '22' }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -e
cd /opt/difabelzone
docker compose pull backend
docker compose up -d --remove-orphans backend
for i in $(seq 1 30); do
if docker compose exec -T backend wget -q --spider http://localhost:8088/api/v1/public/categories 2>/dev/null; then
echo "Backend healthy after ${i}s"; break
fi
sleep 2
done
docker image prune -f
- name: Notify deployment status
if: always()
run: echo "Deployment completed: ${{ job.status }}"
Deploy secrets live in GitHub Actions environment variables (GHCR_PAT, DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY), so nothing sensitive is in the repo.
Kubernetes manifests (k8s/)
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: difabelzone-backend
namespace: difabelzone
spec:
replicas: 2
selector:
matchLabels:
app: difabelzone-backend
template:
metadata:
labels:
app: difabelzone-backend
spec:
containers:
- name: backend
image: ghcr.io/difabelzone/backend:latest
imagePullPolicy: Always
ports:
- containerPort: 8088
envFrom:
- configMapRef: { name: difabelzone-config }
- secretRef: { name: difabelzone-secret }
resources:
requests: { memory: "512Mi", cpu: "256m" }
limits: { memory: "1Gi", cpu: "512m" }
livenessProbe:
httpGet: { path: /api/v1/public/categories, port: 8088 }
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet: { path: /api/v1/public/categories, port: 8088 }
initialDelaySeconds: 30
periodSeconds: 30
# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: difabelzone-backend
namespace: difabelzone
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: difabelzone-backend
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
The ConfigMap holds non-secret env (SPRING_PROFILES_ACTIVE, DB_URL, Redis host/port), the Secret holds base64 JWT_SECRET, DB credentials and the reCAPTCHA key, and kustomization.yaml rolls everything up (namespace + configmap + secret + deployment + service + hpa + ingress), with an nginx Ingress exposing /api/v1/ on api.difabelzone.example.com. Deploy with kubectl apply -k k8s/.