Core Concepts

This tutorial explains the fundamental concepts behind Kastor Gen’s side-channel architecture and how it differs from traditional RDF mapping approaches.

The Side-Channel Architecture

Kastor Gen uses a unique side-channel architecture that separates domain logic from RDF-specific functionality. This design provides several key benefits:

Traditional RDF Mapping Problems

Most RDF mapping frameworks tightly couple domain objects with RDF:

// Traditional approach - RDF types leak into domain
class Person : RdfResource {
    val name: String by rdfProperty("foaf:name")
    val age: Int by rdfProperty("foaf:age")
    
    // RDF-specific methods mixed with domain logic
    fun getRdfGraph(): RdfGraph = ...
    fun validateShacl(): ValidationResult = ...
}

Problems with this approach:

  • ❌ Domain objects are tightly coupled to RDF
  • ❌ Difficult to test domain logic in isolation
  • ❌ RDF concepts leak into business logic
  • ❌ Hard to mock for unit tests
  • ❌ Complex inheritance hierarchies

Kastor Gen’s Side-Channel Solution

Kastor Gen keeps domain interfaces pure and provides RDF access through a side-channel:

// Pure domain interface - no RDF types
@Rdf(iri = "http://xmlns.com/foaf/0.1/Person")
interface Person {
    @Rdf(iri = "http://xmlns.com/foaf/0.1/name")
    val name: List<String>
    
    @Rdf(iri = "http://xmlns.com/foaf/0.1/age")
    val age: List<Int>
}

// RDF access via side-channel
val person: Person = graph.materialize(node)
val rdfHandle = person.asRdf()  // Side-channel access
val extras = rdfHandle.extras   // Unmapped properties

Benefits of this approach:

  • ✅ Pure domain interfaces with no RDF dependencies
  • ✅ Easy to test and mock
  • ✅ RDF power available when needed
  • ✅ Clean separation of concerns
  • ✅ Flexible and extensible

Key Components

1. Domain Interfaces

Domain interfaces define your business model using pure Kotlin types:

@Rdf(iri = "http://xmlns.com/foaf/0.1/Person")
interface Person {
    @Rdf(iri = "http://xmlns.com/foaf/0.1/name")
    val name: List<String>
    
    @Rdf(iri = "http://xmlns.com/foaf/0.1/age")
    val age: List<Int>
    
    @Rdf(iri = "http://xmlns.com/foaf/0.1/knows")
    val friends: List<Person>
}

Characteristics:

  • Pure Kotlin types (String, Int, List<T>)
  • No RDF-specific imports
  • Easy to understand and maintain
  • Can be used independently of RDF

2. Generated Wrappers

KSP automatically generates wrapper implementations:

// Generated code (simplified)
internal class PersonWrapper(override val rdf: RdfHandle) : Person, RdfBacked {
    override val name: List<String> by lazy {
        KastorGraphOps.getLiteralValues(rdf.graph, rdf.node, FOAF.name)
            .map { it.lexical }
    }
    
    override val age: List<Int> by lazy {
        KastorGraphOps.getLiteralValues(rdf.graph, rdf.node, FOAF.age)
            .mapNotNull { it.lexical.toIntOrNull() }
    }
    
    override val friends: List<Person> by lazy {
        KastorGraphOps.getObjectValues(rdf.graph, rdf.node, FOAF.knows) { child ->
            OntoMapper.materialize(RdfRef(child, rdf.graph), Person::class.java)
        }
    }
}

Features:

  • Implements both domain interface and RdfBacked
  • Lazy property evaluation
  • Automatic type conversion
  • Registry-based materialization

3. RDF Side-Channel

The RdfHandle provides access to RDF functionality:

interface RdfHandle {
    val node: RdfTerm          // The RDF node (IRI or blank node)
    val graph: RdfGraph        // The containing RDF graph
    val extras: PropertyBag    // Unmapped properties
    
    fun validate(): ValidationResult
    fun validateOrThrow()      // SHACL validation
}

Capabilities:

  • Access to underlying RDF node and graph
  • Unmapped property access via PropertyBag
  • SHACL validation support
  • Direct RDF operations when needed

4. Property Bag

The PropertyBag provides typed access to unmapped RDF properties:

interface PropertyBag {
    fun predicates(): Set<Iri>                    // All unmapped predicates
    fun values(pred: Iri): List<RdfTerm>          // All values for a predicate
    fun literals(pred: Iri): List<Literal>        // Literal values only
    fun strings(pred: Iri): List<String>          // String values
    fun iris(pred: Iri): List<Iri>                // IRI values
    fun <T : Any> objects(pred: Iri, asType: Class<T>): List<T>  // Materialized objects
}

Use cases:

  • Accessing properties not mapped to domain interface
  • Dynamic property discovery
  • RDF-specific operations
  • Integration with external RDF tools

Materialization Process

1. Registration

Wrapper factories are registered automatically during KSP compilation:

// Generated registration code
companion object {
    init {
        OntoMapper.register(Person::class.java) { handle -> PersonWrapper(handle) }
    }
}

2. Materialization

When you call asType(), the following happens:

val person: Person = rdfRef.asType()

Process:

  1. RdfRef.asType() delegates to OntoMapper.materialize (see Materialization.kt in the runtime)
  2. OntoMapper looks up the factory in the registry (or triggers lazy class-load of PersonWrapper)
  3. The factory receives an RdfHandle; generated wrappers typically expose rdf as a lazy DefaultRdfHandle that carries known mapped predicates for extras
  4. The wrapper instance implements both Person and RdfBacked

3. Property Access

When you access a property, it’s evaluated lazily:

val name = person.name  // Triggers lazy evaluation

Process:

  1. Wrapper’s lazy property is accessed
  2. KastorGraphOps.getLiteralValues() queries the RDF graph
  3. Values are converted to the expected type
  4. Result is cached for subsequent access

Type System

Supported Types

Kastor Gen supports various Kotlin types:

interface Example {
    // Primitive types
    val name: String
    val age: Int
    val height: Double
    val isActive: Boolean
    
    // Collections
    val names: List<String>
    val ages: List<Int>
    val friends: List<Person>
    
    // Optional values (using List with firstOrNull())
    val optionalName: List<String>  // Use firstOrNull() for optional access
}

Type Conversion

Automatic type conversion is provided:

// RDF literal "30" -> Kotlin Int 30
val age: Int = person.age.firstOrNull() ?: 0

// RDF literal "true" -> Kotlin Boolean true
val isActive: Boolean = person.isActive.firstOrNull() ?: false

// Multiple values
val allNames: List<String> = person.names

Custom Types

For complex types, use the side-channel:

// Custom type not directly supported
val customData = person.asRdf().extras.objects(CUSTOM_PREDICATE, CustomType::class.java)

Error Handling

Materialization Errors

try {
    val person: Person = rdfRef.asType()
} catch (e: IllegalStateException) {
    // No wrapper factory registered
    println("Type not supported: ${e.message}")
}

Validation Errors

try {
    val validation = JenaValidation.fromTurtle(shapesTtl) // shapesTtl: your SHACL shapes as Turtle text
    val person: Person = rdfRef.asValidatedType(validation)
} catch (e: ValidationException) {
    // SHACL validation failed
    println("Validation failed: ${e.message}")
}

Type Conversion Errors

val age: Int? = person.age.mapNotNull { 
    try {
        it.lexical.toInt()
    } catch (e: NumberFormatException) {
        null  // Skip invalid values
    }
}.firstOrNull()

Best Practices

1. Keep Interfaces Pure

// ✅ Good - Pure domain interface
@Rdf(iri = "http://xmlns.com/foaf/0.1/Person")
interface Person {
    val name: List<String>
    val age: List<Int>
}

// ❌ Bad - RDF types in domain interface
@Rdf(iri = "http://xmlns.com/foaf/0.1/Person")
interface Person {
    val name: List<String>
    val rdfNode: RdfTerm  // Don't do this!
}

2. Use Side-Channel for RDF Operations

// ✅ Good - Use side-channel for RDF operations
val person: Person = graph.materialize(node)
val rdfHandle = person.asRdf()
val extras = rdfHandle.extras
val unmappedProperties = extras.predicates()

// ❌ Bad - Don't expose RDF in domain interface
interface Person {
    val name: List<String>
    val RdfGraph: RdfGraph  // Don't do this!
}

3. Handle Optional Values Gracefully

// ✅ Good - Handle optional values
val name = person.name.firstOrNull() ?: "Unknown"
val age = person.age.firstOrNull() ?: 0

// ❌ Bad - Assume values always exist
val name = person.name.first()  // May throw exception

4. Use Validation Appropriately

// ✅ Good - Validate when needed
val validation = JenaValidation.fromTurtle(shapesTtl) // shapesTtl: your SHACL shapes as Turtle text
val person: Person = rdfRef.asValidatedType(validation)

// Or validate manually
val rdfHandle = person.asRdf()
rdfHandle.validateOrThrow()

Next Steps

Now that you understand the core concepts: