Skip to content

Map JPA concepts

Map JPA concepts explicitly in the application

Section titled “Map JPA concepts explicitly in the application”

A ConceptAs<T> wraps a value, but JPA still needs to know how to store and reconstruct it. Define each concrete mapping in the application before creating and certifying the persistence unit. This is not automatic converter registration: Arc does not install converters during lookup or infer concept constructors. Keep the converters stateless and tenant-independent so the same mapping does not change with the requesting tenant.

For an ordinary field, a concrete AttributeConverter<SpecificConcept, Scalar> writes the concept’s scalar value and reconstructs the concept when JPA reads it. Declare @Converter(autoApply = false) and apply it with @Convert to each ordinary attribute that needs that mapping. The converter preserves null in both directions; for non-null values, it calls the concrete constructor so invalid values fail rather than becoming defaults:

public record TextValue(String value) implements ConceptAs<String> {
public TextValue {
Objects.requireNonNull(value);
if (value.isBlank()) throw new IllegalArgumentException("Text concept must not be blank.");
}
}
@Converter(autoApply = false)
public class TextConverter implements AttributeConverter<TextValue, String> {
@Override
public String convertToDatabaseColumn(TextValue attribute) {
return attribute == null ? null : attribute.value();
}
@Override
public TextValue convertToEntityAttribute(String dbData) {
return dbData == null ? null : new TextValue(dbData);
}
}

View Java snippet source on GitHub

The Java tab reconstructs the concrete record through its own converter, not a generic erased ConceptAs converter. Both tabs make the stored scalar and the constructor used on read explicit.

The executable recipes also provide concrete UUID-to-UUID and Long-to-Long converters. Their H2 columns contain UUID, CHARACTER VARYING, and BIGINT values, not serialized wrapper objects. Numeric coverage is signed 64-bit Long only, including both boundaries and values beyond JavaScript’s exact integer range. It does not establish another numeric type or a client numeric contract.

Use a one-column embedded concept identifier

Section titled “Use a one-column embedded concept identifier”

An identifier needs a separate mapping. Use @Embeddable and @EmbeddedId with one scalar attribute, an explicit column override, and value equality. JPA then treats the concept as the embedded identifier, not as an ordinary converted field. This recipe does not claim that an attribute converter on a basic @Id is portable. Never mutate an identifier after assigning it to a managed entity:

@Embeddable
public record UuidId(UUID value) implements ConceptAs<UUID>, Serializable { }
@Entity
@ReadModel
public class UuidRow {
@EmbeddedId
@AttributeOverride(name = "value", column = @Column(name = "concept_id"))
public UuidId id;
@Convert(converter = TextConverter.class)
@Column(name = "text_field")
public TextValue textField;
public UuidRow() { }
}

View Java snippet source on GitHub

The Kotlin fixture supplies a JPA no-argument constructor through defaults and mutable scalar slots for field hydration. The Java fixtures execute UUID, String, and Long record embeddables on Hibernate 7.4.5.Final with H2 2.5.250; record support here is evidence for that provider/database combination, not certification of other JPA providers.

Carry that identifier type through the repository: declare, for example, JpaRepository<UuidRow, UuidId>, and supply new UuidId(value) from Java or UuidId(value) from Kotlin. Concept-valued derived predicates instead bind through the ordinary attribute’s converter.

For ordinary concept attributes on a managed entity, replacing the value and flushing persists the change without another save. Reload from a fresh persistence context to check what reached storage; reading the same managed entity again may only show the first-level cache.

Configure all mappings before issuing the JpaPersistenceUnit certificate, and use the exact concept identifier type for lookups. Repository injection itself is ordinary Spring dependency injection, not tenant routing; use the contextual command resolver when the command’s captured tenant must select a persistence unit.

The documented recipe coverage in Integrations/SpringDataJpa comes from io.cratis.arc.persistence.recipes.jpa.JpaConceptStorageTests and JavaJpaConceptStorageTests. They execute real in-process H2 storage with explicit per-unit mappings: repository findById, concept-valued predicates, raw SQL column values and types, flush/clear and fresh reload, nullable fields, dirty replacement, malformed stored values, and signed Long boundaries. Missing @Convert fails factory creation even with a registered non-auto-applying converter. That control demonstrates an application mapping omission, not an Arc product defect.

The tests first assert the provider’s hasSingleIdAttribute() and embedded idType, then pass that exact concept key through the existing contextual Arc resolver. The same keys with different values in two certified H2 factories remain isolated. Unknown tenants, wrong namespaces, mismatched certificates, raw scalar keys, and a different concept class fail without a fixed-store retry. These application-owned recipes add no Arc API, automatic persistence feature, or Arc .NET parity claim; other databases and providers remain unverified.

Run the bounded recipe checks with:

Terminal window
./gradlew :Integrations:SpringDataJpa:test --tests '*JpaConceptStorageTests'