The Problem
The orchestrator gave us a config-driven DAG, but the DAG is only as generic as the steps inside it. The legacy system had 534 court-specific processor classes because every court had its own envelope format, its own deduplication rule, its own credential flow, and its own document store conventions. If the new platform shipped 534 court-specific workers, we had moved the problem, not solved it.
I also had a hard constraint: the workers had to share a Postgres instance with the legacy system in dev and production. Different schema, same database. Hibernate’s default behavior under hbm2ddl.auto: update would have happily reached across to the legacy tables and tried to “fix” them.
The Approach
I collapsed the 534 court-specific processors into ten generic pipeline stages keyed by capability rather than jurisdiction:
graph LR
O[Orchestrator] -->|dispatch| Q[RabbitMQ]
Q --> A[Extract Content]
Q --> B[Match Jurisdiction]
Q --> C[Lookup Case]
Q --> D[Deduplicate]
Q --> E[Authenticate]
Q --> F[Fetch Documents]
Q --> G[Handle Sealed]
Q --> H[Fallback Capture]
Q --> I[Assemble Envelope]
Q --> J[Store Documents]
A --> Q
B --> Q
C --> Q
D --> Q
E --> Q
F --> Q
G --> Q
H --> Q
I --> Q
J --> Q
Q --> O
style O fill:#06b6d4,color:#fff
style Q fill:#06b6d4,color:#fff
Each stage is a @RabbitListener consumer wrapping a pure utility class. The utility takes a typed input, does its job, and returns a typed output. The consumer wrapper handles message decoding, error envelope, and the completion publish back to the orchestrator. Court-specific behavior is configuration: regex patterns for extraction, hash algorithms per court (SHA-512 and SHA-256, not the SHA3-224 the original PRD mistakenly named), credential flow types, and document storage targets all live in versioned config rows, not in code. The stages that face court systems directly go further than HTTP: the authenticate and fetch stages drive real browser automation (Playwright) for the courts that require it. And where a stage needs data another service owns, it asks over gRPC (an artifact ledger on the orchestrator, a separate case-data service) rather than reaching into foreign tables.
The workers run Micronaut 5 on Java 25, migrated alongside the orchestrator without pausing feature work.
Three details made the workers safe to deploy alongside the legacy system.
Session tokens never persist. The authenticate stage returns only the auth-result metadata to the orchestrator. The session cookie or bearer token rides in the dispatch message to the fetch stage and is discarded after use. The orchestrator’s JSONB column never holds credential material. The companion essay on replay idempotency covers why that decision matters for replays.
Workers are read-only at the database level. I locked Hibernate to hbm2ddl.auto: validate in every environment and restricted entity scanning to the worker package so Hibernate has no idea the legacy tables exist. Even a misconfigured pod cannot issue DDL against the shared database. The validate mode also doubles as a deploy-time check that the workers’ view of the schema is consistent with the migrations the orchestrator owns.
The orchestrator owns all JSONB writes. Workers never mutate the processing context. They emit a completion message; the orchestrator merges. Combined with the orchestrator’s @Version optimistic-lock retry, this gives us serializable-by-construction state evolution without distributed transactions.
Results
Ten generic stages now cover everything the original 534 processors did, plus an extensible surface for the next round of courts. Adding a court is configuration: a DAG template plus per-court settings for whichever stages that court touches, managed through a versioned write API. A formal Phase 2 architecture review caught a hbm2ddl.auto: update setting in the workers’ base config before any pod ever ran in production, and the entity-scan restriction stopped a class of cross-tenant accidents that the legacy system had never been forced to think about.