Appearance
Phased Rollout Plan
System: Intern Integration Platform (IIP) Related docs: Architecture · Use Cases · Data Model · Implementation Plan
This document takes each Release's backlog (Implementation Plan §4) and breaks it into much smaller, sequential, individually-completable phases — small enough that each one is roughly a single sitting's work and a single commit. It's an execution checklist, not an architecture artifact: check items off as they land, and expect the phase breakdown for later releases to be revised once earlier ones are actually built (that's normal XP feedback, not drift).
Only Release 1's phases are meant to be taken literally right now. Releases 2–7 are broken down to the same granularity so the whole rollout is visible up front, but per the project's own "simple design" / YAGNI practice (Implementation Plan §1), don't start building Release 3's schema registry plumbing while Release 1 is still in progress — the phase numbers are a map, not a schedule.
How to read this
- Phases are numbered
R.P(Release.Phase) and listed in build order within a release. - Each phase names the repo(s) it touches, what to build, and a tiny, concrete done-when check — usually one test or one observable behavior, matching the project's test-first practice.
- A phase that says "test-first" means: write the failing test in the same phase, before or alongside the implementation — not as a follow-up phase.
[ ]checkboxes are for tracking progress in this file as you go.
Spring Boot 4 / Jackson 3 gotchas hit so far
Discovered while building source-service (Phases 1.1–1.8), and worth watching for in db-adapter/file-adapter too (Spring Data JPA has had similar module-boundary changes in the same Spring Boot generation):
| Gotcha | What actually happened | Fix |
|---|---|---|
| Web starter renamed | spring-boot-starter-web is now spring-boot-starter-webmvc | Use the new artifact id (Spring Initializr will get this right if you regenerate) |
| Test starters split per-feature | The old monolithic spring-boot-starter-test is gone; each feature has its own -test starter (spring-boot-starter-webmvc-test, -kafka-test, -actuator-test, -validation-test, -restclient-test, …) | Add the specific -test starter for whatever you're slice-testing; missing one shows up as a compile error or a missing bean, not a clear message |
| Test annotations moved packages | @WebMvcTest → org.springframework.boot.webmvc.test.autoconfigure; TestRestTemplate → org.springframework.boot.resttestclient | Search the actual jar (unzip -l ... | grep ClassName.class) rather than guessing the package — guessed imports fail with "package does not exist," not a helpful redirect |
TestRestTemplate no longer auto-registered | @SpringBootTest(webEnvironment = RANDOM_PORT) alone doesn't give you a TestRestTemplate bean anymore | Add @AutoConfigureTestRestTemplate and the spring-boot-starter-restclient-test dependency (it needs RestTemplateBuilder, which nothing else pulls in) |
| Spring Boot's own default Jackson is now Jackson 3 | tools.jackson.core, not com.fasterxml.jackson.core — but Spring Kafka's JsonSerializer still uses legacy Jackson 2.x internally | Add com.fasterxml.jackson.datatype:jackson-datatype-jsr310 (the 2.x one) explicitly, or java.time fields fail to serialize with an InvalidDefinitionException about missing modules |
Jackson still defaults to WRITE_DATES_AS_TIMESTAMPS | Without disabling it, java.time fields serialize as numeric arrays/epoch numbers, not the ISO-8601 strings this project's canonical examples show | Build the Kafka value-serializer's ObjectMapper with JavaTimeModule registered and WRITE_DATES_AS_TIMESTAMPS disabled — via DefaultKafkaProducerFactoryCustomizer (org.springframework.boot.kafka.autoconfigure), not a hand-built ProducerFactory, so Spring Boot's own connection-detail handling stays intact |
@ServiceConnection bypasses spring.kafka.bootstrap-servers | Spring Boot wires the Testcontainers address directly into the autoconfiguration; the property itself still just reflects application.yml's fallback | Any raw test Kafka client needs its address from the KafkaContainer bean directly (kafkaContainer.getBootstrapServers()), never from @Value on that property |
Initializr's bootVersion id has a .RELEASE suffix that isn't a real artifact version | 4.1.0.RELEASE doesn't exist on Maven Central; the actual version is 4.1.0 | Strip the suffix from the generated pom.xml's parent version before the first build |
JsonDeserializer trusts the producer's __TypeId__ header by default | A cross-service consumer (db-adapter) can't resolve a header naming the producer's (source-service's) Java class, which isn't even on its classpath | .ignoreTypeHeaders() and always deserialize to the consumer's own local DTO — also the architecturally correct choice, since the JSON contract crossing the boundary shouldn't imply a shared Java type |
@Modifying @Query methods aren't auto-transactional | Unlike save() (transactional via SimpleJpaRepository), a custom native update/insert query throws TransactionRequiredException without one | Add @Transactional on the repository method itself |
The common thread: every one of these was invisible until something actually ran — compiling cleanly, and even a mocked unit test passing, told us nothing. Real Testcontainers integration tests and live docker compose checks caught all of them.
Release 1 — Walking Skeleton
Goal: docker compose up, submit one intern through the real UI, watch it land in Postgres and the CSV, with basic reliability (idempotency, isolated failures, a working DLQ) already true — not bolted on later.
Source Service
- 1.1 — Canonical record type.
source-service: add aCanonicalInternRecord(Javarecord) with the fields from Data Model §1 — no logic, just the shape. Done when: it compiles and a trivial construction test passes. - 1.2 — Intake DTO + validation.
source-service:CreateInternRequestDTO with Bean Validation annotations (@NotBlank,@Email,@NotNullonstartDate, etc.). Done when: a validator unit test shows a request missingemailfails validation and a fully-populated one passes. - 1.3 — Canonical mapper.
source-service:CanonicalMappermappingCreateInternRequest→CanonicalInternRecord, generatingrecordId(UUID) andcreatedAtserver-side. Done when: a unit test asserts two calls with identical input produce differentrecordIds and bothcreatedAts are non-null. - 1.4 — Kafka topics as code.
source-service:NewTopicbeans forintern.created(3 partitions) andintern.dlq(1 partition), matching Architecture §10 — makes topic creation part of the app, not justinfra's manual init job. Done when: app starts against a real broker and the topics exist with the right partition counts (verify viakafka-topics.sh --describe, same check already used againstinfra). - 1.5 — Kafka publisher.
source-service:KafkaEventPublisherwrappingKafkaTemplate<String, CanonicalInternRecord>(JSON), publishing keyed byinternId. Done when: a unit test with a mockedKafkaTemplateverifies the key and topic passed tosend(...). - 1.6 — POST /interns.
source-service:InternControllerwiring@Validrequest → mapper → publisher, returning202 AcceptedwithrecordId;400with field errors on invalid input. Done when: a@WebMvcTest/slice test covers both the happy path and a validation-failure path. - 1.7 — Publish integration test.
source-service: Testcontainers-Kafka test hittingPOST /interns(real HTTP call, real embedded broker) and asserting exactly one message onintern.createdwith the correct key. Done when: that test passes and would fail if the key were wrong (mutate it locally to confirm the test actually catches it, then revert). - 1.8 — GET /interns read path.
source-service: simplest correct read model owned by the Source Service itself (e.g. an in-memory or JPA-backed store populated at publish time) — not a query against Postgres/the CSV, preserving the loose-coupling boundary (Architecture §4.2). Done when: submitting viaPOSTand then callingGET /internsshows the new record.
Reviewed 2026-07-22 (Phases 1.1–1.8): all eight done-when checks re-verified — 23/23 tests green, the actual Docker image (not just
mvn spring-boot:run) rebuilt and exercised against a fresh Kafka viadocker compose,POST/GETround-tripped correctly, and the message read back off the real topic. One genuine Definition of Done gap found: item 5 (recordId/traceIdin logs at every new step) isn't met yet — there's no application-level logging at all insource-serviceso far. Not blocking (fulltraceIdpropagation is explicitly Release 4 / Phase 4.7 work), but plainrecordIdlogging could reasonably start now rather than waiting; left as an open gap rather than silently fixed, since it wasn't part of any phase's stated done-when criteria. Two doc inaccuracies fixed as part of this review: Architecture §5's Source Service diagram called the read modelInternQueryRepository(built asInternRecordStore) and didn't showApiExceptionHandler; UC-1 step 5 stated Schema Registry validation as already true (it's Release 3+, not yet built).
UI
- 1.9 — Intern form.
ui: replace the walking-skeleton placeholder with a real form (fields matching the canonical model) with client-side validation, callingPOST /internsviaVITE_API_BASE_URL. Done when: submitting a valid form in the browser gets a202and shows a confirmation; an invalid one shows inline errors without calling the API. - 1.10 — Records view.
ui: a list view callingGET /interns, rendering submitted records. Done when: a freshly submitted record appears in the list after a refresh.
Verified 2026-07-22 (Phases 1.9–1.10): both done-when checks confirmed against the real running stack (Kafka via
infradocker-compose,source-serviceonlocalhost:8080,uidev server) with a Playwright browser session, not just a build check. An empty-form submit produced 7 inline client-side errors and no API call; a valid submit returned202with a confirmation banner and the record appeared in the list without a manual refresh; light/dark themes (Catppuccin Latte/Mocha) and layout responsiveness at desktop (1400px), tablet (820px), and mobile (390px) widths were all checked, including the dark-mode preference persisting across a reload. One real bug found and fixed along the way: Spring Boot doesn't enable CORS by default, so the browser silently discarded the API's response even though the server processed it (confirmed viacurl—202with noAccess-Control-Allow-Originheader); fixed with aWebMvcConfigurer(WebConfig) insource-servicescoped to/interns/**, with allowed origins externalized viaiip.cors.allowed-origins. All 23source-servicetests re-run green after the fix.
Database Adapter
- 1.11 — JPA entity.
db-adapter:Internentity mapped to the existinginternstable (schema in theinfrarepo'spostgres/init.sql) + a Spring Data repository. Done when: the app starts against Postgres withddl-auto: validatepassing (no mismatch). - 1.12 — Kafka consumer (naive).
db-adapter:@KafkaListeneronintern.created, JSON-deserialize, map to entity,save(). Done when: publishing one message via the CLI or a test results in one row. - 1.13 — Make it idempotent.
db-adapter: replacesave()with a nativeINSERT ... ON CONFLICT (record_id) DO NOTHING(@Modifying @Query). Test-first: write the "deliver the same message twice, assert exactly one row" Testcontainers test before making it pass. Done when: that test passes.
Verified 2026-07-22 (Phases 1.11–1.13): all three done-when checks confirmed via real Testcontainers Postgres + Kafka, not mocks. 1.11:
init.sql(mirrored frominfrasince this repo can't reference a sibling repo's file) applied to a real Postgres,ddl-auto: validatepassed, entity save/find round-tripped correctly including a nullmentor. 1.12: a raw JSON message published directly tointern.created(not via any shared Java type --db-adaptermust never depend onsource-service's classes, only the canonical JSON contract) was consumed, deserialized, and landed as one row. 1.13, genuinely test-first: first confirmed what the naivesave()from 1.12 actually did under duplicate delivery -- it already avoided a second row, but only incidentally (a non-null@Idmakes Spring Data treat the entity as an update, not an insert, which isn't the same as a guaranteed atomic constraint) -- then replaced it with the explicit nativeON CONFLICT (record_id) DO NOTHINGupsert the phase calls for, confirmed via a real double-publish test. One gotcha hit and fixed along the way:@Modifying @Querymethods aren't automatically transactional the waysave()is viaSimpleJpaRepository-- needed an explicit@Transactionalon the repository method, or every call failed withTransactionRequiredException.
File Adapter
- 1.14 — Dedup store + CSV writer.
file-adapter: a local dedup store (recording processedrecordIds — an embedded file/SQLite is fine for R1) and aCsvInternWriterappending lines matching Data Model §4.2. Done when: a unit test writes two different records and asserts two distinct CSV lines. - 1.15 — Kafka consumer + idempotent append.
file-adapter:@KafkaListeneronintern.created: check the dedup store, skip-or-append-then-record. Test-first: "deliver the same message twice, assert exactly one CSV line." Done when: that test passes.
Reviewed 2026-07-22 (Phases 1.1–1.15, full re-check): every phase's done-when re-verified, not just re-read. Full suites re-run clean:
source-service23/23,db-adapter5/5,file-adapter10/10;uibuild + lint clean. Then a genuine end-to-end pass against the real Docker images (docker compose up --build, notmvn spring-boot:run) — submitted one intern both via raw HTTP and via a real browser session against the UI, and confirmed the identicalrecordIdlanded correctly in all three places:GET /interns(Source Service's own read path), a Postgres row, and a CSV line, with matching data in each. Two real doc inaccuracies found and fixed during this pass: Architecture's tech stack table claimed the file adapter's dedup index was "RocksDB/SQLite," but what's actually implemented (deliberately) is a flat file — file adapter is single-writer (AD-6), so there's no concurrent-writer race an embedded database would be needed for; and UC-5 claimed Schema Registry validation in its main flow, the same Release-1-vs-Release-3+ gap already caught and fixed for UC-1 in the earlier 1.1–1.8 review, just missed here sincedb-adapterdidn't exist yet at that time.
Verified 2026-07-22 (Phases 1.14–1.15): all done-when checks confirmed. 1.14: plain unit tests (no Testcontainers needed) covered the literal done-when plus two edge cases worth locking in — a college name containing a comma is RFC4180-quoted rather than corrupting the column count, and reopening an existing CSV file doesn't duplicate the header.
DedupStoreis a flat file (onerecordIdper line), not an embedded database — file-adapter runs as exactly one instance (Architecture AD-6), so there's no concurrent-writer race a heavier store would be needed for. 1.15: a real Testcontainers Kafka integration test published one message (one CSV line) and then the same message twice (still one line). One real bug caught during this phase, before it reached a commit: the bare context-load test (FileAdapterApplicationTests) had no path override, soCsvInternWriter/DedupStorebeans fell back toapplication.yml's default./data/...paths and wrote a real file into the repo's working directory on every test run — caught whendata/interns.csvshowed up as an untracked file about to be committed. Fixed by isolating that test with a@TempDir(matching the pattern already used in the Kafka consumer test) and addingdata/to.gitignore.
Reliability (still Release 1 — see Implementation Plan §4, not deferred)
- 1.16 — Failure isolation test.
db-adapter+file-adapter: Testcontainers test that stops Postgres mid-stream and asserts the File Adapter still succeeds independently while the DB Adapter's offset stays uncommitted (message not lost), then recovers once Postgres comes back. Done when: that test passes. - 1.17 — Shared FailureClassifier.
db-adapter+file-adapter: a small classifier distinguishing retriable vs. non-retriable exceptions, used identically in both adapters (Architecture §6). Done when: a table-driven unit test covers at least "connection refused → retriable" and "deserialization error → non-retriable." - 1.18 — Bounded retry.
db-adapter+file-adapter: wrap the write in the classifier + a bounded retry with backoff (config viaapplication.yml, not a hardcoded constant). Done when: a test asserts the retry count and that it gives up after the configured max. - 1.19 — DLQ on exhaustion/non-retriable.
db-adapter+file-adapter: on non-retriable failure or retry exhaustion, publish the original message + error metadata (Data Model §3) tointern.dlq, then commit the offset. Test-first: "publish a malformed message, assert it lands inintern.dlqand a subsequent good message still processes." Done when: that test passes for both adapters.
Verified 2026-07-22 (Phases 1.16–1.19): implemented in dependency order (1.17 → 1.18 → 1.19 → 1.16) since each builds on the last, then committed in the phase numbering order shown above.
db-adapter: 18/18 tests green (was 10).file-adapter: 19/19 tests green (was 10). Both adapters now deserialize the Kafka message by hand (ObjectMapper.readValueinside the listener) instead of via a KafkaJsonDeserializer— this was a deliberate design change from 1.12/1.15, not just an addition: it's what lets a malformed payload be classified and routed to the DLQ by the same code path as a write failure, and it removedKafkaConsumerConfig's wildcard-capture workaround entirely in both adapters.FailureClassifieris genuinely two different implementations sharing one shape, not one shared library —db-adapter's retriable set isConnectException/TransientDataAccessException,file-adapter's isConnectException/genericIOException(the realistic transient-failure analogue for a local-disk target), each adapter-appropriate. One real bug caught by the classifier's own test:file-adapter's first draft checked the retriable branch before the non-retriable branch, and since Jackson'sJsonProcessingExceptionis itself anIOExceptionsubtype, the broadIOExceptionretriable check silently shadowed the deserialization-error case — the deserialization-error test failed, not the connection-refused one, which is what caught it. 1.16 needed two attempts: stopping and restarting the same Testcontainers Postgres instance left connections failing for 60+ seconds afterstart()returned (container restart isn't a reliable simulate-recovery technique in practice); switched to Docker pause/unpause, which freezes the container without tearing down its network binding, and that worked cleanly.file-adapterdoesn't have its own 1.16 test — "the other target is unaffected" is a structural guarantee of Kafka consumer groups (separate groups on the same topic never block each other), already evidenced byfile-adapter's suite never touching Postgres at all; inventing a test that touches Postgres fromfile-adapter's repo would have been testing Kafka's architecture, not this codebase.
Proof & demo
- 1.20 — Full end-to-end test. One Testcontainers test: publish once (via the real HTTP path), assert a row appears in Postgres and a line appears in the CSV — the proof test called out in Original Specification §9.
- 1.21 — Manual demo pass.
infra:docker compose up --build, submit a real intern through the browser UI, confirm it in Postgres (psql) and the CSV file. Updateinfra/README.mdwith this as a documented smoke-test recipe.
Release 2 — Reliability Hardening
- 2.1 — Formalize the classifier. Extract
FailureClassifier(from 1.17) into a shared, explicitly-documented exception → classification table; expand the unit test table with more exception types as they're discovered in practice. - 2.2 — Chaos test: Postgres. Formalize 1.16 into a repeatable chaos suite: kill/restart Postgres via Testcontainers mid-consumption, assert eventual consistency with no loss/duplication.
- 2.3 — Chaos test: Kafka reachability. Same for a Kafka broker disruption affecting the File Adapter.
- 2.4 — DLQ envelope completeness check. Audit the DLQ payload against the full schema in Data Model §3 (
attemptCount,failedAdapter, timestamps, etc.) and fill any gaps left from 1.19. - 2.5 — Retry/backoff externalized per-adapter. Confirm max attempts and backoff are independently tunable per adapter via config, not shared hardcoded values.
Release 3 — Contract Enforcement
- 3.1 — Schema Registry in infra.
infra: add Confluent/compatible Schema Registry todocker-compose.yml. - 3.2 — Canonical schema as a versioned artifact. Define the canonical schema (Avro or JSON Schema) matching Data Model §1 as a file checked into each service that needs it (or a small shared schema package).
- 3.3 — Schema-validating producer.
source-service: switch to a schema-validating serializer. Test: a deliberately non-conforming record fails to publish (fails fast, before Kafka). - 3.4 — Schema-validating consumers.
db-adapter+file-adapter: switch to a schema-validating deserializer. Test: a schema-invalid message is rejected at deserialization, not deep in business logic. - 3.5 — CI compatibility gate. A script/check that runs the registry's compatibility check; test case using a deliberately-breaking schema change to prove the gate actually blocks it.
- 3.6 — Backward-compatible field addition, proven. Add one new optional field, redeploy only
source-service, confirm the un-redeployeddb-adapter/file-adapterstill process new messages correctly.
Release 4 — Observability
- 4.1 — Metrics endpoint. All three Spring services: add
micrometer-registry-prometheus, expose/actuator/prometheus. - 4.2 — Prometheus in infra.
infra: add Prometheus scraping all three services. - 4.3 — Grafana in infra.
infra: add Grafana with a provisioned Prometheus datasource. - 4.4 — Lag panel. Grafana dashboard panel: consumer lag per adapter over time.
- 4.5 — DLQ depth panel. Grafana dashboard panel: DLQ depth over time.
- 4.6 — Throughput/error panel. Grafana dashboard panel: per-adapter success/failure counts and throughput.
- 4.7 — traceId propagation. Generate a
traceIdat Source Service intake, carry it in the canonical record and every log line at every hop (structured logging config in all three services). Done when: a documented "search all logs for one traceId" walkthrough actually reconstructs one submission's full journey.
Release 5 — Mutable Lifecycle
- 5.1 —
intern.updatedtopic + schema.source-service:NewTopicbean + canonical update payload (Data Model §2). - 5.2 —
intern.deletedtopic + tombstone schema. Same, minimal tombstone payload. - 5.3 — PUT /interns/{internId}.
source-service+ui: edit endpoint + publish tointern.updated; wire the UI's Edit action. - 5.4 — DELETE /interns/{internId}.
source-service+ui: delete endpoint + publish tointern.deleted; wire the UI's Delete action. - 5.5 — DB Adapter handles update.
ON CONFLICT (intern_id) DO UPDATEonintern.updated. - 5.6 — DB Adapter handles delete. Delete/soft-delete the row on
intern.deleted. - 5.7 — Audit log table.
INTERN_AUDIT_LOG(Data Model §4.1) gets a row per event, independent of the current-state table. - 5.8 — File Adapter: keyed state store. Replace the seen-set dedup store with a keyed
internId → current record (or tombstone)store. - 5.9 — File Adapter: rebuild-from-state. Rebuild
interns.csvfrom the keyed store on every event, replacing pure append (Data Model §4.2). - 5.10 — Ordering test. Interleave create/update/delete rapidly for one
internId(same partition key); assert final state is correct in both Postgres and the CSV.
Release 6 — Extensibility
- 6.1 — Target Registry config shape. Define how adapter enablement is configured (start simple — env-driven flags are fine — before reaching for anything heavier).
- 6.2 — Extract the generic adapter pattern. Refactor
db-adapterandfile-adapterto visibly share the same shape (idempotency gate, classifier, retry/DLQ) — this is the refactor-after-duplication moment named in Implementation Plan §1, done once, not designed upfront. - 6.3 — Pluggable formatter strategy.
file-adapter: extract a CSV/JSON/XML formatter interface, selected via config. - 6.4 — A genuinely new adapter. Build a second real target (e.g. a webhook adapter) as its own repo, reusing the pattern from 6.2, with zero changes to
ui/source-service/existing adapters — the concrete proof of UC-9. - 6.5 — Shared adapter test harness. A base test class/suite ("every adapter must pass idempotency + DLQ tests") applied to all adapters, including the new one.
Release 7 — Operability
- 7.1 — DLQ read API. A non-destructive read of
intern.dlq, grouped by error type/adapter. - 7.2 — Admin dashboard: DLQ viewer. UI page rendering 7.1's data.
- 7.3 — DLQ replay tool. Re-publish a selected message to its source topic; mark the DLQ entry replayed (audit trail, not deleted).
- 7.4 — Admin dashboard: operational overview. Aggregate health/lag/DLQ depth (Release 4's data) into one view.
Keeping this document honest
- When a phase's actual implementation reveals the next one needs re-splitting or reordering, edit this file — it's a working checklist, not a frozen spec.
- Every phase should map to one commit (or a small handful) in the relevant repo. If a phase is producing multi-day, multi-file sprawl, it wasn't tiny enough — split it before continuing, per the project's small-releases practice.
- Re-check Implementation Plan §6 Definition of Done at each phase, not just at the end of a release.