Skip to content

System Topology & Integration Patterns

Validated against PRD v1.0


1. Modular Monolith Topology

1.1 Package Structure

flowchart TB
    subgraph API[api/ — REST Controllers]
        ONB_API[onboarding]
        SCR_API[screening]
        RR_API[riskrating]
        CM_API[casemanagement]
        NA_API[networkanalysis]
        CFG_API[configuration]
        AUD_API[audit]
        AUTH_API[auth]
    end
    subgraph DOM[domain/ — Bounded Contexts]
        ONB[onboarding]
        SCR[screening]
        RR[riskrating]
        CM[casemanagement]
        NA[networkanalysis]
        CFG[configuration]
        NT[notification]
        AUD[audit]
        AUTH[auth]
    end
    subgraph SHARED[shared/ — Shared Kernel]
        MODEL[model]
        CONTRACT[contract]
        EVENT[event]
    end
    subgraph INFRA[infra/ — Infrastructure]
        PERSIST[persistence]
        MSG[messaging]
        TEMP[temporal]
        SEC[security]
        EXT[external]
    end
    API --> DOM
    DOM --> SHARED
    INFRA --> DOM

1.2 Module Dependency Rules

Allowed:
  api.X → domain.X (controller calls its own domain service)
  domain.X → shared.contract (implements shared interface)
  domain.X → shared.model (uses shared value objects)
  infra → domain.* (infrastructure implements domain interfaces)

Forbidden:
  domain.X → domain.Y.internal  (cross-module internal imports)
  domain.X → api.Y              (domain must not depend on API layer)
  domain.X → infra              (domain must not depend on infrastructure)

2. Communication Patterns

2.1 Synchronous (In-Process)

When: Caller needs the result to proceed.
How: Direct method call through interface contract in shared.contract.
Example:

// shared/contract/ScreeningModule.kt
interface ScreeningModule {
    fun screen(request: ScreeningRequest): ScreeningResponse
}

// domain/screening/ScreeningServiceImpl.kt (implements)
// domain/onboarding/OnboardingService.kt (calls via interface)

// Wiring: dependency injection resolves ScreeningModule → ScreeningServiceImpl

Used for:

  • Workflow invokes Screening
  • Workflow invokes Risk Rating
  • Workflow invokes Network Analysis
  • All modules fetch config from Configuration Engine
  • All modules write to Audit Service

2.2 Asynchronous (In-Process Events)

When: Caller does not need the result. Fire and forget.
How: Spring Application Events — publish in one module, handle in another.
Example:

// domain/casemanagement — publishes
eventPublisher.publishEvent(CaseEscalated(caseId, escalationLevel, timestamp))

// domain/notification — handles
@EventListener
fun onCaseEscalated(event: CaseEscalated) {
    notificationService.send(SLA_WARNING, event.caseId)
}

// domain/audit — handles
@EventListener
fun onCaseEscalated(event: CaseEscalated) {
    auditWriter.record(AuditEvent(event))
}

Used for:

  • State transitions → Audit Service
  • SLA breaches → Notification
  • Case reassignment → Notification
  • Decision made → Audit Service

2.3 Temporal Workflows (External Orchestration)

When: Long-running workflows with retries, timers, and human-in-the-loop.
How: Temporal SDK — workflow code calls activities, activities call domain services.

flowchart TB
    TW[Temporal Worker]
    TW --> WF[CustomerOnboardingWorkflow]
    WF --> A1[screenCustomer<br/>→ ScreeningModule]
    WF --> A2[rateRisk<br/>→ RiskRatingModule]
    WF --> A3[analyzeNetwork<br/>→ NetworkAnalysisModule]
    WF --> A4[waitForApproval<br/>→ Human Signal]
    WF --> A5[makeDecision<br/>→ CaseManagement]

Temporal provides: retry, timeout, versioning, durability (survives process restart), visibility (Temporal UI).


3. API Gateway

MVP: Spring Boot Controllers (No Separate Gateway)

In the modular monolith, the API layer is co-located:

flowchart LR
    B[Browser] -->|HTTPS| SB[Spring Boot :8443]
    SB --> ONB[api/onboarding]
    SB --> SCR[api/screening]
    SB --> CM[api/casemanagement]
    SB --> CFG[api/configuration]
    SB --> AUD[api/audit]

Why no separate API Gateway in MVP:

  • Single deployment. No need for request routing between services.
  • Spring Boot handles TLS, authentication (Spring Security Filter), CORS, rate limiting (Bucket4j).
  • If extracted to services later, add a gateway (Kong, Spring Cloud Gateway) then.

API Conventions

All endpoints follow the standard defined in NFR-A01:

GET    /api/v1/cases?page=1&limit=50
POST   /api/v1/cases                    { "caseType": "ONBOARDING_REVIEW", ... }
GET    /api/v1/cases/{caseId}
PATCH  /api/v1/cases/{caseId}           { "assignedTo": "user-456" }
POST   /api/v1/cases/{caseId}/decisions { "decisionType": "APPROVED", ... }

Headers:

  • Authorization: Bearer <jwt>
  • Idempotency-Key: <uuid> (for POST/PUT/PATCH)
  • X-Correlation-Id: <uuid> (generated if not provided)

4. Deployment Topology

MVP: Single Deployment

flowchart TB
    subgraph DC[Docker Compose]
        APP[Spring Boot App<br/>Temporal Worker<br/>React SPA<br/>Spring Security<br/>All 10 contexts]
        DB[(PostgreSQL)]
        TS[Temporal Server]
        APP --> DB
        APP --> TS
    end

Ports:

  • App: 8443 (HTTPS)
  • PostgreSQL: 5432 (internal only)
  • Temporal UI: 8080 (dev only, blocked in production)

v2: Multi-Service (When Extracted)

flowchart TB
    GW[Gateway<br/>Kong / SCG]
    ONB[Onboarding Service]
    SCR[Screening Service]
    GW --> ONB
    GW --> SCR
    ONB --> KB[Event Bus<br/>Kafka]
    SCR --> KB

5. Cross-Cutting Concerns

5.1 Audit Logging

Every bounded context calls AuditWriter.record(event). Implementation: writes to PostgreSQL immutable audit table. Post-MVP: also publishes to Kafka for external consumers.

// shared/contract/AuditWriter.kt
interface AuditWriter {
    fun record(event: AuditEvent)
}

Where it's called:

  • @Around AOP advice on all api/* controller methods
  • Explicitly in domain services for state transitions
  • Temporal activities wrap results with audit hooks

5.2 Correlation ID Propagation

Generated at API gateway (Spring Filter). Stored in MDC (Mapped Diagnostic Context). Passed to Temporal workflows as workflow metadata. All logs and audit events include it.

5.3 Configuration Resolution

Every module calls Configuration Engine at invocation time to get the active config version:

val config = configEngine.getActiveConfig(moduleType, entityType, jurisdiction)
// config.version is stored with every module output for audit traceability

5.4 Error Handling

Global exception handler (@ControllerAdvice) returns consistent error format:

{
  "error": {
    "code": "CASE_NOT_FOUND",
    "message": "No case found with ID 123e4567",
    "refId": "abc-123"
  }
}

Domain exceptions propagate through Temporal activities → workflow decides: retry, escalate, or fail.


6. Extraction Path: Monolith → Services

When to Extract a Module

Signal Action
Module has different scaling needs (e.g., screening called 100x more than config) Extract to separate deployable
Module has different deployment cadence (e.g., audit changes rarely, onboarding changes weekly) Extract for independent deployment
Module needs different data storage (e.g., graph DB for Network Analysis) Extract with own database
Team grows and owns specific modules Extract to enable team autonomy
Performance isolation needed (e.g., screening latency affecting UI) Extract to isolate resources

Extraction Steps (When Ready)

  1. Module already has a clean interface in shared.contract — no code changes needed in callers
  2. Create a new Spring Boot app with the module's domain + infra packages
  3. Replace in-process interface implementation with HTTP client that calls the new service
  4. Replace Spring Events with Kafka topics for cross-service events
  5. Run both old and new with feature flags, then cut over

This is why the modular monolith matters. If boundaries are clean at the module level, extraction is mechanical — not a rewrite.


7. Integration with External Services

Pattern: Adapter + Fallback

Every external integration has: 1. Interface in shared.contract (e.g., SanctionsListProvider) 2. Real adapter in infra.external (calls the external API) 3. Mock adapter for tests (returns canned data) 4. Fallback behavior: if external service unavailable, mark task as BLOCKED, notify operator, retry when available (NFR-R02)

interface SanctionsListProvider {
    fun screen(request: SanctionsScreenRequest): SanctionsScreenResponse
}

class RestSanctionsProvider(private val client: WebClient) : SanctionsListProvider { ... }
class MockSanctionsProvider : SanctionsListProvider { ... }  // for dev/test
class FallbackSanctionsProvider(private val delegate: SanctionsListProvider) : SanctionsListProvider {
    override fun screen(request: SanctionsScreenRequest): SanctionsScreenResponse {
        return try {
            delegate.screen(request)
        } catch (e: Exception) {
            SanctionsScreenResponse(status = EXTERNAL_UNAVAILABLE, ...)
        }
    }
}

8. Security Topology

flowchart LR
    B[Browser] -->|TLS| JWT[JWT Auth]
    JWT --> RBAC[RBAC Filter]
    RBAC --> RATE[Rate Limit]
    RATE --> CORR[Correlation ID]
    CORR --> CTRL[Controller]

    SA[Service A] -->|mTLS| SB[Service B]
    SB --> VAL[Validate JWT + RBAC]

Topology designed for PRD v1.0 MVP (single deployment, modular monolith). Re-evaluate at v2 when service extraction begins.