Skip to content

Data Authority Endpoint Guide

Step-by-step guide for implementing a new Data Authority endpoint. This is the procedural companion to the Data Authority Module Pattern (design) and Implementation Patterns (wiring).

Familiarity with:

The guide uses a Sample entity throughout. Replace with your entity name.

Create the data structures for your entity’s payload and its metadata. Both must be @Serializable.

lib/src/main/kotlin/cards/arda/yourmodule/model/
@Serializable
data class Sample(
@Serializable(with = UUIDSerializer::class)
override val eId: EntityId,
val name: String
) : EntityPayload {
override fun validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit> =
Result.success(Unit)
}
@Serializable
data class SampleMetadata(
@Serializable(with = UUIDSerializer::class)
override val tenantId: UUID
) : ScopedMetadata
  • EntityPayload — carries the entity’s business data. The validate method performs payload-level validation (field constraints that do not require database access).
  • ScopedMetadata — carries tenant scoping. Use PayloadMetadata directly only for unscoped entities; most entities use ScopedMetadata.

Map the payload to database columns using the Exposed ORM.

object SampleTableConfiguration : TableConfiguration {
override val name = "SAMPLE"
}
object SampleTable : ScopedTable(SampleTableConfiguration) {
val name = varchar("ITEM_NAME", 255)
}
class SampleRecord(rId: EntityID<UUID>) :
ScopedRecord<Sample, SampleMetadata, SampleTable, SampleRecord>(rId, SampleTable) {
companion object : Persistence<Sample, SampleMetadata, SampleTable, SampleRecord>(
SampleTable,
SampleRecord::class.java,
{ SampleRecord(it) },
{
name = payload.name
tenantId = metadata.tenantId
}
)
var name by SampleTable.name
override fun fillPayload() {
payload = Sample(this.eId, this.name)
}
override fun fillMetadata() {
metadata = SampleMetadata(this.tenantId)
}
}

Key classes:

ClassRole
TableConfigurationDeclares the SQL table name
ScopedTableExtends UniverseTable with a tenantId column
ScopedRecordExtends BitemporalRecord with tenant scoping
Persistence companionFactory that maps between Kotlin objects and table rows via fillInstance (write) and fillPayload/fillMetadata (read)

See Table Mappings for column naming conventions and fill patterns.

3. Create Validator and Universal Condition

Section titled “3. Create Validator and Universal Condition”

The validator runs database-aware checks before mutations. The universal condition provides the tenant-scoping filter.

object SampleValidator : ScopingValidator<Sample, SampleMetadata>() {
private fun validateName(name: String): DBIO<Unit> = suspend {
if (name.isBlank())
Result.failure(AppError.ArgumentValidation("Sample name cannot be blank"))
else
Result.success(Unit)
}
override suspend fun validateForCreate(
ctx: ApplicationContext, payload: Sample, metadata: SampleMetadata,
asOf: TimeCoordinates, author: String
): DBIO<Unit> =
super.validateForCreate(ctx, payload, metadata, asOf, author)
.liftMap { validateName(payload.name) }
override suspend fun validateForUpdate(
ctx: ApplicationContext, payload: Sample, metadata: SampleMetadata,
asOf: TimeCoordinates, author: String,
idempotency: Idempotency, previous: BitemporalEntity<Sample, SampleMetadata>?
): DBIO<Unit> =
super.validateForUpdate(ctx, payload, metadata, asOf, author, Idempotency.CONFIRM, null)
.liftMap { validateName(payload.name) }
}
object SampleUniversalCondition : ScopedUniversalCondition()
  • ScopingValidator — base class that checks tenant scope. Override validateForCreate, validateForUpdate, or validateForDelete to add entity-specific rules.
  • ScopedUniversalCondition — filters all queries by the caller’s tenant. Override only if your entity has different scoping rules.
  • Validation functions return DBIO<Unit> and compose with .liftMap { } — see Functional Programming for the DBIO combinator reference.

The Universe ties together table, record, validator, and universal condition.

class SampleUniverse : AbstractScopedUniverse<
Sample, SampleMetadata, SampleTable, SampleRecord
>(), LogEnabled by LogProvider(SampleUniverse::class) {
override val persistence = SampleRecord.Companion
override val validator = SampleValidator
override val universalCondition = SampleUniversalCondition
}

AbstractScopedUniverse provides default implementations for all Universe methods (create, read, update, delete, list, count, history, findOne, aggregate). Override individual methods only when custom persistence logic is needed. See Universe Design for the full interface.

The service layer orchestrates business logic and transaction boundaries.

interface SampleDataAuthorityService :
DataAuthorityService<Sample, SampleMetadata> {
// Add entity-specific service methods here if needed
}
class SampleDataAuthorityServiceImpl(
override val db: Database,
override val universe: Universe<Sample, SampleMetadata>
) : SampleDataAuthorityService,
ObserverManager<DataAuthorityNotification<Sample, SampleMetadata>>
by DelegatedObserverManager() {
// Base DataAuthorityService methods (getAsOf, add, update, delete, listEntities)
// are provided by default implementations through the Universe.
// Override for pre/post processing or custom business logic.
}

DataAuthorityService provides default method implementations that delegate to the Universe. The service also implements ObserverManager to emit notifications on entity changes.

The endpoint exposes the REST API. Use DataAuthorityEndpoint.reify to construct it.

@Serializable
data class SampleInput(val name: String)
typealias SampleIP = SampleInput
typealias SampleIM = UUID
val metadataExtractor: RoutingCall.() -> Result<SampleIM> = {
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(tenantId)
}
}
}
val inputTranslator: (EntityId, SampleIP?, SampleIM?) -> Result<Pair<Sample?, SampleMetadata?>> =
{ eId, ip, im ->
Result.success(
ip?.let { Sample(eId, it.name) } to im?.let { SampleMetadata(it) }
)
}
class SampleDataAuthorityEndpoint(
override val reference: EndpointLocator.Rest,
service: SampleDataAuthorityService
) : DataAuthorityEndpoint
by DataAuthorityEndpoint.Companion.reify<
SampleIP, SampleIM,
Sample, SampleMetadata,
EntityRecord<Sample, SampleMetadata>,
PageResult<Sample, SampleMetadata>
>(
inModule = moduleConfig,
reference = reference,
service = service,
metadataExtractor = metadataExtractor,
inputTranslator = inputTranslator,
)

The reify factory parameters:

ParameterPurpose
IP, IMInput payload and input metadata types (wire format)
EP, EMEntity payload and entity metadata types (domain format)
ER, PREntity record and page result types (response format)
inModuleModule configuration
referenceEndpointLocator.Rest defining the URL path
serviceThe DataAuthorityService instance
metadataExtractorExtracts input metadata (tenant ID) from the HTTP request
inputTranslatorConverts wire-format input to domain payload + metadata

Wire everything together in the module’s entry point.

fun Application.sampleModule(
inComponent: ComponentConfiguration,
locator: EndpointLocator.Rest,
cfg: ModuleConfig,
authentication: Authentication,
injectedUniverse: SampleUniverse? = null,
injectedService: SampleDataAuthorityService? = null,
): SampleDataAuthorityService {
val db: Database = DataSource(cfg.dataSource!!.db, cfg.dataSource!!.pool)
.newDb(cfg.dataSource!!.flywayConfig)
val service = injectedService
?: SampleDataAuthorityServiceImpl(db, injectedUniverse ?: SampleUniverse())
val endpoint = SampleDataAuthorityEndpoint(locator, service)
MultiEndpointKtorModule(inComponent, cfg, authentication, listOf(endpoint))
.configureServer(this)
return service
}

This follows the standard Ktor Module Wiring pattern. The injected* parameters support testing with mock universes or services.

Create a migration script for the new table. Follow the naming convention V<version>__<description>.sql.

-- V1__create_sample_table.sql
CREATE TABLE SAMPLE (
record_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_id UUID NOT NULL,
tenant_id UUID NOT NULL,
effective_from BIGINT NOT NULL,
effective_to BIGINT NOT NULL DEFAULT 9223372036854775807,
recorded_from BIGINT NOT NULL,
recorded_to BIGINT NOT NULL DEFAULT 9223372036854775807,
author VARCHAR(255) NOT NULL,
created_by VARCHAR(255) NOT NULL,
created_at BIGINT NOT NULL,
retired BOOLEAN NOT NULL DEFAULT FALSE,
previous UUID REFERENCES SAMPLE(record_id),
ITEM_NAME VARCHAR(255) NOT NULL
);
CREATE INDEX SAMPLE_ENTITY_ID_INDEX ON SAMPLE(entity_id);
CREATE INDEX SAMPLE_TENANT_ID_INDEX ON SAMPLE(tenant_id);

The bitemporal columns (effective_from, effective_to, recorded_from, recorded_to, author, created_by, created_at, retired, previous) are standard across all Data Authority tables.

  • Payload implements EntityPayload with validate
  • Metadata implements ScopedMetadata (or PayloadMetadata for unscoped)
  • Table extends ScopedTable, record extends ScopedRecord
  • Persistence companion object with fillInstance, fillPayload, fillMetadata
  • Validator extends ScopingValidator with entity-specific rules
  • Universe extends AbstractScopedUniverse
  • Service extends DataAuthorityService
  • Endpoint uses DataAuthorityEndpoint.reify with correct type parameters
  • Module function follows Ktor wiring pattern with injectable dependencies
  • Flyway migration creates table with all bitemporal columns
  • Serialization configured via ContentNegotiation with JsonConfig.standardJson