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).
Prerequisites
Section titled “Prerequisites”Familiarity with:
- Data Authority Module Pattern — four-layer architecture
- Bitemporal Persistence — time coordinates
- Universe Design — entity collections
- Table Mappings — Exposed ORM mapping
- Functional Programming — DBIO monad
The guide uses a Sample entity throughout. Replace with your entity name.
1. Define Payload and Metadata
Section titled “1. Define Payload and Metadata”Create the data structures for your entity’s payload and its metadata. Both must be @Serializable.
@Serializabledata 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)}
@Serializabledata class SampleMetadata( @Serializable(with = UUIDSerializer::class) override val tenantId: UUID) : ScopedMetadataEntityPayload— carries the entity’s business data. Thevalidatemethod performs payload-level validation (field constraints that do not require database access).ScopedMetadata— carries tenant scoping. UsePayloadMetadatadirectly only for unscoped entities; most entities useScopedMetadata.
2. Define Table and Record
Section titled “2. Define Table and Record”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:
| Class | Role |
|---|---|
TableConfiguration | Declares the SQL table name |
ScopedTable | Extends UniverseTable with a tenantId column |
ScopedRecord | Extends BitemporalRecord with tenant scoping |
Persistence companion | Factory 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. OverridevalidateForCreate,validateForUpdate, orvalidateForDeleteto 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.
4. Create the Universe
Section titled “4. Create the Universe”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.
5. Create the Service
Section titled “5. Create the Service”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.
6. Create the Endpoint
Section titled “6. Create the Endpoint”The endpoint exposes the REST API. Use DataAuthorityEndpoint.reify to construct it.
@Serializabledata class SampleInput(val name: String)
typealias SampleIP = SampleInputtypealias 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:
| Parameter | Purpose |
|---|---|
IP, IM | Input payload and input metadata types (wire format) |
EP, EM | Entity payload and entity metadata types (domain format) |
ER, PR | Entity record and page result types (response format) |
inModule | Module configuration |
reference | EndpointLocator.Rest defining the URL path |
service | The DataAuthorityService instance |
metadataExtractor | Extracts input metadata (tenant ID) from the HTTP request |
inputTranslator | Converts wire-format input to domain payload + metadata |
7. Register in the Ktor Module
Section titled “7. Register in the Ktor Module”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.
8. Add Flyway Migration
Section titled “8. Add Flyway Migration”Create a migration script for the new table. Follow the naming convention V<version>__<description>.sql.
-- V1__create_sample_table.sqlCREATE 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.
Checklist
Section titled “Checklist”- Payload implements
EntityPayloadwithvalidate - Metadata implements
ScopedMetadata(orPayloadMetadatafor unscoped) - Table extends
ScopedTable, record extendsScopedRecord -
Persistencecompanion object withfillInstance,fillPayload,fillMetadata - Validator extends
ScopingValidatorwith entity-specific rules - Universe extends
AbstractScopedUniverse - Service extends
DataAuthorityService - Endpoint uses
DataAuthorityEndpoint.reifywith correct type parameters - Module function follows Ktor wiring pattern with injectable dependencies
- Flyway migration creates table with all bitemporal columns
- Serialization configured via
ContentNegotiationwithJsonConfig.standardJson
Copyright: © Arda Systems 2025-2026, All rights reserved