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.
Convert ordinary attributes
Section titled “Convert ordinary attributes”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); }}data class TextValue(private val scalar: String) : ConceptAs<String> { init { require(scalar.isNotBlank()) { "Text concept must not be blank." } } override fun value(): String = scalar}
@Converter(autoApply = false)class TextConverter : AttributeConverter<TextValue, String> { override fun convertToDatabaseColumn(attribute: TextValue?): String? = attribute?.value() override fun convertToEntityAttribute(dbData: String?): TextValue? = dbData?.let(::TextValue)}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:
@Embeddablepublic record UuidId(UUID value) implements ConceptAs<UUID>, Serializable { }
@Entity@ReadModelpublic 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() { }}@Embeddabledata class UuidId(var scalar: UUID = UUID(0, 0)) : ConceptAs<UUID>, Serializable { override fun value(): UUID = scalar}
@Entity@ReadModelopen class UuidRow( @field:EmbeddedId @field:AttributeOverride(name = "scalar", column = Column(name = "concept_id")) open var id: UuidId = UuidId()) { @field:Convert(converter = TextConverter::class) @field:Column(name = "text_field") open var textField: TextValue? = null}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.
Evidence and routing boundary
Section titled “Evidence and routing boundary”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:
./gradlew :Integrations:SpringDataJpa:test --tests '*JpaConceptStorageTests'