Skip to content

Architecture

stdOWL edited this page Jun 22, 2026 · 3 revisions

Architecture

Technical overview of the Customer Management service: the runtime stack, how a request flows through the filter chain, the main internal flows (SPA serving, auth, rate limiting), the data layer, and the code layout.

Stack at a glance

Layer Tech
HTTP server Reactor Netty (non-blocking)
Framework Spring Boot 3.5, Spring WebFlux (reactive) + Kotlin coroutines (suspend handlers)
Security Spring Security resource server — HS256 JWT (Nimbus), BCrypt
Persistence Spring Data R2DBC (reactive) on in-memory H2
Migrations Flyway (runs over JDBC, same H2)
Rate limiting Token bucket — in-memory or Redis
API docs springdoc-openapi + Swagger UI
Frontend React 19 + TypeScript + MUI, bundled into the jar and served same-origin

Everything — API and the SPA — is one Spring Boot app, one container, one origin.

Request flow

flowchart TD
  C["Browser / API client"] -->|HTTPS| CR["Cloud Run (TLS termination)"]
  CR -->|"HTTP + X-Forwarded-*"| N["Reactor Netty"]
  N --> FH["ForwardedHeaderTransformer (pre-filter)"]
  FH --> SPA["SpaWebFilter"]
  SPA --> RL["RateLimitWebFilter"]
  RL --> SEC["Spring Security (CORS + JWT)"]
  SEC --> HM{"Handler mapping"}
  HM -->|"/api/**"| CT["Controllers -> Services -> R2DBC -> H2"]
  HM -->|"SPA routes"| RS["Static handler -> index.html"]
  HM -->|"/swagger-ui, /v3/api-docs"| DOC["springdoc / Swagger UI"]
Loading

ForwardedHeaderTransformer runs at the framework level before the WebFilter chain; the rest are WebFilters run in @Order.

The WebFilter chain (order matters)

# Filter Order Purpose
0 ForwardedHeaderTransformer pre-filter (framework) Applies X-Forwarded-Proto/Host so generated URLs use the external https host. Deployed profiles only (server.forward-headers-strategy=framework).
1 SpaWebFilter HIGHEST_PRECEDENCE Forwards SPA navigation routes to /index.html (see below).
2 RateLimitWebFilter -110 Per-IP token bucket; just ahead of security so abusive traffic is dropped early.
3 Spring Security (WebFilterChainProxy) -100 CORS, then JWT bearer validation.
4 Handler mapping Controllers, the static resource handler (SPA), or springdoc.

SPA serving flow

The React SPA is built and bundled into the jar under static/, so the backend serves both UI and API from one origin (no CORS between them). SpaWebFilter decides how each GET is handled:

flowchart TD
  G["GET request"] --> Q{"reserved prefix?<br/>/api /actuator /swagger /v3/ /webjars"}
  Q -->|yes| PASS["pass through (API / docs)"]
  Q -->|no| EXT{"path has a file extension?"}
  EXT -->|"yes (e.g. /assets/app.js)"| STATIC["served as a static file"]
  EXT -->|"no (e.g. /customers)"| REWRITE["rewrite path to /index.html -> SPA shell"]
Loading

So a refresh on /customers returns index.html and React Router renders the page; /assets/*.js is served as-is; /api/* reaches the API untouched. Because ES module scripts are fetched in CORS mode (they send an Origin even same-origin), deployed profiles enable forward-headers-strategy and add the service's own origin to the CORS allow-list so those requests aren't rejected.

Authentication flow (JWT)

sequenceDiagram
  participant C as Client
  participant A as AuthController / AuthService
  participant J as JwtService
  participant R as Resource server (JwtDecoder)
  C->>A: POST /api/auth/login {username, password}
  A->>A: load user, verify BCrypt hash (off the event loop)
  A->>J: mint HS256 JWT (sub, roles, iss, exp)
  J-->>C: { accessToken, tokenType, expiresIn, role }
  Note over C,R: every subsequent request
  C->>R: GET /api/me  (Authorization: Bearer <token>)
  R->>R: verify signature + issuer + expiry; map "roles" claim to ROLE_*
  R-->>C: 200  (or 401 missing/invalid token, 403 wrong role)
Loading
  • Login is the only entry point (no registration); accounts are seeded by Flyway.
  • The token carries sub (username) and roles; the resource server maps rolesROLE_USER / ROLE_ADMIN.
  • Per-endpoint roles are enforced with method-level @PreAuthorize on the controllers (ADMIN writes; USER + ADMIN read).

Rate-limiting flow

  • Key = auth: or general: + client IP (remoteAddress, or the first X-Forwarded-For entry when ratelimit.trust-forwarded-for=true).
  • Tier: /api/auth/** → strict (low capacity); everything else → general. Docs/health are excluded.
  • RateLimiter.tryConsume(key, capacity, refillPeriod) against a token bucket — in-memory (local) or Redis (deployed). Returns 429 + Retry-After when empty, adds X-RateLimit-Remaining to every response, and fails open if the backend is unavailable.

Data layer

  • The app reads/writes via R2DBC (reactive) on in-memory H2.
  • Flyway owns the schema + seed and runs over JDBC against the same H2 instance (shared DB name + DB_CLOSE_DELAY=-1 keeps it alive for the JVM). Migrations: db/migration/V1__init.sql (tables + audit trigger) and V2__seed_users.sql.
  • Auditing: Spring Data R2DBC auditing stamps created_by / last_modified_by / timestamps; an H2 trigger (CustomerAuditTrigger) writes every customer update/delete into audit_log.

Code layout

src/main/kotlin/com/allica/customermanagement/
├── CustomermanagementApplication.kt   # Spring Boot entry point
├── auth/        # login + self-service password change
│   └── AuthController, AuthService, AuthDtos
├── security/    # security wiring
│   ├── SecurityConfig          # filter chain, CORS, JWT decoder, method security
│   ├── JwtService              # mints HS256 tokens
│   ├── JwtProperties           # secret / issuer / expiry
│   └── AppUserDetailsService   # loads users by username
├── user/        # AppUser entity + AppUserRepository
├── customer/    # Customer entity, repository, service, controller, DTOs
├── audit/       # AuditLog entity/repo + CustomerAuditTrigger (H2 trigger)
├── ratelimit/   # RateLimiter (interface) + InMemory/Redis impls,
│                #   RateLimitWebFilter, RateLimitProperties, TokenBucket
├── web/         # MeController (/api/me), AdminController (/api/admin)
└── config/      # OpenApiConfig, SpaWebFilter, AutoAuthSwaggerIndexTransformer, AuditingConfig
frontend/src/
├── api/         # axios client + auth/customers calls
├── auth/        # AuthContext (token in localStorage)
├── components/  # Layout, ProtectedRoute, ConfirmDialog, AllicaLogo
├── pages/       # Login, Customers, CustomerFormDialog, Profile
└── App.tsx, main.tsx, theme.ts, types.ts

Services & responsibilities

Component Responsibility
AuthService Verify credentials, mint tokens (via JwtService), change password
JwtService Build and sign HS256 JWTs
AppUserDetailsService Load AppUser by username
CustomerService Customer CRUD + search/pagination/sort; 404 / 409 handling
RateLimiter (InMemory / Redis) Token-bucket consume for a key
RateLimitWebFilter Tier selection, client-IP derivation, 429 + headers
SpaWebFilter Route SPA navigations to index.html
AutoAuthSwaggerIndexTransformer Inject the Swagger "auto-apply token after login" snippet
CustomerAuditTrigger H2 trigger writing audit_log on update/delete
AuditingConfig Enable R2DBC auditing (@CreatedBy / @LastModifiedBy)

Runtime & deployment

One container (multi-stage Docker: frontend build → jar → JRE) runs on Cloud Run (scale-to-zero). Spring profiles select the rate-limit backend (in-memory locally, Redis in deployed envs) and the forwarded-headers behavior. See Setup to run it, and terraform/README.md for the Cloud Run + CI/CD setup.

Clone this wiki locally