Why Spring Contracts Break Between Repositories While Everything Still Compiles
A practical evidence model for HTTP and Kafka drift: relation, evidence, finding, and explicit unknown states.

Spring contracts often fail at repository boundaries even when every service compiles independently. Detecting that drift early requires modeling relationships, preserving source and configuration evidence, and distinguishing proven mismatches from unknowns. This article applies that model to OpenFeign, Spring MVC, Kafka, DTOs, baselines, and CI.
Consider a client in one repository:
@FeignClient(name = "payments")
interface PaymentClient {
@PostMapping("/payments/{id}")
PaymentDto update(
@PathVariable String id,
@RequestBody PaymentDto dto
);
}
And the provider in another:
@RestController
class PaymentController {
@PutMapping("/payments/{id}")
PaymentDto update(
@PathVariable String id,
@RequestBody PaymentDto dto
) {
// ...
}
}
Both fragments are valid Java. Both are valid Spring. The path and DTO even appear to agree. But the relation is broken: the caller sends POST, while the provider accepts PUT.
The compiler cannot report this because the contract does not live in either repository. It lives between them.
This article develops a practical model for detecting that kind of drift before runtime. The same model applies to OpenFeign and Spring MVC, Kafka producers and consumers, retry/DLT chains, and external OpenAPI or AsyncAPI manifests.
Disclosure: I build ArchVerity, a paid IntelliJ IDEA plugin with a 14-day trial. The product grew out of the evidence model described here. The examples below are intentionally small and synthetic; no customer incident or measured ROI is implied.
The real unit of analysis is a relationship
Most static analysis starts with a file, symbol, or module. Integration defects need a different starting point: a relationship.
For HTTP, the useful relation is not simply "this method has @GetMapping." It is closer to:
client method
-> resolved service identity
-> HTTP method + normalized path
-> provider endpoint
-> request/response contract
For Kafka, it is not simply "this method sends a message." The flow may be:
producer
-> resolved topic
-> consumer group
-> retry topic
-> dead-letter topic
Each node can be valid in isolation. The failure appears only after the nodes are connected.
That leads to a useful separation of concerns:
- Relation: which two or more elements are expected to work together?
- Evidence: which source locations and configuration values support that relation?
- Finding: what exactly disagrees, and how confident are we?
Keeping these concepts separate matters. A finding without evidence becomes another warning that developers learn to ignore. Evidence without a relation is just an inventory. A relation without an explicit finding cannot explain what action is required.
Use three states, not two
A contract analyzer is tempting to model as a Boolean check: match or mismatch. Real repositories need a third state.
MATCH: both sides were found and the supported comparison agrees.MISMATCH: both sides were found and a supported comparison disagrees.UNKNOWN: the available source, configuration, or analysis scope is insufficient.
UNKNOWN is not a softer version of success. It is also not automatically a defect.
Suppose a Kafka producer resolves orders.events.v2, but no consumer is found. Several explanations are possible:
- the topic is orphaned;
- the consumer is in a repository that was not scanned;
- the consumer is implemented by a third-party system;
- the topic name is transformed at runtime;
- the relationship is declared in an external manifest that was not provided.
Reporting a high-confidence error would overstate the evidence. Reporting success would hide risk. The honest result is UNKNOWN, accompanied by the scope and the point where resolution stopped.
This distinction is one of the most important ways to keep static analysis trustworthy. A smaller set of explainable findings is usually more valuable than a large issue count built on guesses.
HTTP matching needs normalization and provenance
A minimal HTTP relation key usually combines:
service identity
+ normalized HTTP method
+ normalized path template
+ request/response evidence
Path normalization should recognize that /orders/{id} and /orders/{orderId} have the same structural position. It should not erase real differences such as /orders/{id} versus /v2/orders/{id}.
The same principle applies to class-level and method-level mappings. A provider may compose its route like this:
@RequestMapping("/v2/orders")
class OrdersController {
@GetMapping("/{id}")
OrderResponse get(@PathVariable UUID id) {
// ...
}
}
The effective route is /v2/orders/{id}, so comparing only the method annotation would be wrong.
Configuration introduces another layer. A Feign client may get its service identity or base URL from properties, profiles, or environment placeholders. A useful result should therefore preserve provenance:
- the annotation that declared the client;
- the property key that supplied a value;
- the profile or manifest from which the value came;
- the provider mapping that was selected;
- the normalization steps used to compare them.
The analyzer should be able to open both sides. If a developer still has to search manually for the provider after seeing a client-side warning, much of the context has been lost.
DTO names are not contracts
It is easy to stop after matching an endpoint and a Java class name. That is not enough.
These two DTOs share a name but not a wire contract:
record PaymentDto(
String id,
BigDecimal amount,
String currency
) {}
record PaymentDto(
String id,
BigDecimal amount,
@JsonProperty("currency_code") String currency
) {}
A useful DTO comparison may include field names, serialized names, requiredness, collections, nullability, and nested shapes. Even then, the supported subset must be explicit. Jackson mix-ins, custom serializers, polymorphic hierarchies, reflection, and generated classes can move the effective contract beyond what source analysis can prove.
The correct response is not to pretend that every serialization rule has been modeled. It is to identify the evidence that was compared and surface uncertainty where the model ends.
Kafka requires a flow, not a topic search
Matching a producer and consumer by a string literal handles only the easiest case.
In a Spring system, the topic may come from a constant:
kafkaTemplate.send(Topics.ORDER_EVENTS, event);
The consumer may resolve a placeholder:
@KafkaListener(
topics = "${orders.events.topic}",
groupId = "${orders.events.group}"
)
void consume(OrderEvent event) {
// ...
}
Retry and DLT behavior may be declared elsewhere:
@RetryableTopic(
attempts = "3",
dltTopicSuffix = "-dlt"
)
The relation must preserve the steps by which values were resolved. When a profile changes the topic, the finding should identify that profile. When a placeholder cannot be resolved, the result should become UNKNOWN instead of silently substituting a guessed value.
This is also why scanning a single repository can be misleading. A topology view is useful only when its scope is visible. "No consumer in the analyzed repositories" is a defensible statement. "This topic has no consumer" may not be.
Baselines turn a scan into a delivery control
A full scan answers: "What does the system look like now?"
Code review usually asks a narrower question: "What changed compared with the accepted state?"
A deterministic baseline makes that comparison possible. A subsequent scan can report:
- a new or removed contract;
- a changed caller/provider relation;
- a new or resolved finding;
- a confidence change;
- a new
UNKNOWNcaused by reduced analysis scope.
This is much more actionable than sending a permanent red list into every pull request. Existing debt can stay visible without blocking unrelated work, while newly introduced drift becomes reviewable.
The same result can be represented for different consumers:
- an IDE view for navigation and investigation;
- JSON for automation;
- SARIF for CI and code-review systems;
- an SVG topology for a human-readable architecture snapshot.
The important property is determinism. If the same source and configuration produce unstable identifiers or ordering, every baseline becomes noisy.
Static evidence complements runtime tests
This approach does not replace integration tests, consumer-driven contract tests, schema registries, OpenAPI, or AsyncAPI.
Each tool answers a different question:
- static evidence asks what relationships can be derived from the current source and configuration;
- specifications describe an intended external contract, when maintained as a source of truth;
- contract tests verify selected producer/consumer expectations;
- integration tests exercise behavior in a running environment;
- observability shows what happened in production.
Static analysis is useful because it can produce an early, local signal while a developer still has the change in context. Runtime checks remain necessary because routing, serialization, service meshes, gateways, and environment-specific behavior may change the effective contract.
Where the model should stop
There are cases where a precise answer cannot be justified:
- URLs or topics are assembled by arbitrary runtime code;
- custom serialization changes the payload outside the visible type model;
- consumers live outside the available repositories and no manifest describes them;
- a gateway or service mesh rewrites routes at runtime;
- reflection or generated code leaves insufficient source evidence.
In these situations, a bounded diagnostic is better than a confident-looking graph. The quality of an architecture tool is partly determined by how clearly it marks the edge of its knowledge.
From model to workflow
The practical workflow I use is:
detect -> prove -> gate
- Detect a changed HTTP or Kafka relation.
- Prove it with caller/provider or producer/consumer source evidence.
- Gate only the relevant change using a baseline and deterministic output.
I implemented this workflow in ArchVerity 2.0.2 for IntelliJ IDEA. The current release performs analysis locally in the IDE and does not send project source, architecture snapshots, findings, or usage telemetry to the publisher. It supports Spring MVC, OpenFeign/HTTP clients, Kafka producer/consumer/retry/DLT evidence, baseline diff, and JSON/SVG/SARIF outputs.
If cross-repository Spring contracts are a problem in your team, you can try it on one real flow from the JetBrains Marketplace. I am especially interested in three questions:
- What do you treat as sufficient evidence that a client and provider belong to the same contract?
- Which cases should be
MISMATCH, and which should remainUNKNOWN? - Is your main pain HTTP drift, Kafka topology, or getting a reviewable baseline into CI?
Editorial note: AI assistance was used for drafting and structure. The technical examples and product claims were checked against the current product documentation.
