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.

Layers L1 → L3 Deployable units 2 External systems 5 Cross-cutting 6 concerns Pattern Hexagonal-ish · feature-package

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 participant N as Nginx participant F as Filter Chain participant C as Controller participant S as Service participant DB as PostgreSQL B->>N: HTTPS · Bearer JWT N->>F: forward Note over F: JWT validate · rate-limit
TenantContext.set(agencyId) F->>C: @PreAuthorize · @Valid DTO C->>S: business call S->>DB: SQL · WHERE agency_id = ?
(Hibernate filter via aspect) DB-->>S: rows S-->>C: DTO (MapStruct) C-->>B: 200 OK · ApiResponse<T> Note over F: 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.