Skip to main content
Version: v0.25.0 (Latest)

Party Persistence

The EDK provides database persistence for parties, identities, contacts, and physical addresses. The persistence layer supports PostgreSQL, MySQL, and SQLite through a unified adapter pattern.

Overview

The party persistence system extends the IDK's party data models with:

  • Multi-dialect support - Same API works with PostgreSQL, MySQL, or SQLite
  • Unified adapter pattern - Dialect-specific SQLDelight databases behind a common interface
  • Tenant isolation - All queries filtered by tenant ID
  • Multi-tenant routing - Route to different databases per tenant
  • Extended models - Contacts, physical addresses, electronic identifiers, registration numbers

Data Models

Core Entities

The persistence layer stores these entity types:

EntityDescription
TenantOrganizational isolation boundary
PartyPerson, organization, or service
IdentityRole a party plays (issuer, verifier, holder)
CorrelationIdentifierExternal identifier mapping (DID, X.509, email)
ContactExternal party encountered during credential exchange
PhysicalAddressPhysical/postal addresses linked to identities

Contact

Represents an external party discovered during credential exchanges:

data class Contact(
val contactId: String,
val partyId: String, // Links to Party
val tenantId: String,
val firstIdentityId: String, // First identity encountered
val discoveryMethod: DiscoveryMethod,
val notes: String?,
val firstEncounteredAt: Instant,
val createdAt: Instant,
val updatedAt: Instant,
val deletedAt: Instant? // Soft delete
)

Discovery Methods:

MethodDescription
MANUALManually added by user
X509_CERTIFICATEDiscovered from X.509 certificate
OPENID_FEDERATIONDiscovered via OpenID Federation
CREDENTIAL_EXCHANGEDiscovered during OID4VP/OID4VCI
IMPORTImported from external source
AUTOAuto-discovered by system

Physical Address

Postal/physical addresses with validity periods:

data class PhysicalAddress(
val addressId: String,
val identityId: String, // Links to Identity
val tenantId: String,
val addressType: AddressType,
val label: String?,
val isPrimary: Boolean,
val streetAddress: String?,
val city: String?,
val province: String?, // State/province/region
val postalCode: String?,
val countryCode: String?, // ISO 3166-1 alpha-2
val latitude: Double?,
val longitude: Double?,
val validFrom: Instant?, // Address history support
val validUntil: Instant?,
val createdAt: Instant,
val updatedAt: Instant,
val deletedAt: Instant?
)

Address Types:

TypeDescription
WORKWork/office address
HOMEResidential address
BILLINGBilling address
SHIPPINGShipping/delivery address
MAILINGPostal mailing address
HEADQUARTERSOrganization headquarters
BRANCHBranch office
LEGALLegal/registered address

Identifier Extensions

Additional identifier types beyond the IDK's base correlation identifiers:

IdentifierElectronic - For EMAIL, PHONE, URL types:

data class IdentifierElectronic(
val identifierId: String, // Same ID as CorrelationIdentifier
val electronicType: String, // EMAIL, PHONE, URL
val label: String? // "Work Email", "Mobile", etc.
)

IdentifierRegistration - For registration numbers:

data class IdentifierRegistration(
val identifierId: String,
val registrationType: String, // VAT, LEI, KVK, EIN, DUNS
val issuingAuthority: String?, // Who issued the registration
val jurisdictionCountry: String? // Country of jurisdiction
)

Unified Adapter Pattern

The persistence layer uses a unified adapter pattern to support multiple database dialects:

Unified Adapter Pattern

UnifiedIdentityDatabase

The common interface for all database operations:

interface UnifiedIdentityDatabase {
val dialect: DatabaseDialect
val identityQueries: IdentityQueriesInterface

fun getRawDatabase(): Any // For advanced/dialect-specific operations
}

IdentityQueriesInterface

The unified query interface with 40+ methods:

interface IdentityQueriesInterface {
// Tenant operations
fun findTenantById(tenantId: String): TenantRecord?
fun listTenants(): List<TenantRecord>
fun countTenants(): Long
fun insertTenant(...)
fun updateTenant(...)
fun softDeleteTenant(...)

// Identity operations
fun findIdentityById(tenantId: String, identityId: String): IdentityRecord?
fun listIdentities(tenantId: String): List<IdentityRecord>
fun listIdentitiesByRole(tenantId: String, role: String): List<IdentityRecord>
fun findDefaultIdentity(tenantId: String, partyId: String): IdentityRecord?
fun insertIdentity(...)
fun updateIdentity(...)
fun softDeleteIdentity(...)

// Correlation identifier operations
fun findCorrelationIdentifierById(tenantId: String, id: String): CorrelationIdentifierRecord?
fun findIdentityByCorrelationValue(tenantId: String, type: String, value: String): IdentityRecord?
fun listCorrelationIdentifiersByIdentity(tenantId: String, identityId: String): List<CorrelationIdentifierRecord>
// ... more methods

// Physical address operations
fun findPhysicalAddressById(tenantId: String, addressId: String): PhysicalAddressRecord?
fun listPhysicalAddressesByIdentity(tenantId: String, identityId: String): List<PhysicalAddressRecord>
fun insertPhysicalAddress(...)
fun updatePhysicalAddress(...)
fun softDeletePhysicalAddress(...)

// Contact operations
fun findContactById(tenantId: String, contactId: String): ContactRecord?
fun findContactByPartyId(tenantId: String, partyId: String): ContactRecord?
fun listContacts(tenantId: String): List<ContactRecord>
fun insertContact(...)
fun updateContact(...)
fun softDeleteContact(...)

// Filtered/paginated queries
fun listIdentitiesFiltered(tenantId: String, filter: IdentityFilter, offset: Long, limit: Long): List<IdentityRecord>
fun listContactsFiltered(tenantId: String, filter: ContactFilter, offset: Long, limit: Long): List<ContactRecord>
// ... more filtered queries

// Superadmin operations (cross-tenant)
fun superadminListAllTenants(): List<TenantRecord>
fun superadminFindIdentityById(identityId: String): IdentityRecord?
// ... more superadmin methods
}

Repository Interfaces

IdentityRepository

High-level repository for identity operations:

interface IdentityRepository {
// Read operations
suspend fun findById(tenantId: String, identityId: String): Identity?
suspend fun findAll(tenantId: String): List<Identity>
suspend fun findDefault(tenantId: String, partyId: String): Identity?
suspend fun findByCorrelationValue(tenantId: String, type: String, value: String): Identity?
suspend fun count(tenantId: String): Long

// Write operations
suspend fun create(identity: Identity): Identity
suspend fun update(identity: Identity): Identity
suspend fun softDelete(tenantId: String, identityId: String, deletedBy: String?)

// Correlation identifier operations
suspend fun findCorrelationIdentifierById(tenantId: String, id: String): CorrelationIdentifier?
suspend fun findCorrelationIdentifiers(tenantId: String, identityId: String): List<CorrelationIdentifier>
suspend fun createCorrelationIdentifier(identifier: CorrelationIdentifier): CorrelationIdentifier
suspend fun updateCorrelationIdentifier(identifier: CorrelationIdentifier): CorrelationIdentifier
suspend fun softDeleteCorrelationIdentifier(tenantId: String, id: String, deletedBy: String?)
suspend fun deleteCorrelationIdentifier(tenantId: String, id: String)
}

ContactRepository

Repository for contact operations:

interface ContactRepository {
suspend fun findById(tenantId: String, contactId: String): Contact?
suspend fun findByPartyId(tenantId: String, partyId: String): Contact?
suspend fun findAll(tenantId: String): List<Contact>
suspend fun findFiltered(tenantId: String, filter: ContactFilter, offset: Long, limit: Long): List<Contact>

suspend fun create(contact: Contact): Contact
suspend fun update(contact: Contact): Contact
suspend fun softDelete(tenantId: String, contactId: String, deletedBy: String?)
}

PhysicalAddressRepository

Repository for address operations:

interface PhysicalAddressRepository {
suspend fun findById(tenantId: String, addressId: String): PhysicalAddress?
suspend fun findByIdentity(tenantId: String, identityId: String): List<PhysicalAddress>
suspend fun findPrimary(tenantId: String, identityId: String): PhysicalAddress?

suspend fun create(address: PhysicalAddress): PhysicalAddress
suspend fun update(address: PhysicalAddress): PhysicalAddress
suspend fun softDelete(tenantId: String, addressId: String, deletedBy: String?)
}

Multi-Tenant Routing

TenantDatabaseRegistry

Stores the mapping between tenants and their database configurations:

interface TenantDatabaseRegistry {
suspend fun getTenantDatabaseConfig(tenantId: String): TenantDatabaseConfig?
suspend fun registerTenant(tenantId: String, config: TenantDatabaseConfig)
suspend fun unregisterTenant(tenantId: String)
suspend fun listTenants(): List<String>
suspend fun listTenantsByDialect(dialect: DatabaseDialect): List<String>
suspend fun isRegistered(tenantId: String): Boolean
suspend fun enableTenant(tenantId: String)
suspend fun disableTenant(tenantId: String)
}

TenantDatabaseRouter

Routes database operations to the correct tenant database:

interface TenantDatabaseRouter {
suspend fun getDialectForTenant(tenantId: String): DatabaseDialect
suspend fun getDatabaseForTenant(tenantId: String): UnifiedIdentityDatabase
suspend fun hasTenantDatabase(tenantId: String): Boolean
suspend fun invalidateCache(tenantId: String)
suspend fun invalidateAllCaches()
suspend fun isHealthy(tenantId: String): Boolean
}

Usage Example

@Singleton
class PartyService @Inject constructor(
private val router: TenantDatabaseRouter
) {
suspend fun getIdentitiesForTenant(tenantId: String): List<Identity> {
// Get the correct database for this tenant
val database = router.getDatabaseForTenant(tenantId)

// Query using unified interface
val records = database.identityQueries.listIdentities(tenantId)

// Map to domain objects
return records.map { it.toIdentity() }
}

suspend fun findContactByDid(tenantId: String, did: String): Contact? {
val database = router.getDatabaseForTenant(tenantId)

// Find identity by DID correlation
val identity = database.identityQueries
.findIdentityByCorrelationValue(tenantId, "DID", did)
?: return null

// Find contact for that party
return database.identityQueries
.findContactByPartyId(tenantId, identity.partyId)
?.toContact()
}
}

Database Provider Architecture

DatabaseProvider

Abstracts database connections:

interface DatabaseProvider {
val dialect: DatabaseDialect

fun getDatabase(): Any // Dialect-specific SQLDelight database
fun close()
fun isHealthy(): Boolean
}

DatabaseProviderFactory

Creates providers with optional connection pooling:

interface DatabaseProviderFactory {
val dialect: DatabaseDialect

fun create(config: DatabaseConfig): DatabaseProvider
fun createPooled(config: DatabaseConfig, poolName: String): DatabaseProvider
}

Dialect-Specific Factories

Each database dialect provides its own factory:

PostgreSQL:

class PostgresDatabaseProviderFactory : DatabaseProviderFactory {
override val dialect = DatabaseDialect.POSTGRESQL

override fun createPooled(config: DatabaseConfig, poolName: String): DatabaseProvider {
val hikariConfig = HikariConfig().apply {
jdbcUrl = "jdbc:postgresql://${config.host}:${config.port}/${config.database}"
username = config.username
password = config.password
maximumPoolSize = config.poolSize
poolName = poolName
// PostgreSQL-specific settings
addDataSourceProperty("prepStmtCacheSize", "250")
addDataSourceProperty("prepStmtCacheSqlLimit", "2048")
}
return PostgresDatabaseProvider(HikariDataSource(hikariConfig))
}
}

MySQL and SQLite follow similar patterns with dialect-specific configurations.

Configuration

TenantDatabaseConfig

Configuration for a tenant's database:

data class TenantDatabaseConfig(
val tenantId: String,
val dialect: DatabaseDialect,
val host: String,
val port: Int,
val database: String,
val username: String,
val password: String,
val schema: String? = null,
val poolSize: Int = 10,
val enabled: Boolean = true,
val tier: ServiceTier = ServiceTier.STANDARD,
val isolationStrategy: IsolationStrategy = IsolationStrategy.Shared,
val properties: Map<String, String> = emptyMap()
)

Registering Tenants

// In-memory registry for development
val registry = InMemoryTenantDatabaseRegistry()

// Register a tenant with PostgreSQL
registry.registerTenant(
tenantId = "tenant-acme",
config = TenantDatabaseConfig(
tenantId = "tenant-acme",
dialect = DatabaseDialect.POSTGRESQL,
host = "db.example.com",
port = 5432,
database = "vdx_acme",
username = "vdx_app",
password = secrets.getPassword("tenant-acme"),
schema = "acme",
poolSize = 20,
tier = ServiceTier.PREMIUM
)
)

// Register a tenant with SQLite (edge deployment)
registry.registerTenant(
tenantId = "tenant-edge",
config = TenantDatabaseConfig(
tenantId = "tenant-edge",
dialect = DatabaseDialect.SQLITE,
host = "",
port = 0,
database = "/data/edge.db",
username = "",
password = "",
poolSize = 1
)
)

Best Practices

Use the unified adapter pattern - Always work through UnifiedIdentityDatabase and IdentityQueriesInterface for portability across databases.

Enable connection pooling - Use createPooled() in production for efficient connection management.

Implement tenant caching - The TenantDatabaseRouter caches database connections; call invalidateCache() when tenant configuration changes.

Handle soft deletes - Most entities support soft delete via deletedAt. Query active records only unless specifically needing deleted data.

Use filtered queries for pagination - For large datasets, use the filtered/paginated query methods instead of loading all records.

Monitor connection pools - Track HikariCP metrics to detect connection exhaustion or slow queries.