Skip to content

Architecture Document

System: Intern Integration Platform (IIP) Type: Service-Oriented, Event-Driven Integration Middleware Related docs: Original Specification · Use Cases · Data Model · Implementation Plan

Scope note: This document describes the architecture of the full platform, not only the MVP slice. Sections are annotated with the Release (see Implementation Plan) in which each capability first appears, so the document stays accurate as the system grows instead of needing a rewrite per release.


1. Purpose

IIP decouples the systems that produce intern data (HR) from the systems that consume it (a relational database, a payroll file feed, and any future target) using an event-driven middleware built around a canonical, schema-enforced data contract. This document describes the system's structure: its context, containers, components, data flows, deployment topology, and the key decisions behind each.


2. Architectural Principles

#PrincipleEnforcement mechanism in this system
1Loose couplingSource service has zero target-specific code; it only knows the canonical schema.
2Canonical data model as contractSchema Registry rejects any producer/consumer that doesn't conform.
3At-least-once deliveryKafka's default durability + manual offset commit only after successful processing.
4Idempotency everywhereDB adapter: SQL upsert. File adapter: processed-ID dedup store. Any new adapter must implement the same contract.
5Failure isolationIndependent consumer groups per adapter; retry/DLQ per-message, not per-partition or per-topic.
6Independent deployabilityEach service is its own container/deployment unit with its own lifecycle.
7Extensibility by additionNew targets subscribe to existing topics; zero changes to UI, source service, or topic schema.
8ObservabilityActuator health/metrics + consumer lag from day one; tracing and dashboards added as the system matures.

These eight principles are the acceptance criteria for every architectural decision below — if a proposed change violates one of them, it needs an explicit, documented justification, not a quiet exception.


3. System Context (C4 Level 1)

Actors:

ActorTypeInteracts via
HR StaffHumanWeb UI
Platform OperatorHumanActuator endpoints, Kafka UI, admin dashboard, DLQ tooling
Downstream target systemsSystemKafka consumer contract (canonical schema)

4. Container View (C4 Level 2)

Container responsibilities:

ContainerResponsibilityIntroduced
Web UIForm-based intake, record listing, submission-status displayRelease 1
Source ServiceSingle entry point: validation, canonical mapping, schema-validated publishRelease 1
Schema RegistryEnforces the canonical contract; governs schema evolutionRelease 3
Kafka (intern.created)Durable, ordered (per internId) primary event streamRelease 1
Kafka (intern.dlq)Quarantine for poison / retry-exhausted messagesRelease 1
Kafka (intern.updated, intern.deleted)Lifecycle events beyond createRelease 5
Database AdapterCanonical → SQL, idempotent upsertRelease 1
File AdapterCanonical → CSV, idempotent dedup-appendRelease 1
Target RegistryConfig-driven enablement of adapters (no hardcoding)Release 6
Additional adaptersProve extensibility-by-addition (e.g. webhook, document store)Release 6
Actuator / Kafka UIBaseline health + lag visibilityRelease 1
Prometheus / GrafanaAggregated dashboards: lag, DLQ depth, throughput, adapter healthRelease 4

5. Component View — Source Service (C4 Level 3)

The source service is intentionally the thinnest possible layer over "validate, canonicalize, publish." It contains no SQL, CSV, or target-specific logic — that boundary is what keeps loose coupling real rather than aspirational.


6. Component View — Generic Adapter Pattern

Every consumer (database, file, or any future target) implements the same internal shape, so the reliability guarantees apply uniformly as new adapters are added.

Why this shape is mandatory, not optional: the platform's headline guarantee ("no data loss, no duplicate side effects, no poison-message stall") is only true if every adapter honors this contract. A new adapter that skips the idempotency gate or the failure classifier silently breaks the platform's core promise, so this diagram is the acceptance checklist for any new adapter (see Use Case UC-9).


7. Data Flow — Happy Path (Sequence)


8. Data Flow — Failure Handling (Sequence)

Key property demonstrated: a transient outage on one adapter's target never affects the other adapter (separate consumer groups), and a single poison message never stalls the partition for messages behind it (bounded retry → DLQ → commit).


9. Data Flow — DLQ Replay (Release 7)


10. Topic & Partitioning Design

TopicKeyPartitions (baseline)ReplicationRetentionIntroduced
intern.createdinternId337 days (tune per volume)Release 1
intern.dlqinternId1330 days (operator review window)Release 1
intern.updatedinternId337 daysRelease 5
intern.deletedinternId337 daysRelease 5

Why key by internId: Kafka guarantees ordering within a partition only. Keying by internId pins every event for a given intern to the same partition, so created → updated → deleted for that intern are always processed in order by any single-instance consumer of that partition. Without this, a delete could theoretically be processed before its create.

Why a single partition for intern.dlq: the DLQ is read by operators, not scaled consumers; strict ordering of "what went wrong, in what order" is more valuable there than parallelism.


11. Deployment View

Each application service is an independently buildable/deployable container. docker-compose.yml is the local/demo orchestration; the same images are portable to Kubernetes when the platform needs to scale beyond a single host (out of scope for the initial releases, noted here as the natural next step).


12. Technology Stack

LayerTechnologyRationale
UIReact + TypeScript + TailwindFast to build, deliberately not the focus of engineering effort
Source ServiceSpring Boot (Java)Mature validation, Actuator, Kafka client, and Testcontainers ecosystem
MessagingApache KafkaDurable log, consumer groups give free fan-out + independent failure domains
Schema contractConfluent Schema Registry (Avro or JSON Schema)Turns the canonical model into an enforced, versioned contract
DatabasePostgreSQLUnique constraint on record_id enables trivial idempotent upsert
File outputCSV + embedded dedup index (RocksDB/SQLite)Simplest representative "legacy file feed" target
Dead-letterKafka topic intern.dlqReuses existing broker; no new infrastructure
Admin/API for control planeSpring BootConsistency with rest of the stack
BuildMavenStandard for the Spring Boot ecosystem
ContainerizationDocker Compose (→ Kubernetes-ready)Independent deployability principle
ObservabilitySpring Boot Actuator, Kafka UI, Prometheus, GrafanaHealth/metrics near-free from the framework; lag is the critical EDA signal
TestingJUnit, Testcontainers (Kafka + Postgres)Real infrastructure in tests, not mocks — proves guarantees rather than asserting them

13. Key Architectural Decisions

#DecisionAlternatives consideredWhy this choice
AD-1Event-driven fan-out via Kafka, not point-to-point calls from the source serviceDirect HTTP calls to each target; RabbitMQ/other brokerKafka's durable log + consumer groups gives replay, ordering-per-key, and free multi-consumer fan-out without the source service knowing how many consumers exist
AD-2Canonical schema enforced via Schema Registry, not "documented convention"Shared DTO library; JSON with no enforcementConvention-only contracts silently drift; a registry makes an invalid publish/consume a compile/runtime failure instead of a production incident
AD-3At-least-once delivery + idempotent consumers, not exactly-once semanticsKafka transactional exactly-onceExactly-once adds significant operational complexity across heterogeneous targets (a CSV file can't participate in a distributed transaction); idempotent-consumer at-least-once achieves the same observable guarantee more simply
AD-4Bounded retry + DLQ per adapter, not infinite retryInfinite retry with uncommitted offsetInfinite retry on a poison message stalls the entire partition; a DLQ trades "guaranteed eventual automatic success" for "guaranteed pipeline liveness + no silent data loss"
AD-5Each adapter is its own consumer groupOne shared consumer group for all adaptersA shared group means only one adapter instance gets each message — breaks fan-out entirely; independent groups are required for the "every target gets every message" guarantee
AD-6File adapter is single-writerMultiple instances writing one shared CSVConcurrent appenders interleave writes and corrupt the file; documented as a real limitation of file targets rather than papered over
AD-7internId as the partition key (not recordId)Random/round-robin partitioning; recordId as keyOrdering must be guaranteed per intern (create before update before delete); a random key or per-event UUID key cannot provide that
AD-8Target Registry (config-driven adapters) deferred to Release 6, not built firstBuild registry before any adapter existsYou cannot design a good abstraction for "pluggable adapters" before at least two concrete adapters exist to abstract over — premature abstraction risk

14. Non-Functional Requirements

CategoryRequirementHow the architecture satisfies it
ReliabilityNo record is silently lostAt-least-once delivery + DLQ quarantine for unprocessable records
ReliabilityNo duplicate side effects on redeliveryIdempotent consumers (upsert / dedup-append) on every adapter
AvailabilityA single target outage doesn't halt other targetsIndependent consumer groups per adapter
AvailabilityA single bad record doesn't halt the pipelineBounded retry + DLQ, offset committed after quarantine
ExtensibilityNew target added without touching existing servicesAdapters are independent consumers of existing topics; Target Registry (Release 6) makes activation config-driven
ConsistencyAll services agree on record shapeSchema Registry-enforced canonical contract
OrderingEvents for one intern are processed in orderPartition key = internId
ObservabilityOperators can see system health and backlogActuator /health + /metrics, Kafka UI consumer lag, Prometheus/Grafana dashboards
TraceabilityAny record's journey is reconstructable end-to-endrecordId/traceId propagated through every hop and logged at each stage
TestabilityGuarantees are proven, not just claimedTestcontainers integration tests against real Kafka + Postgres, including idempotency and DLQ tests

15. Cross-Cutting Concerns

  • Traceability: every canonical record carries recordId and a traceId; all services log both at every processing stage (received, validated, published, consumed, written, dead-lettered), enabling end-to-end reconstruction of a single intern's journey from logs alone.
  • Configuration: target activation (Release 6 Target Registry), retry limits, and backoff parameters are externalized (Spring application.yml / environment variables), not hardcoded, consistent with the independent-deployability principle.
  • Security (noted for completeness, not a Release-1 focus): the UI/source-service boundary is the natural place for authN/authZ if this were to leave a trusted internal network; out of scope for the current release set but the REST boundary is where it would be added without touching the eventing core.
  • Schema evolution governance: Schema Registry compatibility mode is set to backward-compatible by default (new schema can read old data), which is what allows new optional fields to be added without breaking already-deployed consumers — see Data Model §5.