Engineering · L1 → L3

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.

flowchart TB classDef person fill:#fff7ed,stroke:#f97316,stroke-width:2px,color:#0f172a classDef system fill:#ecfeff,stroke:#0891b2,stroke-width:2px,color:#0f172a classDef external fill:#eef2ff,stroke:#1e3a5f,stroke-width:2px,color:#0f172a PUB(["Public visitor
(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
L1 — system context. Everyone interacts with the same product surface; internally we keep identity and payments as external systems with strict integration contracts.

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.

flowchart LR classDef web fill:#ecfeff,stroke:#0891b2,color:#0f172a classDef api fill:#eef2ff,stroke:#1e3a5f,color:#0f172a classDef store fill:#fff7ed,stroke:#f97316,color:#0f172a classDef ext fill:#f5f5f5,stroke:#64748b,color:#0f172a subgraph BROWSER["Browser"] direction TB NEXTC["Next.js (CSR portal)"]:::web end subgraph EDGE["Edge / hosting"] direction TB NGINX["Nginx TLS · HSTS
(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
L2 — containers. The SSR path goes browser → edge → Next.js → API; the CSR path goes browser → API. Schedulers run inside the API process under ShedLock to stay safe on multi-node deploys.

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.

flowchart TB classDef ctrl fill:#ecfeff,stroke:#0891b2,color:#0f172a classDef svc fill:#eef2ff,stroke:#1e3a5f,color:#0f172a classDef repo fill:#fff7ed,stroke:#f97316,color:#0f172a classDef cross fill:#fef2f2,stroke:#dc2626,color:#0f172a subgraph FEATURE["Feature package · e.g. property"] direction TB PC["PropertyController
@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
L3 — one feature package, common cross-cutting wiring. Every request flows through the same security → rate-limit → tenant chain before hitting a controller.

Request lifecycle

The “happy path” for an authenticated API call:

sequenceDiagram autonumber participant B as Browser / Next.js participant N as Nginx participant API as Spring API participant FC as SecurityFilterChain participant RL as RateLimitFilter participant TF as TenantFilter participant CTL as Controller participant SVC as Service participant ASP as TenantFilterAspect participant REPO as Repository participant PG as PostgreSQL B->>N: HTTPS · Authorization: Bearer JWT N->>API: forward (HTTP, internal) API->>FC: validate JWT (JWKS cache) FC->>RL: check token-bucket per IP/endpoint RL->>TF: extract agency_id claim TF->>TF: TenantContext.setCurrentTenant(agencyId) TF->>CTL: invoke @PreAuthorize-guarded method CTL->>SVC: @Valid DTO SVC->>ASP: repository call (advised) ASP->>REPO: session.enableFilter("tenantFilter", agencyId) REPO->>PG: SQL · WHERE agency_id = ? PG-->>REPO: rows REPO-->>SVC: entities SVC-->>CTL: DTO mapped via MapStruct CTL-->>API: ApiResponse<T> API-->>N: 200 OK · JSON N-->>B: response Note over TF: finally → TenantContext.clear()
Every authenticated request follows this chain. 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 role claim mapped to ROLE_* authorities.
  • Per-method authorization via @PreAuthorize + SecurityUtils.

Full detail → Security & IAM.

Multi-tenancy

  • Servlet filter sets TenantContext ThreadLocal from JWT.
  • AOP aspect enables Hibernate filter on every repository call.
  • @BypassTenantFilter escape 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-recent
  • transactionSummary · 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

LayerConventionEnforced by
ControllersReturn ResponseEntity<ApiResponse<T>>ArchUnit · ApiResponseRule
RepositoriesInterfaces extending JpaRepository, in *Repository packageArchUnit · RepositoryRule
Services@Service + @RequiredArgsConstructor, no HTTP layer accessArchUnit · ServiceRule
InjectionConstructor injection only — no @Autowired fieldsArchUnit · InjectionRule
EntitiesExtend BaseEntity / ImmutableEntity; @Table annotationArchUnit · EntityRule
DTOsSuffix Request, Response, Dto; never leak entitiesArchUnit · NamingRule + EntityLeakageRule
Pagination@PageableDefault(size = 20, sort = "createdAt")Convention & review
API pathsUUID for portal, slug for SEO — both variants when entity has slugConvention (Property, Agency)
ExceptionsTyped app exceptions, mapped by GlobalExceptionHandlerArchUnit · no raw RuntimeException
Code qualitySLF4J only — no System.outArchUnit · 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:

flowchart LR classDef cross fill:#fff7ed,stroke:#f97316,color:#0f172a classDef core fill:#ecfeff,stroke:#0891b2,color:#0f172a classDef money fill:#eef2ff,stroke:#1e3a5f,color:#0f172a classDef content fill:#f0fdf4,stroke:#16a34a,color:#0f172a classDef ops fill:#fef2f2,stroke:#dc2626,color:#0f172a SEC["security
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
Black-on-white arrows are the allowed cycles (e.g. property ↔ subscription because credits flow both ways). Everything depends on common · config · security · tenant.

Quality attributes

Tenant isolation
Mandatory at the persistence layer; verified by TenantIsolationIntegrationTest.
Idempotency
PayHere webhooks via UNIQUE payments.order_id; locked reads in payment service.
SEO
SSR + ISR on public routes; canonical URLs, OG metadata, JSON-LD, dynamic sitemap.
Observability
Spring Actuator health/info/metrics; structured Logback (JSON in prod) + audit ledger.
Data integrity
Flyway never rewrites applied migrations; soft delete + partial unique indexes.
Operational safety
ShedLock prevents duplicate scheduler runs; systemd auto-restart with rate-limit; graceful shutdown.
Compliance
PII (phone, email, address) encrypted at rest; financial events on a separate immutable ledger.
Next: see Deep architecture & sequences for the wire-level diagrams of auth + tenant, property publish, PayHere webhook, and the recurring-charge engine.