Skip to content

How to Define Persistent Entities

This document describes the design of the bitemporal persistence layer that Arda Systems uses to implement the concept of Entity and how to define a basic persistence layer for an Entity.

Entity Design

Entity Behaviors

Apart from the basic bitemporal behavior, entitities can adopt additional capabilities:

  1. Scoping: Entities can be scoped based on metadata information. The most common use cases are to scope entities based on the tenant they belong to or to a header record when designing parent-child relationships. This is achieved through the use of universalQuery constraints available in the Universe.
  2. Editability: Some entities, notably those representing Reference Data require a more complex editing behavior than a simple update operation. For these, a Edit-Draft-Publish lifecycle is implemented through the EditableDataAuthorityService.

Defining and Implementing a Persistent Entity

The Capabilities associated with a Persistent, Bitemporal Entity include the definition
of the information contents itself, the persistence layer for the entity, support for the business and Service layers and support for API Endpoints (currently only REST, gRPC to be added).

Entity Information Contents and Local Behaviors

The information contents and local behavior of an entity is defined by two Kotlin Classes:

  • Payload: The business information contents of the entity plus their Entity Id.
  • Metadata: The metadata associated with the entity, typically for scoping, but not
    strictly restricted to it..
  • Unit Tests to validate the local behavior of an entity instance.

The Local Behavior of an entity is the set of methods, validations, etc. that can be implemented strictly using Kotlin Methods on the POJO class of the Payload and/or its Metadata. without resorting to external services.

Persistence Layer

The persistence layer for an entity consists of:

  • exposed Persistence Classes, Tables and Mappings, describing how to define Kotlin classes to create persistent entities.
  • A Bitemporal Entity data type that defines the persisted representation of a bitemporal record of the entity.
  • A Universe for the Entity that provides the behavior of a collection of entity instances determined by its universalQuery constraint.
  • SQL scripts to create the tables in the database that live in the resources directory of the module and are executed by [Flyway] when the module is configured
    as part of a component initialization.
  • Unit Tests to validate the persistence definitions and any specialized methods of the
    universe.

The persistence layer is implemented following the DBIO pattern. This means that Universe methods do not directly execute persistence operations in the database but instead
create DBIO instances that can then be combined and executed later in the context of a transaction.

Service Layer

Services in Arda’s architecture are responsible for orchestrating the business behavior of the application, potentially accessing multiple entity types. Services may access multiple universes to manage the entities they need to perform their business logic.

It is important to note that:

  1. Services OWN the entity instances they manage and DO NOT SHARE them with other services except through their own Service Interface.
  2. Services represent the boundary of transactional behavior in the system. Coordination between services must take into account the absence of transactional guarantees beyond a single service operation invocation.
  3. The internal structure of a Service can vary widely,from a simple Class with one method per Service Operation to a complex multi-layered design with command patterns, etc.
  4. Entities within a Service can take advantage of knowing they share persistence mechanisms for optimization, etc. E.g. using Data base joins or common transactions to simplify the
    implementation of the business logic.

Although Services can be very complex, there are some basic types of services that appear frequently in Arda’s enterprise architecture:

  • Data Authority Services: Services that manage a main Entity Type, potentially with some ancillary types, each of them with their own persistence layer.
  • Editable Data Authority Services: To support the Edit-Draft-Publish pattern.

API Endpoints

API Endpoints are associated with Services. A Service may expose multiple API Endpoints using the same or different protocols. While the functionality exposed by an API Endpoint can be arbitrary, the Data Authority pattern is very common in the system and
has dedicated support to ease the implementation of these endpoints.

  • REST API Endpoints can be defined using the DSL defined in the ServiceEndpointDsl.kt file.
  • Abstract classes to implement Rest API endpoints for Data Authority Services and Editable Data Authority Services are available in the common-module library.

Implementation Guide

This section provides detailed technical guidance for implementing persistent entities across all architectural layers: persistence, service, and API endpoints.

Persistence Layer Implementation Guide

Basic Elements
Payload and Metadata Definitions

The Payload defines the business information content of an entity, while Metadata provides scoping and contextual information.

Payload Requirements:

  • Must implement the EntityPayload interface
  • Must include an eId: UUID property for entity identification
  • Should be defined as a sealed interface with a data class Entity implementation
  • Must implement validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit>

Example: Item payload definition (see operations/reference/item/business/Item.kt):

@Serializable
sealed interface Item : EntityPayload {
  @Serializable(with = UUIDSerializer::class)
  override val eId: EntityId
  val name: String
  val description: String?
  // ... other business properties

  @Serializable
  data class Entity(
    @Serializable(with = UUIDSerializer::class)
    override val eId: EntityId,
    override val name: String,
    override val description: String? = null,
    // ... other properties
  ) : Item {
    override fun validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit> {
      return Result.success(Unit)
    }
  }
}

Metadata Requirements:

  • Must implement PayloadMetadata (or ScopedMetadata for tenant-scoped entities)
  • For scoped entities, must include tenantId: UUID

Example: ItemMetadata definition:

@Serializable
data class ItemMetadata(
  @Serializable(with = UUIDSerializer::class)
  override val tenantId: UUID
) : ScopedMetadata
Exposed Table and Mappings

Tables are defined using Exposed’s DSL, extending either UniverseTable, ScopedTable, or specialized table types.

Table Configuration:
Every table requires a TableConfiguration object:

object ItemTableConfiguration: TableConfiguration {
  override val name = "ITEM"
}

Table Definition:
Define the table object extending the appropriate base class (see operations/reference/item/persistence/ItemPersistence.kt):

object ITEM_TABLE : ScopedTable(ItemTableConfiguration) {
  private val root = Component.Root<ITEM_TABLE, ItemRecord>(this)
  val name = naturalIdentifier("item_name")
  val description = memo("description").nullable()
  val imageUrl = url("image_url").nullable()
  val classification = root.itemClassificationComponent<ITEM_TABLE, ItemRecord, ItemClassification.Value?>("classification")
  val useCase = varchar("use_case", 255).nullable()
  // ... additional columns
}
EntityTable Column Definition Methods

The EntityTable base class (inherited by UniverseTable, ScopedTable, etc.) provides standard column definition methods in common-module/lib/src/main/kotlin/cards/arda/common/lib/persistence/bitemporal/BitemporalTable.kt:

Standard Column Types:

  • naturalIdentifier(name: String) - VARCHAR(255) for business identifiers
  • memo(name: String) - TEXT for long-form content
  • url(name: String) - VARCHAR(8192) for URLs
  • bool(name: String) - BOOLEAN columns
  • uuid(name: String) - UUID columns
  • varchar(name: String, length: Int) - Variable character columns
  • double(name: String) - DOUBLE PRECISION numeric columns
  • long(name: String) - BIGINT columns
  • integer(name: String) - INTEGER columns

Enumeration Columns:

  • enumerationByName<E>(name: String, length: Int) - Standard Exposed column. Stores enum as string
  • enumerated<E>(name: String) - Normalized Size column for Enums in Arda, stores enum as string.

JSON Columns:
For complex types that don’t warrant component mapping:

val contacts = json<Map<String, Contact>>("other_contacts", JsonConfig.standardJson)
val addresses = json<Map<String, PostalAddress.Value>>("other_addresses", JsonConfig.standardJson)

Making Columns Nullable:
Call .nullable() on any column definition:

val description = memo("description").nullable()
val taxable = bool("TAXABLE").nullable()
Persistent Components for Multi-Column Mappings

Components allow mapping complex value objects to multiple database columns. They follow the Component.Root and Component.Sub pattern defined in common-module/lib/src/main/kotlin/cards/arda/common/lib/persistence/repo/Component.kt.

Component Structure:

  • Component.Root<TBL, R> - The root component representing the table itself
  • Component.Sub<TBL, R, T> - Sub-components for value objects

Defining a Component:
Create an abstract class extending Component.Sub (see operations/reference/item/domain/persistence/QuantityComponent.kt):

abstract class QuantityComponent<TBL: EntityTable, R: RowRecord<TBL, R>, T : Quantity?>(
  override val cmpName: String,
  override val parent: Component<TBL, R, *>
) : Component.Sub<TBL, R, T> {
  override val prefix = prefixGetter()
  val amount = tbl.double(forName("amount")).nullable()
  val unit = tbl.varchar(forName("unit"), 255).nullable()

  override fun fill(insert: InsertStatement<Number>, valueObject: T) {
    insert[amount] = valueObject?.amount
    insert[unit] = valueObject?.unit
  }

  @ExperimentalContracts
  @Suppress("UNCHECKED_CAST")
  override fun getComponentValue(row: R): Result<T?> =
    with (row) {
      val amount = amount.getValue(row, Quantity::amount)
      val unit = unit.getValue(row, Quantity::unit)
      when(build.requiring(amount, unit)) {
        true -> Result.success(Quantity.Value(amount, unit) as T)
        false -> Result.success(null)
        null -> Result.failure(
          AppError.IncompatibleState("Quantity: {\n  amount: ${amount}\n  unit: ${unit}} must be all null or none null")
        )
      }
    }

  override fun setComponentValue(r: R, valueObject: T) = with(r) {
    amount.setValue(this, Quantity::amount, valueObject?.amount)
    unit.setValue(this, Quantity::unit, valueObject?.unit)
  }
}

Component Extension Function:
Provide an extension function for easy usage:

inline fun <TBL: EntityTable, R: RowRecord<TBL, R>, reified T: Quantity?>
        Component<TBL, R, *>.quantityComponent(name: String): QuantityComponent<TBL, R, T> {
  return object : QuantityComponent<TBL, R, T>(name, this) {
    override val isNullable = (null is T)
  }
}

Using Components in Tables:

object ITEM_TABLE : ScopedTable(ItemTableConfiguration) {
  private val root = Component.Root<ITEM_TABLE, ItemRecord>(this)
  val minQuantity = root.quantityComponent<ITEM_TABLE, ItemRecord, Quantity.Value?>("min_quantity")
  val locator = root.physicalLocatorComponent<ITEM_TABLE, ItemRecord, PhysicalLocator.Value?>("physical_locator")
}

Component Composition:
Components can be nested to create hierarchical structures. See TimeCoordinatesComponent, ItemSupplyComponent, and PhysicalLocatorComponent in the codebase for examples.

Bitemporal Entity Structure

The BitemporalEntity class (in common-module/lib/src/main/kotlin/cards/arda/common/lib/persistence/bitemporal/BitemporalEntity.kt) represents a versioned record with both valid time and transaction time dimensions.

Record Class Definition:
Create a record class extending ScopedRecord or BitemporalRecord:

class ItemRecord(rId: EntityID<UUID>):
  ScopedRecord<Item, ItemMetadata, ITEM_TABLE, ItemRecord>(rId, ITEM_TABLE) {
  var name: String by ITEM_TABLE.name
  var description: String? by ITEM_TABLE.description
  var locator by ITEM_TABLE.locator  // Component delegation
  var minQuantity by ITEM_TABLE.minQuantity  // Component delegation
  // ... other properties

  companion object : Persistence<Item, ItemMetadata, ITEM_TABLE, ItemRecord>(
    ITEM_TABLE,
    ItemRecord::class.java,
    { ItemRecord(it) },
    {insertStm, payload, metadata ->
      insertStm[name] = payload.name
      insertStm[description] = payload.description
      locator.fill(insertStm, payload.locator)
      minQuantity.fill(insertStm, payload.minQuantity)
      insertStm[tenantId] = metadata.tenantId
    }
  )

  override fun fillPayload() {
    payload = Item.Entity(
      eId = eId,
      name = name,
      description = description,
      locator = locator,
      minQuantity = minQuantity
    )
  }

  override fun fillMetadata() {
    metadata = ItemMetadata(tenantId)
  }
}

Key Methods:

  • fillPayload() - Reconstructs the payload from database columns
  • fillMetadata() - Reconstructs the metadata from database columns
  • Companion object’s lambda - Defines how to populate an insert statement
SQL Migration Scripts

SQL migrations use Flyway and follow a strict naming convention. Scripts are located in src/main/resources/<module-path>/database/migrations/.

Naming Convention:
V<VERSION>__<DESCRIPTION>.sql

  • V prefix for versioned migrations
  • Version number with dots or underscores (lexicographic ordering)
  • Double underscore separator
  • Descriptive name

Example: V001__item.sql (see operations/src/main/resources/reference/item/database/migrations/V001__item.sql):

CREATE TABLE IF NOT EXISTS item (
  id UUID PRIMARY KEY,
  effective_as_of TIMESTAMP NOT NULL,
  recorded_as_of TIMESTAMP NOT NULL,
  author VARCHAR(244) NOT NULL,
  eid UUID NOT NULL,
  previous UUID NULL,
  retired BOOLEAN DEFAULT FALSE NOT NULL,
  tenant_id UUID NOT NULL,
  item_name VARCHAR(255) NOT NULL,
  image_url VARCHAR(8192) NULL,
  description TEXT NULL,
  -- Component columns (note the naming pattern)
  min_quantity_amount DOUBLE PRECISION NULL,
  min_quantity_unit VARCHAR(255) NULL,
  physical_locator_facility VARCHAR(255) NULL,
  physical_locator_department VARCHAR(255) NULL,
  physical_locator_location VARCHAR(255) NULL
);

CREATE INDEX idx_item_eid ON item (eid);
CREATE INDEX idx_item_effective_as_of ON item (effective_as_of);
CREATE INDEX idx_item_recorded_as_of ON item (recorded_as_of);
CREATE INDEX idx_item_tenant_id ON item (tenant_id);

Best Practices:

  • Always include bitemporal columns: effective_as_of, recorded_as_of, previous, retired
  • Create indexes on eid, temporal columns, and scoping columns
  • Use component naming conventions: <component_prefix>_<field_name>
  • Add created_by, created_at_effective, created_at_recorded for audit trails
Universe Implementation

A Universe represents a collection of bitemporal entities with a specific scope constraint. Universes extend AbstractUniverse or AbstractScopedUniverse.

Basic Universe Definition (see operations/reference/item/persistence/ItemUniverse.kt):

class ItemUniverse :
  AbstractScopedUniverse<Item, ItemMetadata, ITEM_TABLE, ItemRecord>(),
  LogEnabled by LogProvider(ItemUniverse::class) {
  override val persistence = ItemRecord.Companion
  override val universalCondition = ItemUniversalCondition
  override val validator = validatorFor(this)
}

Universal Condition:
Defines the scope constraint for all queries:

object ItemUniversalCondition: ScopedUniversalCondition()

For custom constraints, extend UniversalCondition and override withCondition().

Validator:
Provides validation logic for create/update/delete operations:

fun validatorFor(u: ItemUniverse): ScopingValidator<Item, ItemMetadata> {
  return object : ScopingValidator<Item, ItemMetadata>() {
    // Add custom validation logic if needed
  }
}

Universe Operations:
The Universe interface (in common-module/lib/src/main/kotlin/cards/arda/common/lib/persistence/universe/Universe.kt) provides:

  • create() - Create new entities
  • read() - Read by entity ID at a point in time
  • readRecord() - Read specific record by record ID
  • list() - Query entities with filtering, sorting, pagination
  • update() - Update existing entities
  • delete() - Soft-delete entities
  • history() - Retrieve entity history
  • count() - Count entities matching criteria

Scoped Entities

Scoped entities are restricted to a specific scope, typically by tenantId for multi-tenancy.

Scoped Metadata:
Use ScopedMetadata interface:

@Serializable
data class ItemMetadata(
  @Serializable(with = UUIDSerializer::class)
  override val tenantId: UUID
) : ScopedMetadata

Scoped Table:
Extend ScopedTable which automatically includes tenantId column:

object ITEM_TABLE : ScopedTable(ItemTableConfiguration) {
  // tenantId column is inherited
  val name = naturalIdentifier("item_name")
  // ... other columns
}

Scoped Record:
Extend ScopedRecord:

class ItemRecord(rId: EntityID<UUID>):
  ScopedRecord<Item, ItemMetadata, ITEM_TABLE, ItemRecord>(rId, ITEM_TABLE) {
  // tenantId property is inherited
  // ... other properties
}

Scoped Universal Condition:
Use ScopedUniversalCondition to automatically filter by tenantId:

object ItemUniversalCondition: ScopedUniversalCondition()

Parent-Child Scoping:
For parent-child relationships, scope children to their parent using metadata:

@Serializable
data class OrderLineMetadata(
  override val parentEid: UUID,
  val rank: Int
) : ChildMetadata

The child universe queries are automatically scoped to the parent entity.

Implementing Validations

Validations occur at multiple levels:

Entity-Level Validation:
Implement validate() in the payload:

override fun validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit> {
  return when {
    name.isBlank() -> Result.failure(AppError.ArgumentValidation("name", "Name cannot be blank"))
    minQuantity != null && minQuantity.amount <= 0 ->
      Result.failure(AppError.ArgumentValidation("minQuantity", "Minimum quantity must be positive"))
    else -> Result.success(Unit)
  }
}

Component-Level Validation:
Validate within getComponentValue():

override fun getComponentValue(row: R): Result<T?> =
  with (row) {
    val amount = amount.getValue(row, Quantity::amount)
    val unit = unit.getValue(row, Quantity::unit)
    when(build.requiring(amount, unit)) {
      true -> Result.success(Quantity.Value(amount, unit) as T)
      false -> Result.success(null)
      null -> Result.failure(
        AppError.IncompatibleState("Quantity: amount and unit must be all null or none null")
      )
    }
  }

Universe Validator:
Implement custom validation logic in the validator:

fun validatorFor(u: ItemUniverse): ScopingValidator<Item, ItemMetadata> {
  return object : ScopingValidator<Item, ItemMetadata>() {
    override suspend fun validateForCreate(
      ctx: ApplicationContext,
      payload: Item,
      metadata: ItemMetadata,
      asOf: TimeCoordinates,
      author: String
    ): Result<Unit> {
      // Check for duplicate names within tenant
      // Validate business rules
      return super.validateForCreate(ctx, payload, metadata, asOf, author)
    }
  }
}

Transactional Considerations

Using inTransaction:
All database operations must execute within a transaction:

inTransaction(db, readOnly = true) {
  universe.read(eId, asOf)()
}

Read-Only vs. Write Transactions:

  • Use readOnly = true for queries to enable optimizations
  • Omit or set readOnly = false for mutations

Transaction Boundaries:

  • Transactions are scoped to a single service operation
  • No transactional guarantees across service boundaries
  • Rollback occurs automatically on exceptions

DBIO Capabilities and Composition:

DBIO<T> represents a deferred database operation that returns Result<T> when executed.

Understanding DBIO:

  • DBIO<T> is a type alias for suspend () -> Result<T>
  • Operations are composed but not executed until invoked with ()
  • Enables building complex multi-step operations in a single transaction

Composing DBIO Operations:

map - Transform successful results:

val dbio: DBIO<BitemporalEntity<Item, ItemMetadata>> = universe.read(eId, asOf)
val recordDbio: DBIO<EntityRecord<Item, ItemMetadata>?> = dbio.map { it?.let { EntityRecord.fromEntity(it) } }

flatMap - Chain dependent operations:

universe.read(eId, asOf).flatMap { entity ->
  when (entity) {
    null -> DbioFailure(AppError.NotFound("Item", { "Item $eId not found" }))
    else -> universe.update(Update(entity.payload.copy(name = "New Name"), entity.metadata, asOf, author))
  }
}

liftMap - Lift a Result<DBIO<T>> to DBIO<T>:

draftStore.current(eId, tenantId).liftMap { draft ->
  when (draft) {
    null -> universe.create(payload, metadata, asOf, author)
    else -> DbioFailure(AppError.IncompatibleState("Draft already exists"))
  }
}

then - Sequence operations, discarding first result:

draftStore.closeEdit(eId, tenantId)
  .then(universe.update(Update(payload, metadata, asOf, author)))

Building Complex Operations:

suspend fun complexOperation(eId: UUID): DBIO<EntityRecord<Item, ItemMetadata>> =
  universe.read(eId, TimeCoordinates.now()).liftMap { entity ->
    when (entity) {
      null -> DbioFailure(AppError.NotFound("Item", { "Not found" }))
      else -> {
        // Multi-step operation
        draftStore.current(eId, entity.metadata.tenantId).liftMap { draft ->
          when (draft) {
            null -> universe.update(Update(entity.payload, entity.metadata, TimeCoordinates.now(), "system"))
                      .map { EntityRecord.fromEntity(it) }
            else -> draftStore.closeEdit(eId, entity.metadata.tenantId)
                      .then(universe.update(Update(draft.value, draft.metadata, TimeCoordinates.now(), "system")))
                      .map { EntityRecord.fromEntity(it) }
          }
        }
      }
    }
  }

// Execute in transaction
inTransaction(db) {
  complexOperation(eId)()
}

Error Handling:

  • Use Result.failure() or DbioFailure() for errors
  • Errors propagate through composition
  • Transaction rolls back on failure

Unit Tests

Persistence Layer Test Patterns:

Tests should validate table definitions, component mappings, and universe operations.

ContainerizedPostgres Test Infrastructure:

ContainerizedPostgres provides a Testcontainers-based PostgreSQL instance for integration tests (see common-module/lib/src/main/kotlin/cards/arda/common/lib/testing/persistence/ContainerizedPostgres.kt).

Setting Up ContainerizedPostgres:

class ItemUniverseTest : StringSpec({
  val dsConfig: DataSourceConfig by lazy {
    DataSourceConfig(
      PostgresConfig(
        "dummyUser", "dummyPwd", JdbcUri("jdbc://dummy/dummy")
      ),
      PoolConfig()
    )
  }

  val cpg: ContainerizedPostgres by lazy {
    ContainerizedPostgres.fromTestConfig(dsConfig.db, dsConfig.initFile)
  }

  beforeSpec {
    cpg.start()
    Database.connect(
      url = cpg.postgresContainer.jdbcUrl,
      driver = cpg.postgresContainer.driverClassName,
      user = cpg.postgresContainer.username,
      password = cpg.postgresContainer.password
    )
    TransactionManager.manager.defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE
    TransactionManager.manager.defaultMaxAttempts = 3
  }

  afterSpec {
    cpg.stop()
  }

  // Tests go here
})

Loading Flyway Migrations:

Migrations are loaded via DataSource.newDb() with FlywayConfig:

val itemDb = itemDbDs.newDb(
  FlywayConfig(
    locations = listOf("reference/item/database/migrations"),
    "reference_item_flyway_history"
  )
)

Alternative Setup with fromValues:

val cpg: ContainerizedPostgres by lazy {
  ContainerizedPostgres.fromValues(
    "test_db",
    "test_user",
    "test_pwd",
    "kanban-service-test/database/db-init.sql"
  )
}

Database Connection Management:

  • Use beforeSpec to start container and connect
  • Use afterSpec to stop container
  • Set transaction isolation level and retry attempts

Test Data Setup and Teardown:

  • Create test data within transactions
  • Use transaction { } blocks for setup
  • Leverage SchemaUtils.create() and SchemaUtils.drop() for DDL tests

Component Mapping Tests:

Test DDL generation and component behavior:

"test DDL generation for QuantityComponent" {
  transaction {
    addLogger(StdOutSqlLogger)
    SchemaUtils.create(TEST_TABLE)
    println("\n--- DDL statements should be visible above ---")
    SchemaUtils.drop(TEST_TABLE)
  }
}

Universe Operation Testing:

Test CRUD operations:

"create and read item" {
  val itemService = ItemService.Impl(/* dependencies */)
  val item = Item.Entity(
    eId = UUID.randomUUID(),
    name = "Test Item",
    description = "Test Description"
  )
  val metadata = ItemMetadata(tenantId)

  val createResult = appCtx.inContext {
    itemService.add(item, metadata, clock.next(), "test-author")
  }

  assertTrue(createResult.isSuccess)
  val created = createResult.getOrThrow()

  val readResult = appCtx.inContext {
    itemService.getAsOf(created.eId, TimeCoordinates.now())
  }

  assertTrue(readResult.isSuccess)
  assertEquals(item.name, readResult.getOrThrow()?.payload?.name)
}

Data Authority Service Layer Implementation Guide

Basic Data Authority

A Data Authority Service manages the lifecycle of a single entity type, providing CRUD operations and business logic orchestration.

Service Interface:
Extend DataAuthorityService (see common-module/lib/src/main/kotlin/cards/arda/common/lib/service/DataAuthorityService.kt):

interface ItemService : DataAuthorityService<Item, ItemMetadata>,
  ObserverManager<DataAuthorityNotification<Item, ItemMetadata>> {

  class Impl(
    universeP: ItemUniverse,
    dbP: Database
  ) : ItemService,
    ObserverManager<DataAuthorityNotification<Item, ItemMetadata>> by InMemoryObserverManagerDelegate() {
    override val universe: ItemUniverse = universeP
    override val db: Database = dbP
  }
}

Required Properties:

  • universe: Universe<EP, M> - The universe managing the entity
  • db: Database - The database connection

Inherited Operations:

  • getAsOf(eId, asOf, includeDeleted) - Retrieve entity at a point in time
  • getRecord(rId) - Retrieve specific record version
  • listEntities(query, asOf) - Query entities
  • history(eId, since, until, page) - Get entity history
  • add(payload, metadata, effectiveTime, author, postProcess) - Create entity
  • update(updatedValue, updatedMetadata, effectiveTime, author, postProcess) - Update entity
  • delete(eId, deleteMetadata, effectiveAt, author, postProcess) - Delete entity
  • addBunch(items, effectiveTime, author, postProcess) - Batch create
  • updateBunch(updates, effectiveTime, author, postProcess) - Batch update

Observer Pattern:
Services implement ObserverManager to notify listeners of changes:

class Impl(...) : ItemService,
  ObserverManager<DataAuthorityNotification<Item, ItemMetadata>> by InMemoryObserverManagerDelegate() {
  // Notifications are automatically sent by DataAuthorityService base implementation
}

Custom Business Logic:
Add domain-specific operations:

interface ItemService : DataAuthorityService<Item, ItemMetadata> {
  suspend fun findBySupplier(supplier: String, asOf: TimeCoordinates): Result<List<EntityRecord<Item, ItemMetadata>>>

  class Impl(...) : ItemService {
    override suspend fun findBySupplier(supplier: String, asOf: TimeCoordinates): Result<List<EntityRecord<Item, ItemMetadata>>> {
      return inTransaction(db, readOnly = true) {
        val filter = Filter.eq(ITEM_TABLE.primarySupply.supplier.name, supplier)
        universe.list(Query(filter), asOf, includeDeleted = false)().map { page ->
          page.records.map { EntityRecord.fromEntity(it) }
        }
      }
    }
  }
}

Editable Data Authority Service

Editable Data Authority Services support the Edit-Draft-Publish pattern for reference data management (see common-module/lib/src/main/kotlin/cards/arda/common/lib/service/EditableDataAuthorityService.kt).

Service Interface:
Extend EditableDataAuthorityService:

interface ItemService : EditableDataAuthorityService<Item, ItemMetadata> {
  class Impl(
    draftStoreP: DraftStore<Item, ItemMetadata>,
    universeP: ItemUniverse,
    dbP: Database
  ) : ItemService {
    override val universe: ItemUniverse = universeP
    override val db: Database = dbP
    override val draftStore: DraftStore<Item, ItemMetadata> = draftStoreP
  }
}

Required Properties:

  • draftStore: DraftStore<EP, M> - Manages draft versions

Draft Management Operations:

  • getDraft(eId, tenantId, author) - Get or create draft
  • updateDraft(draftValue, draftMetadata, author) - Update draft
  • forceDelete(eId, deleteMetadata, effectiveAt, author, postProcess) - Delete with draft cleanup

Edit-Draft-Publish Workflow:

  1. Open Edit: Call getDraft() to create or retrieve a draft
  2. Update Draft: Call updateDraft() repeatedly as user makes changes
  3. Publish: Call update() to publish draft to live entity (draft is automatically closed)
  4. Discard: Delete the draft without publishing

Modified Behavior:

  • update() requires an active draft; closes draft and publishes changes
  • delete() fails if an active draft exists; use forceDelete() to delete both draft and entity

Example Usage:

// Open edit session
val draftResult = itemService.getDraft(itemEId, tenantId, "user@example.com")
val draft = draftResult.getOrThrow()

// Update draft
val updatedDraft = draft.value.copy(name = "Updated Name")
itemService.updateDraft(updatedDraft, draft.metadata, "user@example.com")

// Publish changes
itemService.update(updatedDraft, draft.metadata, clock.next(), "user@example.com")

Parent-Child Data Authority Service

Parent-child relationships require coordinated management of header and line entities (see operations/procurement/orders/service/OrderService.kt for a complete example).

Metadata Design:
Child metadata includes parentEid:

@Serializable
data class OrderLineMetadata(
  override val parentEid: UUID,
  val rank: Int
) : ChildMetadata

Service Structure:
Manage both parent and child universes:

interface OrderService {
  suspend fun createOrder(
    header: OrderHeader,
    lines: List<OrderLine>,
    metadata: OrderMetadata,
    effectiveTime: Long,
    author: String
  ): Result<Pair<EntityRecord<OrderHeader, OrderMetadata>, List<EntityRecord<OrderLine, OrderLineMetadata>>>>

  class Impl(
    val headerUniverse: OrderHeaderUniverse,
    val lineUniverse: OrderLineUniverse,
    val db: Database
  ) : OrderService {
    override suspend fun createOrder(...): Result<...> {
      return inTransaction(db) {
        // Create header
        headerUniverse.create(header, metadata, TimeCoordinates.now(effectiveTime), author)().flatMap { headerEntity ->
          // Create lines scoped to header
          lines.mapIndexed { index, line ->
            val lineMetadata = OrderLineMetadata(headerEntity.eId, index)
            lineUniverse.create(line, lineMetadata, TimeCoordinates.now(effectiveTime), author)()
          }.collectAll().map { lineEntities ->
            EntityRecord.fromEntity(headerEntity) to lineEntities.map { EntityRecord.fromEntity(it) }
          }
        }
      }
    }
  }
}

Transactional Consistency:

  • Create/update parent and children in a single transaction
  • Use flatMap to chain parent creation with child operations
  • Leverage collectAll() for batch child operations
  • When updating a child, ensure the parent is updated as well using Idempotency.CONFIRM even if the parent is not modified. This ensures consistency between the parent and its children in terms of bitemporal coordinates

Querying Children:
Scope queries to parent entity:

suspend fun getOrderLines(orderEId: UUID, asOf: TimeCoordinates): Result<List<EntityRecord<OrderLine, OrderLineMetadata>>> {
  return inTransaction(db, readOnly = true) {
    val filter = Filter.eq("metadata.parentEid", orderEId)
    lineUniverse.list(Query(filter), asOf)().map { page ->
      page.records.map { EntityRecord.fromEntity(it) }
    }
  }
}

REST API Endpoint Implementation Guide

Introduction to the REST API Endpoint DSL

The REST API Endpoint DSL (defined in common-module/lib/src/main/kotlin/cards/arda/common/lib/api/rest/server/service/ServiceEndpointDsl.kt) provides a type-safe, declarative way to define REST endpoints with automatic OpenAPI documentation generation.

Route Configuration:
Endpoints are defined using a hierarchical structure:

val apiDef = serviceDefinition {
  group("api") {
    group("v1") {
      group("items") {
        // Endpoint definitions
      }
    }
  }
}

OpenAPI Integration:
The DSL integrates with ktor-openapi to automatically generate OpenAPI specifications. Each endpoint definition includes:

  • Operation ID
  • Path parameters
  • Query parameters
  • Request body schema
  • Response schema
  • HTTP status codes

Parameter Definitions

Path Parameters:
Define typed path parameters:

val entityIdParam = PathParameter<UUID>("entity-id") {
  description = "The Entity ID of the item"
}

Standard Path Parameters:
Use predefined parameters from PathParameter.Companion:

val resourceId = PathParameter.standardResourceEId("item")
val recordId = PathParameter.standardResourceRId("item")

Query Parameters:
Define query parameters with validation:

val pageSize = QueryParameter<Int>("pageSize") {
  description = "Number of results per page"
  defaultValue = 20
}

Standard Query Parameters:
Use predefined parameters from QueryParameter.Companion:

val effectiveAsOf = QueryParameter.effectiveAsOf
val recordedAsOf = QueryParameter.recordedAsOf
val asOf = QueryParameter.asOf  // Combines both temporal dimensions

Header Parameters:
Define header parameters:

val customHeader = HeaderParameter<String>("X-Custom-Header") {
  description = "Custom header for special processing"
}

Standard Header Parameters:
Use predefined headers from HeaderParameter.Companion and StandardHeaders:

val tenantId = HeaderParameter.tenantId  // UUID
val author = HeaderParameter.author      // String
val requestId = HeaderParameter.requestId // UUID

Body Parameters:
Define request body:

val itemInput = RequiredBodyMessage<ItemInput> {
  description = "The item data to create"
}

Custom Parameter Extractors:
Create custom parameter types by implementing Parameter<T>:

data class CustomParameter<T>(
  override val name: String,
  override val kType: KType,
  val extractor: (RoutingCall) -> Result<T>
) : Parameter<T> {
  override suspend fun extractValue(call: RoutingCall): Result<T> = extractor(call)
}

Standard Parameter Definitions:

StandardHeaders Companion Object:
Located in common-module/lib/src/main/kotlin/cards/arda/common/lib/api/rest/types/openapi/StandardHeaders.kt:

  • tenantId - Tenant identifier (required)
  • author - Request author (optional, for non-JWT auth)
  • requestId - Unique request identifier (optional, auto-generated if missing)
  • responseId - Response header with request ID
  • redirectionLocation - Location header for redirects

StandardPathParams:
Common path parameters:

  • standardResourceEId(resourceName) - Entity ID path parameter
  • standardResourceRId(resourceName) - Record ID path parameter
  • pageId(resourceName) - Page ID for pagination

StandardQueryParams:
Common query parameters:

  • effectiveAsOf - Effective time dimension
  • recordedAsOf - Recorded time dimension
  • asOf - Combined temporal coordinates (auto-defaults missing dimensions to now())

Reusing Standard Parameters:
Standard parameters ensure consistency across endpoints:

post("items") {
  withParameters(
    HeaderParameter.tenantId,
    HeaderParameter.author,
    RequiredBodyMessage<ItemInput>()
  ) { tenantId, author, itemInput ->
    // Implementation
  }
}

Data Authority Endpoints

Using DataAuthorityEndpoint:
The DataAuthorityEndpoint abstract class (in common-module/lib/src/main/kotlin/cards/arda/common/lib/api/rest/server/dataauthority/DataAuthorityEndpointNew.kt) provides standard CRUD endpoints.

Endpoint Definition:
Implement the endpoint by providing transformation functions:

class ItemEndpoint(
  val inModule: ModuleConfig,
  override val reference: EndpointLocator.Rest,
  private val service: ItemService
) : DataAuthorityEndpoint by DataAuthorityEndpoint.reify<ItemInput, ItemInputMetadata, Item, ItemMetadata>(
  inModule,
  reference,
  service,
  ip2Payload = { uuid -> toItem(uuid) },
  im2Metadata = { ItemMetadata(this.tenantId) },
  retrieveMetadata = {
    requireHeader(StandardHeaders.tenantId.name).flatMap { tIdStr ->
      when(val tenantId = tIdStr.validUUIDOrNull()) {
        null -> Result.failure(AppError.ArgumentValidation(StandardHeaders.tenantId.name, "TenantId must be a valid UUID"))
        else -> Result.success(ItemInputMetadata(tenantId))
      }
    }
  },
  recordRespond = { respond(it); Result.success(Unit) },
  draftRespond = null  // Not editable
) {
  override val spec = ItemEndpointSpec(inModule, reference)
}

Standard CRUD Endpoints:
Automatically provides:

  • POST /items - Create item
  • GET /items/{entity-id} - Get item by ID
  • PUT /items/{entity-id} - Update item
  • DELETE /items/{entity-id} - Delete item
  • GET /items - List items with query/pagination
  • GET /items/{entity-id}/history - Get item history

Query and Pagination Handling:
List endpoint automatically handles:

  • Query parameter parsing
  • Filter construction
  • Pagination (page size, page number)
  • Sorting

Request/Response Mapping:
Transformation functions map between API DTOs and domain entities:

val ip2Payload: ItemInput.(UUID) -> Item = { toItem(it) }
val im2Metadata: ItemInputMetadata.() -> ItemMetadata = { ItemMetadata(this.tenantId) }

Editable Data Authority Endpoints

Using EditableDataAuthorityEndpoint:
Extends DataAuthorityEndpoint with draft management endpoints (see common-module/lib/src/main/kotlin/cards/arda/common/lib/api/rest/server/dataauthority/EditableDataAuthorityEndpointNew.kt).

Endpoint Definition:

class ItemEndpoint(
  val inModule: ModuleConfig,
  override val reference: EndpointLocator.Rest,
  private val service: ItemService
) : EditableDataAuthorityEndpoint by EditableDataAuthorityEndpoint.reify<ItemInput, ItemInputMetadata, Item, ItemMetadata>(
  inModule,
  reference,
  service,
  ip2Payload,
  im2Metadata,
  retrieveMetadata,
  recordRespond,
  draftRespond = { respond(it); Result.success(Unit) }
) {
  override val spec = ItemEndpointSpec(inModule, reference)
}

Additional Endpoints:
Provides draft management in addition to standard CRUD:

  • GET /items/{entity-id}/draft - Get or create draft
  • PUT /items/{entity-id}/draft - Update draft
  • DELETE /items/{entity-id}/draft - Discard draft

Publish Workflow:
Publishing uses the standard PUT /items/{entity-id} endpoint, which automatically closes the draft.

Custom Endpoint Extensions:
Add domain-specific endpoints by implementing configureSecureRoutes():

class HomeEndpoint(
  val inModule: ModuleConfig,
  override val reference: EndpointLocator.Rest,
  private val service: ItemService,
  private val daDelegate: EditableDataAuthorityEndpoint = EditableDataAuthorityEndpoint.reify<...>(...)
) : EditableDataAuthorityEndpoint by daDelegate {
  override val spec = HomeEndpointSpec(inModule, reference)

  override fun configureSecureRoutes(rt: Route) {
    daDelegate.configureSecureRoutes(rt)
    configurePrintingRoutes(rt)
  }

  private fun configurePrintingRoutes(rt: Route) {
    rt.openApiPost(spec.printLabelsPath, spec.printLabelsBuilder) {
      responds {
        with(call) {
          optionalQueryParam(spec.printLiveParam).flatMap { liveStr ->
            requireHeader(StandardHeaders.author.name).flatMap { author ->
              requireBody<EntityIdsInput>().flatMap { idsInput ->
                service.printLabels(
                  idsInput.ids,
                  author,
                  liveStr?.toBooleanStrictOrNull() ?: false
                ).map { respond(it) }
              }
            }
          }
        }
      }
    }
  }
}

This example shows how to extend the standard editable data authority endpoints with custom printing functionality (see operations/reference/item/api/HomeEndpoint.kt).


Copyright: © Arda Systems 2025-2026, All rights reserved

Comments