Skip to content

Repository files navigation

JCLAIM

Maven Central Build and Test codecov License: MIT FOSSA Status Java

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.

Features

  • 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 the PublicIdGenerator port (CrockfordPublicIdGenerator built-in, FilteringPublicIdGenerator decorator for content acceptance).
  • Match-or-mint as one operationresolveOrMint(claim) returns a Matched or Minted result. Callers know which path was taken.
  • Matching policy as data — Express your matching logic as a JSPEC specification via the optional jclaim-matching-jspec module. Tri-state evaluation surfaces MATCHED, NOT_MATCHED, and UNDETERMINED candidates 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 MatchEvent rather than silently updating or guessing. Evidence is preserved for stewardship; stored attributes are never overwritten.
  • Storage adapters — In-memory in jclaim-core for 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 integrationjclaim-core and the storage adapters never import Spring. jclaim-spring-boot-starter provides idiomatic auto-configuration for Boot users without compromising that independence.
  • Java 21 foundation — Records, sealed interfaces, switch expressions, immutable collections throughout.

Installation

<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.

Quick Start

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());

Reacting to stewardship events

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.

Runnable example: retail customer reconciliation

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=test

The 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.

Other corpora

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.

Matching policy

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 TriStateMATCHED, 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.

Design

  • URN schemeurn:<namespace>:<type>:<UUID v7>. Both the namespace and the type segment are caller-configurable per resolver — the type defaults to entity (builder entityType(...) / namespace(...), or jclaim.urn.*). The UUID is RFC 9562 v7 (time-ordered, B-tree-friendly) generated via uuid-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 (builder publicIdTemplate(...) / 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 historic XXXX-XXXX-X shape). Independently minted, never derived from the URN. Storage enforces uniqueness for entities that have one (Postgres public_id column + entities_public_id_unique partial index; Mongo publicId field + jclaim_publicId_unique partial index); the resolver re-rolls on collision. Generation is pluggable via the PublicIdGenerator port; CrockfordPublicIdGenerator is the built-in default, wrapped by FilteringPublicIdGenerator(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 (builder allowUnfilteredPublicIds() / jclaim.public-id.filter: off) to silence it — see ADR-0003.
  • Storage as a portEntityStorage exposes five operations: three reads, one atomic resolveOrCreate primitive (Mongo-shaped, maps to findOneAndUpdate upsert), and one atomic addAlias. The MongoDB and PostgreSQL adapters fit this port without any service-code change, and an abstract EntityStorageContract suite pins every adapter to identical behaviour across paradigms.
  • Pluggable matching policy — an exact (source, sourceId) alias owner short-circuits to Matched, preserving the alias-atomic concurrency guarantee. Otherwise resolveOrMint scores a capped candidate pool with the configured MatchingPolicy (port in jclaim-core, default aliasOnly()). Attribute-based matching is driven by a JSPEC specification through the optional jclaim-matching-jspec provider — see Matching policy.

Documentation

Development

mvn clean verify            # build, test, coverage check
mvn test                    # run JUnit suite
mvn test -Dtest=ClassName   # focused single-class run

Requires Java 21 and Maven 3.6+. Lombok annotation processing must be enabled in the IDE.

Modules

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.

Suite

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).

License

MIT — see LICENSE.

FOSSA Status

About

Embeddable Java library for entity identity reconciliation. Maintains one stable canonical ID per entity across multiple source systems. MDM matching pattern with configurable policies via JSPEC and pluggable storage.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages