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¶
- Bitemporal Persistence describes the basic concepts of Bitemporal Data Stores
- Universe Design describes in detail the design of the
Universecapabilities.
Entity Behaviors¶
Apart from the basic bitemporal behavior, entitities can adopt additional capabilities:
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 ofuniversalQueryconstraints available in theUniverse.Editability: Some entities, notably those representing Reference Data require a more complex editing behavior than a simpleupdateoperation. For these, a Edit-Draft-Publish lifecycle is implemented through theEditableDataAuthorityService.
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:
exposedPersistence Classes, Tables and Mappings, describing how to define Kotlin classes to create persistent entities.- A
Bitemporal Entitydata type that defines the persisted representation of a bitemporal record of the entity. - A
Universefor the Entity that provides the behavior of a collection of entity instances determined by itsuniversalQueryconstraint. SQLscripts to create the tables in the database that live in theresourcesdirectory 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:
- Services OWN the entity instances they manage and DO NOT SHARE them with other services except through their own Service Interface.
- 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.
- 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.
- 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 mainEntity 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
DSLdefined in theServiceEndpointDsl.ktfile. - Abstract classes to implement Rest API endpoints for Data Authority Services and Editable Data Authority Services are available in the
common-modulelibrary.
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
EntityPayloadinterface - Must include an
eId: UUIDproperty for entity identification - Should be defined as a
sealed interfacewith a data classEntityimplementation - 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(orScopedMetadatafor 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:
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 identifiersmemo(name: String)- TEXT for long-form contenturl(name: String)- VARCHAR(8192) for URLsbool(name: String)- BOOLEAN columnsuuid(name: String)- UUID columnsvarchar(name: String, length: Int)- Variable character columnsdouble(name: String)- DOUBLE PRECISION numeric columnslong(name: String)- BIGINT columnsinteger(name: String)- INTEGER columns
Enumeration Columns:
enumerationByName<E>(name: String, length: Int)- Standard Exposed column. Stores enum as stringenumerated<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:
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 itselfComponent.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 columnsfillMetadata()- 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
Vprefix 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_recordedfor 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:
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 entitiesread()- Read by entity ID at a point in timereadRecord()- Read specific record by record IDlist()- Query entities with filtering, sorting, paginationupdate()- Update existing entitiesdelete()- Soft-delete entitieshistory()- Retrieve entity historycount()- 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:
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:
Read-Only vs. Write Transactions:
- Use
readOnly = truefor queries to enable optimizations - Omit or set
readOnly = falsefor 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 forsuspend () -> 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:
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()orDbioFailure()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
beforeSpecto start container and connect - Use
afterSpecto 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()andSchemaUtils.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 entitydb: Database- The database connection
Inherited Operations:
getAsOf(eId, asOf, includeDeleted)- Retrieve entity at a point in timegetRecord(rId)- Retrieve specific record versionlistEntities(query, asOf)- Query entitieshistory(eId, since, until, page)- Get entity historyadd(payload, metadata, effectiveTime, author, postProcess)- Create entityupdate(updatedValue, updatedMetadata, effectiveTime, author, postProcess)- Update entitydelete(eId, deleteMetadata, effectiveAt, author, postProcess)- Delete entityaddBunch(items, effectiveTime, author, postProcess)- Batch createupdateBunch(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 draftupdateDraft(draftValue, draftMetadata, author)- Update draftforceDelete(eId, deleteMetadata, effectiveAt, author, postProcess)- Delete with draft cleanup
Edit-Draft-Publish Workflow:
- Open Edit: Call
getDraft()to create or retrieve a draft - Update Draft: Call
updateDraft()repeatedly as user makes changes - Publish: Call
update()to publish draft to live entity (draft is automatically closed) - Discard: Delete the draft without publishing
Modified Behavior:
update()requires an active draft; closes draft and publishes changesdelete()fails if an active draft exists; useforceDelete()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
flatMapto 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.CONFIRMeven 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:
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:
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 IDredirectionLocation- Location header for redirects
StandardPathParams:
Common path parameters:
standardResourceEId(resourceName)- Entity ID path parameterstandardResourceRId(resourceName)- Record ID path parameterpageId(resourceName)- Page ID for pagination
StandardQueryParams:
Common query parameters:
effectiveAsOf- Effective time dimensionrecordedAsOf- Recorded time dimensionasOf- 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 itemGET /items/{entity-id}- Get item by IDPUT /items/{entity-id}- Update itemDELETE /items/{entity-id}- Delete itemGET /items- List items with query/paginationGET /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 draftPUT /items/{entity-id}/draft- Update draftDELETE /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