ADR-012: Service Modularity — Independent Operation of Arbitrary Service Subsets
On this page
Context
CRAIG’s target adopters include state agencies replacing legacy systems incrementally, counties operating only relevant modules, and organizations evaluating a subset before committing. In all cases, operators need to run some CRAIG services without running others.
The architecture is structurally modular (independent processes, per-service databases, no shared state), but runtime dependencies create implicit coupling:
-
craig-casescallscraig-rulessynchronously for safety assessment evaluation — if craig-rules is not running, the safety assessment endpoint returns 500 -
craig-intakecallscraig-casessynchronously for report-to-referral conversion — same problem -
craig-web(BFF) requires all 8 backend URLs in its config — it won’t start if any are missing -
RabbitMQ event subscriptions exist in cases and placement but handlers are currently no-op debug logging — these are soft dependencies
When this ADR was first written, only two synchronous inter-service HTTP calls existed in the codebase. Three years of feature growth and the 11-adapter data-exchange implementation have expanded that surface substantially. Current inventory (2026-04-20) of synchronous inter-service or outbound HTTP calls:
-
POST /v1/rules/evaluatefrom craig-cases (investigation screening, rule-driven branching) -
POST /v1/cases/referralsfrom craig-intake (convert_reporthandler, public-report → formal referral) -
11 craig-exchange adapters × 2 operations each (
test_connectivity+send) = up to 22 outbound HTTP call sites to external partner systems -
craig-security detection webhook notifications (critical alerts, fire-and-log)
-
craig-intake CAPTCHA verification (Cloudflare Turnstile)
-
craig-intake partner forwarding sink (standalone mode)
All outbound calls now share a single reqwest::Client per service, built via craig_common::build_shared_client(name, version) (one connection pool, consistent 30s timeout / 5s connect timeout / 32 idle connections per origin / <service>/<version> user-agent).
The optional-URL + 503 rule from this ADR still governs inter-service CRAIG calls: every cross-service URL remains Option<Url>, absent means the integration is disabled with a clear 503 response rather than a 500.
Three approaches were evaluated:
| Approach | Complexity | Strengths | Weaknesses |
|---|---|---|---|
Optional URLs + 503 |
Low |
|
Report conversion could work asynchronously since the report is already stored |
Full event-driven |
High |
Complete decoupling; services communicate only via events |
Safety assessment needs immediate result for caseworker workflow; fundamentally changes UX |
Hybrid sync/async |
Medium |
Keeps synchronous calls where real-time results are needed; uses events where deferred processing is acceptable |
Two integration patterns to maintain |
Decision
Adopt a hybrid approach: synchronous HTTP where the user needs an immediate result, event-driven async where deferred processing is acceptable.
All inter-service URLs are Option — absent means the integration is disabled with graceful degradation.
Rules
Rule 1: No mandatory service-to-service dependencies at startup
A service MUST start and serve its core functionality without any other CRAIG service present. Infrastructure dependencies (PostgreSQL, RabbitMQ, OIDC provider) are per-service requirements and are not subject to this rule.
Rule 2: Inter-service integrations activated by URL configuration
When a service integrates with another, the downstream URL is supplied via an Option<String> environment variable.
If unset, the integration is disabled.
The service MUST handle the absent integration gracefully:
-
Return
503 Service Unavailablewith an RFC 9457 Problem Details body naming the missing dependency, OR -
Operate with reduced functionality (e.g., skip rule evaluation), OR
-
Queue the operation for later processing via RabbitMQ
MUST NOT return a generic 500 or panic.
Rule 3: The BFF renders partial deployments gracefully
craig-web MUST NOT hard-fail when a backend URL is unconfigured or unreachable.
Navigation items for unconfigured services are hidden.
Pages for unreachable (but configured) services show an informative unavailable state.
Integration Points
| Integration | Pattern | Env Var | Degraded Behavior |
|---|---|---|---|
cases → rules (safety assessment) |
Synchronous HTTP |
|
503 "Rules engine unavailable" — safety assessment cannot be completed |
intake → cases (report submission) |
Synchronous HTTP forwarder |
|
Per ADR-017, intake is a stateless edge that forwards |
web → all backends |
Synchronous HTTP |
|
Nav hidden when URL unset; unavailable banner when URL set but service unreachable |
Why Safety Assessment Stays Synchronous
A caseworker submits threat factors and protective capacities during an investigation and needs the safety decision immediately to proceed with the workflow. Making this asynchronous would require polling UI, callback handlers, and a "pending evaluation" state — significant UX degradation for a workflow that takes <100ms synchronously.
Why Report Conversion Becomes Event-Driven
When a caseworker converts a public report to a referral, the report is already stored in craig-intake’s database. The referral creation in craig-cases can happen asynchronously. The caseworker needs confirmation that conversion was initiated, not that the referral exists in craig-cases immediately. The existing RabbitMQ infrastructure makes this straightforward.
Reference Deployments
This decision enables the following topologies without code changes:
-
Minimal (2 services):
craig-cases+craig-web— case management only -
Core (4 services): +
craig-rules+craig-placement— primary CCWIS workflow -
Intake-only:
craig-intakestandalone — public abuse reporting portal -
Full stack (9 services): complete CRAIG deployment
Rationale
-
Only 2 synchronous calls exist — this is not a systemic problem requiring a systemic (full event-driven) solution.
-
The hybrid approach matches the data flow: safety assessment is request-response; report conversion is fire-and-forget.
-
RabbitMQ is already deployed: event-driven intake conversion uses existing infrastructure.
-
Option<Url>is a one-line change per config field: low implementation complexity.
Consequences
-
State agencies can adopt CRAIG incrementally without deploying the entire stack.
-
Degraded-mode behavior must be explicitly designed and tested per integration point.
-
The report conversion event flow introduces eventual consistency — the referral may not appear in craig-cases for a few seconds after conversion.
-
Documentation must clearly describe which features require which services.
-
Infrastructure dependencies (PostgreSQL, RabbitMQ, OIDC) remain mandatory per service. This ADR addresses only inter-service CRAIG dependencies.