System architecture
Three views of the system at increasing zoom: context (who and what), containers (deployable units and their wires), and components (what lives inside the API). Then the request lifecycle and the cross-cutting concerns that touch every feature.
L1 · System context
From the outside, CPLK is one product. Underneath it is a single web app, a single API and a small number of supporting services. Identity and payments are bought, not built.
(SEO discovery)"]):::person AG(["Agency staff
(super admin · admin · agent)"]):::person OWN(["Property owner
(individual)"]):::person STAFF(["CPLK staff
(approver · finance · editor · support)"]):::person CPLK["CPLK Platform
Multi-tenant commercial property SaaS"]:::system KC["Keycloak
Identity provider"]:::external PH["PayHere
Payment gateway (LK)"]:::external R2["Cloudflare R2
Object storage"]:::external SMTP["SMTP relay
Transactional email"]:::external PUB -- "browses, inquires" --> CPLK AG -- "manages listings, billing, team" --> CPLK OWN -- "lists own properties" --> CPLK STAFF -- "approves, audits, supports" --> CPLK CPLK -- "OIDC · JWKS · admin REST" --> KC CPLK -- "checkout · webhook · recurring charge" --> PH CPLK -- "presigned upload · public read" --> R2 CPLK -- "transactional mail" --> SMTP
L2 · Containers
The deployable units. The web front-end and the API are independently deployed. The API talks to Postgres directly; messaging and SSE are optional in dev and required in multi-node production.
(dev/uat VPS)"]:::web CFP["Cloudflare Pages
(prod web)"]:::web WEB["Next.js 16 standalone
SSR · ISR · routes"]:::web end subgraph BACKEND["Backend VPS"] direction TB API["Spring Boot 3 API
port 8080 · /api"]:::api JOBS["Schedulers
ShedLock · recurring · cleanup"]:::api end subgraph STATE["State"] direction TB PG[("PostgreSQL 16")]:::store REDIS[("Redis
SSE pub/sub")]:::store MQ["RabbitMQ
notifications"]:::store end subgraph EXTERNAL["External"] direction TB KC["Keycloak"]:::ext PH["PayHere"]:::ext R2[("Cloudflare R2")]:::ext MAIL["SMTP"]:::ext end NEXTC -- HTTPS --> NGINX NEXTC -- HTTPS --> CFP NGINX --> WEB CFP --> WEB WEB -- SSR fetch --> API NEXTC -- JSON/Bearer --> API API --> PG API --> REDIS API --> MQ JOBS --> PG JOBS --> API API --> KC API --> PH PH -. webhook .-> API API --> R2 API --> MAIL
L3 · Backend components
Inside the API, code is organised by feature package
(com.cplk.api.property, ...payment, etc.). Each package
carries its own controllers, services, repositories and DTOs. Cross-cutting
concerns live in common, config, security,
tenant and audit.
@RestController"]:::ctrl PS["PropertyService
PropertyWorkflowService
PropertyApprovalService"]:::svc PR["PropertyRepository
(JpaRepository)"]:::repo end subgraph CROSS["Cross-cutting"] direction TB SEC["SecurityFilterChain
OAuth2 resource server"]:::cross RL["RateLimitFilter
Bucket4j"]:::cross TF["TenantFilter
+ TenantFilterAspect"]:::cross AUD["AuditService
FinancialAuditService"]:::cross EXH["GlobalExceptionHandler
→ ApiResponse error"]:::cross end SEC --> RL --> TF --> PC PC --> PS --> PR PS --> AUD PS -. throws .-> EXH
Request lifecycle
The “happy path” for an authenticated API call:
TenantContext is always cleared in the filter's finally block to prevent thread-local leakage.Cross-cutting concerns
Security & identity
- Spring Security 6, OAuth2 resource server, JWT only (sessionless).
- JWKS fetched from Keycloak (
auth.cplk.org/realms/cplk). - Realm roles + custom
roleclaim mapped toROLE_*authorities. - Per-method authorization via
@PreAuthorize+SecurityUtils.
Full detail → Security & IAM.
Multi-tenancy
- Servlet filter sets
TenantContextThreadLocal from JWT. - AOP aspect enables Hibernate filter on every repository call.
@BypassTenantFilterescape hatch for SUPER_ADMIN paths.- Integration test (
TenantIsolationIntegrationTest) guards regression.
Rate limiting
Bucket4j buckets per endpoint & IP:
/auth/login· 5/min/auth/register· 3/h/inquiries/properties/{id}· 10/h/contact-us· 5/15min- Default · 100/min
Audit & financial ledger
AuditLog— every write, async, REQUIRES_NEW.FinancialAuditLog— money & credit movements, immutable.- Captures IP, user agent, request id, old/new values (JSONB).
Caching
Caffeine in-memory caches, evicted on writes:
properties,property-slug,property-featured,property-recenttransactionSummary· 1h TTL- Lookup data (packages, cities, districts)
Notifications
- In-app via
NotificationService+ SSE broadcaster. - Email via Thymeleaf templates + JavaMailSender (
@Async). - Multi-node: Redis pub/sub for SSE; RabbitMQ for delivery queues + DLQ.
Architecture conventions
| Layer | Convention | Enforced by |
|---|---|---|
| Controllers | Return ResponseEntity<ApiResponse<T>> | ArchUnit · ApiResponseRule |
| Repositories | Interfaces extending JpaRepository, in *Repository package | ArchUnit · RepositoryRule |
| Services | @Service + @RequiredArgsConstructor, no HTTP layer access | ArchUnit · ServiceRule |
| Injection | Constructor injection only — no @Autowired fields | ArchUnit · InjectionRule |
| Entities | Extend BaseEntity / ImmutableEntity; @Table annotation | ArchUnit · EntityRule |
| DTOs | Suffix Request, Response, Dto; never leak entities | ArchUnit · NamingRule + EntityLeakageRule |
| Pagination | @PageableDefault(size = 20, sort = "createdAt") | Convention & review |
| API paths | UUID for portal, slug for SEO — both variants when entity has slug | Convention (Property, Agency) |
| Exceptions | Typed app exceptions, mapped by GlobalExceptionHandler | ArchUnit · no raw RuntimeException |
| Code quality | SLF4J only — no System.out | ArchUnit · CodeQualityRule |
Module boundaries
Packages are intentionally not cycle-free everywhere — but the allowed cycles are explicitly whitelisted in ArchUnit. The diagram below shows the dependency direction within the backend:
tenant
common · config"]:::cross AG["agency"]:::core US["user"]:::core PR["property"]:::core SU["subscription"]:::money PA["payment"]:::money FA["financial"]:::money BO["boost"]:::money IN["inquiry · contact · favorite"]:::core NO["notification"]:::ops AU["audit"]:::ops ST["storage"]:::ops BL["blog · article"]:::content SEC --> AG & US & PR & SU & PA & IN & NO & AU & BL & ST AG <--> US PR <--> SU PA <--> SU PR --> NO PR --> AU PA --> FA PA --> AU BO --> SU IN --> NO BL --> AU
property ↔ subscription because credits flow both ways). Everything depends on common · config · security · tenant.Quality attributes
TenantIsolationIntegrationTest.payments.order_id; locked reads in payment service.