JCLAIM is an embeddable, Spring-independent Java library for entity identity reconciliation. Given identity claims about an entity from multiple source systems, JCLAIM returns a single stable canonical identifier — matching to one if the entity is already known, minting one if not.
The MDM (Master Data Management) entity-matching pattern, packaged as a library rather than an enterprise platform. Generic across entity types: configure one instance for Person, another for Vehicle, another for Customer — each with its own matching policy expressed in JSPEC.
- Canonical identity — One stable URN per entity, minted as UUID v7. Source-system IDs become aliases on the canonical entity.
- Public IDs — Optional, opt-in Crockford Base32 with Damm check digit (
K7M2-9X4P-3). Minted only when a template is configured (publicIdTemplate(...)/jclaim.public-id.template); default is none. Phone-readable, OCR-friendly, transcription-error-resistant. Generation is pluggable via thePublicIdGeneratorport (CrockfordPublicIdGeneratorbuilt-in,FilteringPublicIdGeneratordecorator for content acceptance). - Match-or-mint as one operation —
resolveOrMint(claim)returns aMatchedorMintedresult. Callers know which path was taken. - Matching policy as data — Express your matching logic as a JSPEC specification via the optional
jclaim-matching-jspecmodule. Tri-state evaluation surfacesMATCHED,NOT_MATCHED, andUNDETERMINEDcandidates naturally; the default policy is alias-only, so behaviour is unchanged until you opt in. See Matching policy. - Alias graph from day one — Records the mapping from canonical identity to source IDs, with the data shape ready for merge, split, and federation correlation.
- Stewardship events — When a match succeeds but stored attributes differ from the new claim, when a mint leaves candidates undetermined, or when several candidates match at once, JCLAIM emits a typed
MatchEventrather than silently updating or guessing. Evidence is preserved for stewardship; stored attributes are never overwritten. - Storage adapters — In-memory in
jclaim-corefor tests and evaluation; production adapters for MongoDB (jclaim-storage-mongo) and PostgreSQL (jclaim-storage-postgres) ship as separate modules. All three back the same conformance suite, so behaviour is identical across paradigms. - Spring-independent core, optional Boot integration —
jclaim-coreand the storage adapters never import Spring.jclaim-spring-boot-starterprovides idiomatic auto-configuration for Boot users without compromising that independence. - Java 21 foundation — Records, sealed interfaces, switch expressions, immutable collections throughout.
<dependency>
<groupId>uk.codery</groupId>
<artifactId>jclaim-core</artifactId>
<version>0.3.0</version>
</dependency>jclaim-core ships the domain model, the resolver service, the in-memory storage adapter, and the conflict event surface — everything needed to exercise the library end-to-end. Pair it with one of the storage adapter modules for durable persistence:
<!-- MongoDB adapter -->
<dependency>
<groupId>uk.codery</groupId>
<artifactId>jclaim-storage-mongo</artifactId>
<version>0.3.0</version>
</dependency>
<!-- PostgreSQL adapter -->
<dependency>
<groupId>uk.codery</groupId>
<artifactId>jclaim-storage-postgres</artifactId>
<version>0.3.0</version>
</dependency>
<!-- Spring Boot 3.x auto-configuration -->
<dependency>
<groupId>uk.codery</groupId>
<artifactId>jclaim-spring-boot-starter</artifactId>
<version>0.3.0</version>
</dependency>
<!-- JSPEC-backed matching policy (optional) -->
<dependency>
<groupId>uk.codery</groupId>
<artifactId>jclaim-matching-jspec</artifactId>
<version>0.3.0</version>
</dependency>Spring Boot 3.x apps can use jclaim-spring-boot-starter for auto-configured wiring (storage adapter selection, conflict-event bridging, Actuator health, Micrometer metrics). See its module README. Non-Spring callers use jclaim-core directly.
JCLAIM is in pre-1.0 development; Maven Central publication will follow with the first tagged release.
The in-memory adapter ships with the core module so the library can be exercised end-to-end without any infrastructure:
import uk.codery.jclaim.model.*;
import uk.codery.jclaim.resolver.*;
import uk.codery.jclaim.storage.memory.InMemoryEntityStorage;
import java.util.List;
// 1. Build a resolver against the in-memory storage adapter.
var resolver = DefaultEntityResolver.builder(new InMemoryEntityStorage())
.namespace("codery") // urn:codery:entity:<UUID v7>
.publicIdTemplate("????-????-?") // opt in to a publicId (omit for none)
.build();
// 2. First claim — the resolver mints a fresh canonical entity.
var firstClaim = new Claim(
SourceSystem.of("ecommerce"),
"cust-001",
List.of(MatchingAttribute.of("email", "alice@example.com")));
var first = resolver.resolveOrMint(firstClaim);
assert first instanceof ResolutionResult.Minted;
System.out.println("urn = " + first.entity().id().urn());
System.out.println("publicId = " + first.entity().publicId());
// 3. Second claim, same source alias — the resolver matches.
var second = resolver.resolveOrMint(firstClaim);
assert second instanceof ResolutionResult.Matched;
assert second.entity().equals(first.entity());
// 4. Attach an alias from a second source system.
resolver.addAlias(first.entity().id(), SourceSystem.of("pos"), "loyalty-42");
// 5. Look up by any known alias.
var found = resolver.findByAlias(SourceSystem.of("pos"), "loyalty-42");
assert found.isPresent();
assert found.get().equals(first.entity());The resolver delivers every stewardship event to a MatchEventSink. Wire one to observe attribute conflicts, undecided mints, and ambiguous matches:
import uk.codery.jclaim.event.EntityAttributesConflicted;
var resolver = DefaultEntityResolver.builder(new InMemoryEntityStorage())
.matchEventSink(event -> {
if (event instanceof EntityAttributesConflicted c) {
log.warn("conflict on {}: {}", c.stored().id(), c.differingValues());
}
})
.build();The sealed MatchEvent hierarchy is EntityAttributesConflicted, MatchUndecided, and MatchAmbiguous. Only differing values for shared attribute names raise a conflict — a claim that merely adds a new attribute is additive, not a conflict. The stored entity is not updated; silent overwrites are explicitly avoided. Stewardship logic decides whether to overwrite, merge, branch, or escalate.
A complete, runnable demonstration lives in
examples/RetailQuickStart.java. It
loads five curated customers from the retail synthetic dataset under
jclaim-core/src/test/resources/retail-fixtures/,
folds each customer's source-system records into the resolver one alias
at a time, and prints the resulting entity graph:
JClaim -- Retail customer reconciliation
========================================
cust-001 -- 4 source record(s)
resolveOrMint ecommerce/ec-12345 -> Minted urn:codery:entity:019e17f8-...
addAlias pos/pos-78910 -> attached
addAlias loyalty/L-22334 -> attached
addAlias crm/crm-99887 -> attached
cust-002 -- 3 source record(s)
resolveOrMint ecommerce/ec-10002 -> Minted urn:codery:entity:019e17f8-...
addAlias loyalty/L-22002 -> attached
addAlias crm/crm-30002 -> attached
...
---- Final entity graph ----
cust-001
urn = urn:codery:entity:019e17f8-...
publicId = K7M2-9X4P-3 (example opts in via publicIdTemplate)
aliases :
ecommerce/ec-12345
pos/pos-78910
loyalty/L-22334
crm/crm-99887
attributes (from first claim ingested):
email = jane.doe@example.com
first_name = Jane
last_name = Doe
phone = +44 7700 900123
registered_at = 2024-03-15
Run from the project root:
mvn -q -pl jclaim-core test-compile exec:java \
-Dexec.mainClass=uk.codery.jclaim.examples.RetailQuickStart \
-Dexec.classpathScope=testThe retail dataset itself covers around 100 synthetic customers across
four source systems and is documented in
jclaim-core/src/test/resources/retail-fixtures/README.md.
JClaim is entity-agnostic by design, so the test suite exercises the same library against three different domains:
| Corpus | Fixtures | Example |
|---|---|---|
| Customers | retail-fixtures/ |
RetailQuickStart |
| Products | product-fixtures/ |
ProductQuickStart |
| Properties | property-fixtures/ |
PropertyQuickStart |
Each corpus is ~100 ground-truth entities across four source systems with the same scenario shape (single-source, multi-source, conflict events, similar-looking-but-distinct entities). The library and the test scaffolding are shared between them — only the YAML data and a thin domain-named loader wrapper differ.
resolveOrMint first looks for the exact (source, sourceId) alias owner. When none exists, instead of minting blindly it blocks a pool of candidates that share an attribute with the claim and scores each with a matching policy. The policy returns a TriState — MATCHED, NOT_MATCHED, or UNDETERMINED — and the resolver always returns an identity: a single match links the alias, multiple matches link the oldest and emit MatchAmbiguous, no match mints (emitting MatchUndecided if any candidate was undetermined).
The default policy is MatchingPolicy.aliasOnly() — a candidate matches iff it already owns the claim's alias. With the default in place, behaviour is identical to alias-only matching; nothing changes until you supply a policy.
Richer policies are expressed as JSPEC specifications and supplied by the optional jclaim-matching-jspec module. The provider projects each (claim, candidate) pair into a target document (claim.*) and a context document (candidate.*); spec operands late-bind candidate values through the $contextPath sentinel:
id: customer-match
criteria:
- id: same-email
query:
claim.email:
$eq: { $contextPath: candidate.email }
- id: same-postcode
query:
claim.postcode:
$eq: { $contextPath: candidate.postcode }Wire it into the resolver:
import uk.codery.jclaim.matching.jspec.JspecMatchingPolicy;
var resolver = DefaultEntityResolver.builder(new InMemoryEntityStorage())
.namespace("codery")
.matchingPolicy(JspecMatchingPolicy.fromResource("/matching/customer-match.yaml"))
.maxCandidates(100) // cap the candidate pool (default 100)
.build();The default aggregation is conjunctive — all criteria MATCHED yields MATCHED, any NOT_MATCHED yields NOT_MATCHED, otherwise UNDETERMINED. The candidate pool is capped (maxCandidates, default 100); truncation is logged at WARN. See the jclaim-matching-jspec module README for projection and aggregator customisation.
- URN scheme —
urn:<namespace>:<type>:<UUID v7>. Both the namespace and the type segment are caller-configurable per resolver — thetypedefaults toentity(builderentityType(...)/namespace(...), orjclaim.urn.*). The UUID is RFC 9562 v7 (time-ordered, B-tree-friendly) generated viauuid-creator. - Public ID — Crockford Base32 data characters plus a Damm check digit, e.g.
K7M2-9X4P-3. Optional and opt-in: a publicId is minted only when a template is configured (builderpublicIdTemplate(...)/jclaim.public-id.template); the default is none, so by default entities carry no publicId. When set, each?is a placeholder (the last?renders the check digit, every other?a random data symbol) and any other character is a literal (????-????-?reproduces the historicXXXX-XXXX-Xshape). Independently minted, never derived from the URN. Storage enforces uniqueness for entities that have one (Postgrespublic_idcolumn +entities_public_id_uniquepartial index; MongopublicIdfield +jclaim_publicId_uniquepartial index); the resolver re-rolls on collision. Generation is pluggable via thePublicIdGeneratorport;CrockfordPublicIdGeneratoris the built-in default, wrapped byFilteringPublicIdGenerator(delegate, allow-all)so acceptance filtering can be layered later without changing the port. Acceptance is permissive by default (allow-all): a publicId is shared and cannot be recalled once issued, so configuring a template without an acceptance filter logs a one-time WARN at resolver build. Make the choice explicit by adding a filter, or acknowledge unfiltered IDs deliberately (builderallowUnfilteredPublicIds()/jclaim.public-id.filter: off) to silence it — see ADR-0003. - Storage as a port —
EntityStorageexposes five operations: three reads, one atomicresolveOrCreateprimitive (Mongo-shaped, maps tofindOneAndUpdateupsert), and one atomicaddAlias. The MongoDB and PostgreSQL adapters fit this port without any service-code change, and an abstractEntityStorageContractsuite pins every adapter to identical behaviour across paradigms. - Pluggable matching policy — an exact
(source, sourceId)alias owner short-circuits toMatched, preserving the alias-atomic concurrency guarantee. OtherwiseresolveOrMintscores a capped candidate pool with the configuredMatchingPolicy(port injclaim-core, defaultaliasOnly()). Attribute-based matching is driven by a JSPEC specification through the optionaljclaim-matching-jspecprovider — see Matching policy.
CLAUDE.md— AI assistant context, module layout, key invariants.AGENTS.md— repository conventions for collaborators and agents.CONTRIBUTING.md— contribution workflow, coding standards, test guidelines.CODE_OF_CONDUCT.md— Contributor Covenant.SECURITY.md— how to report a vulnerability.CHANGELOG.md— release history.
mvn clean verify # build, test, coverage check
mvn test # run JUnit suite
mvn test -Dtest=ClassName # focused single-class runRequires Java 21 and Maven 3.6+. Lombok annotation processing must be enabled in the IDE.
JCLAIM is a multi-module Maven project. The repository root holds the aggregator POM; each capability is a separate Maven module under it.
| Module | Purpose | Status |
|---|---|---|
jclaim-core |
Domain model, resolver service, in-memory storage adapter, MatchingPolicy port + alias-only default, stewardship events |
available |
jclaim-matching-jspec |
JSPEC-backed MatchingPolicy provider — $contextPath specs scoring (claim, candidate) pairs to a TriState — see module README |
available |
jclaim-storage-mongo |
MongoDB storage adapter for the EntityStorage port — see module README |
available |
jclaim-storage-postgres |
PostgreSQL storage adapter for the EntityStorage port — see module README |
available |
jclaim-spring-boot-starter |
Spring Boot 3.x auto-configuration — wires the resolver, selects a storage adapter, bridges conflict events, adds Actuator health + Micrometer metrics — see module README | available |
Consumers depend only on the modules they need. The in-memory adapter is shipped in jclaim-core for tests and evaluation; production deployments pair jclaim-core with one of the dedicated storage adapter modules. Every adapter passes the same EntityStorageContract test suite, so swapping backends is behaviourally transparent.
JCLAIM is one of a family of small Java libraries developed under the uk.codery namespace:
- JSPEC — declarative criteria evaluation against JSON / YAML documents. JCLAIM composes it for matching policy via
jclaim-matching-jspec. - JCLAIM — entity identity reconciliation (this project).
MIT — see LICENSE.