DifabelZone

13 min read
JavaSpring BootPostgreSQLRedisWebSocketDockerKubernetes

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.

DifabelZone high-level architecture

  • Request flow. A JWT from the Authorization: Bearer header or an HttpOnly cookie is parsed by AuthTokenFilter, which loads the user, validates the token against the blacklist, and seeds SecurityContext. The RateLimitInterceptor then 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/notifications and /ws/chat push 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 @Transactional method validates the address, snapshots price and discount into OrderItem, decrements stock per line, clears the cart, and writes an OrderStatusHistory row 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 to FUNDED and 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 docker profile, or an in-memory map on h2/dev.
  • Brute-force protectionFailedLoginTracker requires reCAPTCHA after 3 failed logins; RateLimitInterceptor enforces 120 req/min general and 10 req/min on auth endpoints (HTTP 429).
  • Observability — a CorrelationIdFilter pushes 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):

RelationCardinalityNote
User ↔ RoleM:Njoin table user_role
User → Cart / Wishlist / RefreshToken1:1one per user
Product → CategoryN:1category_id
Product → ProductImage1:NisPrimary flag
Product ↔ AccessibilityAttributeM:Ncore accessibility feature
Order → OrderItem1:Nprice snapshot at purchase time
Order → Payment1:1payment-gateway fields stored
DonationWishlist → Donation1:Naggregates collectedAmount

Performance strategy.

  • Pagination everywhere via Pageable + a PagedResponse<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) and LEFT JOIN FETCH on orders.
  • Optimistic locking via @Version on Product, Order, and Cart to keep concurrent writes consistent.

5. Tech Stack & Engineering Trade-offs

ConcernChoice
Language / FrameworkJava 17 + Spring Boot 3.4
PersistencePostgreSQL 16 + Hibernate 6 + Flyway
CacheRedis 7 (Spring Data Redis)
AuthSpring Security 6 + JJWT 0.12.6
API docsSpringDoc OpenAPI / Swagger UI
EmailSendGrid
RealtimeRaw Spring WebSocket
DeployDocker 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/.

← Back to portfolio