Skip to content

Service Implementation Requirements Best Practices

This document provides guidance on writing high-quality service implementation requirements for Arda’s backend Kotlin services. Follow these practices when creating or reviewing a Service Implementation Requirements document.

Every service module follows a consistent package structure:

cards.arda.operations.<domain>.<module>/
├── Module.kt # Entry point and DI wiring
├── api/ # REST endpoint definitions
│ └── rest/<Entity>Endpoint.kt
├── business/ # Business entities (sealed interfaces + data classes)
│ ├── <Entity>.kt
│ └── <Entity>Metadata.kt
├── domain/ # Value types, enums, domain logic
│ ├── <ValueType>.kt
│ └── persistence/ # Column component definitions
├── persistence/ # Database layer
│ ├── <Entity>Persistence.kt # Table + Record definitions
│ └── <Entity>Universe.kt # Persistence operations
└── service/ # Service interface and implementation
└── <Entity>Service.kt
Interface/ClassPurposeWhen to Use
EntityPayloadBase for all entities with eIdAll business entities
ScopedMetadataMetadata with tenant scopingRoot entities
ChildMetadataMetadata with parent referenceChild entities
EditableDataAuthorityService<E, M>CRUD service contractServices managing entities
ScopedTableBase table with tenant supportAll entity tables
ScopedRecord<E, M, T, R>ORM record with bitemporal supportAll entity records
ScopedUniversePersistence operations wrapperEncapsulating DB operations

All business entities should use the sealed interface pattern:

@Serializable(with = EntitySerializer::class)
sealed interface Entity : EntityPayload {
override val eId: EntityId
val name: String
@Serializable
data class Entity(
override val eId: EntityId,
override val name: String,
) : Entity {
override fun validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit> {
// Validation logic returning AppError.Composite on failure
}
}
}

Rationale: This pattern enables polymorphic serialization and future extension.

All methods that can fail must return Result<T>:

  • Success path: Result.success(value)
  • Failure path: Result.failure(AppError.<Type>(...))
  • Chaining: Use flatMap, map, onSuccess, onFailure
  • Collection operations: Use collectAll() for List<Result<T>>

Avoid throwing exceptions:

// Bad
suspend fun create(entity: Entity): Entity {
if (entity.name.isBlank()) throw IllegalArgumentException("Name required")
return repository.save(entity)
}
// Good
suspend fun create(entity: Entity, ...): Result<EntityRecord<E, M>> {
return entity.validate(ctx, Mutation.CREATE).flatMap {
universe.create(entity, metadata, asOf, author)()
}
}

Document behavior for all three qualifiers for operations that accept them:

QualifierIntentValidationSide Effects
LAXFlexibility, importsMinimalUpsert behavior
STRICTData integrity, UIFull consistency checkNo implicit changes
PROPAGATECascade changesRelaxedUpdates related entities

Always include a “Behavior by Qualifier” table in every method specification.

All persistence operations must account for bitemporal storage:

  • time: Long: Effective time for the operation
  • TimeCoordinates.now(time): Standard construction pattern
  • asOf queries: Point-in-time reads
  1. Use value types for complex properties: Quantity.Value instead of separate amount + unit.
  2. Required fields: non-nullable with validation. Optional fields: nullable with defaults in Entity data class.
  3. Entity references: Use UUID (as EntityId). Pair with optional name/display field for denormalization.
  4. Enum values: Place in domain/ package. Include UNKNOWN as first value for forward compatibility.

Collect all validation errors before returning:

override fun validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit> {
val errors = mutableListOf<AppError>()
if (name.isBlank()) errors.add(AppError.ArgumentValidation("name", "Name is required"))
return if (errors.isEmpty()) Result.success(Unit)
else Result.failure(AppError.Composite("<Entity> Validation Failed", null, errors))
}
OperationStandard Signature
Createsuspend fun create(payload: E, metadata: M, time: Long, author: String, qualifier: Qualifier): Result<EntityRecord<E, M>>
Updatesuspend fun update(payload: E, metadata: M, time: Long, author: String, qualifier: Qualifier): Result<EntityRecord<E, M>>
Deletesuspend fun delete(eId: UUID, time: Long, author: String): Result<EntityRecord<E, M>>
Getsuspend fun getAsOf(eId: UUID, asOf: TimeCoordinates): Result<EntityRecord<E, M>?>
Listsuspend fun listAsOf(filter: Filter, asOf: TimeCoordinates): Result<PageResult<E, M>>
class Impl(
override val draftStore: DraftStore<E, M>,
override val universe: <Entity>Universe,
private val dependency: DependencyService,
override val db: Database,
) : <Entity>Service,
ObserverManager<...> by InMemoryObserverManagerDelegate(),
LogEnabled by LogProvider(Impl::class) {
override val serviceId = TypeId()
override suspend fun create(...): Result<EntityRecord<E, M>> = inTransaction(db) {
// Implementation
}
}
  • Wrap operations in inTransaction(db) { ... } or inTransaction(db, readOnly = true) { ... }
  • Document which operations are transactional in method specifications

Use lowercase snake_case. Use component columns for complex value types:

val orderQuantity = root.quantityComponent<...>("order_quantity")
// → order_quantity_amount, order_quantity_unit
class EntityRecord(rId: EntityID<UUID>) : ScopedRecord<E, M, TABLE, EntityRecord>(rId, TABLE) {
var property: Type by TABLE.property
companion object : Persistence<E, M, TABLE, EntityRecord>(
TABLE,
EntityRecord::class.java,
{ EntityRecord(it) },
{ insertStm, payload, metadata ->
insertStm[property] = payload.property
// Map all properties
}
)
override fun fillPayload() {
payload = Entity.Entity(
eId = eId,
property = property,
// Map all properties
)
}
override fun fillMetadata() {
metadata = EntityMetadata(tenantId)
}
}

fillPayload() maps database columns back to the business entity. fillMetadata() reconstructs the metadata object (e.g., tenant scope, parent reference). Both methods must be implemented for every record class.

The Universe encapsulates all persistence operations for a single entity type:

class EntityUniverse : ScopedUniverse<E, M, TABLE, EntityRecord>(
TABLE,
EntityRecord,
EntityUniversalCondition
) {
// Custom queries go here
suspend fun findByName(name: String, asOf: TimeCoordinates): Result<BiTemporalEntity<E, M>?> = ...
}

Custom queries beyond standard CRUD belong in the Universe class, not in the service.

When adding columns to existing tables:

  1. Add nullable column first.
  2. Backfill existing data.
  3. Add NOT NULL constraint after confirming all data is backfilled.
fun Application.<module>(
cfgProvider: ConfigurationProvider,
authentication: Authentication,
// ... other dependencies
): <Entity>Service {
val moduleCfg = cfgProvider.moduleConfiguration("system.<domain>.<module>")
// Validate required configuration
val dsCfg = moduleCfg.dataSource
?: throw AppError.ArgumentValidation("cfg.dataSource", "DataSource is required for <Module> Module")
// Initialize database
val db: Database = DataSource(dsCfg.db, dsCfg.pool).newDb(dsCfg.flywayConfig)
// Construct dependencies
val universe = <Entity>Universe()
val service = <Entity>Service.Impl(universe, db, ...)
// Configure endpoints
val endpoint = <Entity>Endpoint.Impl(moduleCfg, locator, service)
val ktorModule = MultiEndpointKtorModule(...)
ktorModule.configureServer(this)
return service
}

Support test injection with nullable constructor parameters:

fun Application.<module>Module(
...,
injectedUniverse: <Entity>Universe? = null,
injectedService: <Entity>Service? = null
): <Entity>Service {
val activeUniverse = injectedUniverse ?: <Entity>Universe()
val service = injectedService ?: <Entity>Service.Impl(activeUniverse, db, ...)
}
  • Sealed interface pattern: Uses sealed interface + Entity data class
  • Validation method: Implements validate() returning Result<Unit>
  • Error aggregation: Validation collects all errors into AppError.Composite
  • Result return types: All fallible methods return Result<T>
  • Suspend functions: All I/O operations are suspend
  • Qualifier documentation: All three behaviors documented per method
  • Pre-conditions defined
  • Error conditions mapped: All failure cases mapped to AppError types
  • Side effects listed
  • Transaction scope clear
  • Column naming: Snake_case matching conventions
  • Component columns: Complex types use components
  • Fill methods: Both fillPayload() and fillMetadata() implemented
  • Universe queries: Custom queries encapsulated in Universe class
  • Unit tests specified: Coverage for validation, each qualifier
  • Integration tests: Database round-trip testing identified
  • Harness structure: Clear test setup pattern documented

Throwing Exceptions Instead of Returning Failures

Section titled “Throwing Exceptions Instead of Returning Failures”

Bad: Using throw NotFoundException(...) instead of Result.failure(AppError.NotFound(...))

Bad: A method spec that says “Creates the entity” without differentiating LAX/STRICT/PROPAGATE.

Bad: Using IllegalArgumentException, RuntimeException, or generic Exception.

Good: Using the AppError hierarchy consistently.

Bad:

val filter = Filter.Eq("name", value)

Good:

val filter = Filter.Eq(TABLE.name.name, value)

Referencing the column object directly ensures refactoring is safe and eliminates typos.

Bad:

suspend fun get(id: UUID): Result<Entity?> {
val entity = repository.find(id)
if (entity == null) return Result.success(null)
if (!entity.isActive) return Result.failure(AppError.IncompatibleState("..."))
return Result.success(entity)
}

Good:

suspend fun get(id: UUID): Result<Entity?> {
return repository.find(id).flatMap { entity ->
when {
entity == null -> Result.success(null)
!entity.isActive -> Result.failure(AppError.IncompatibleState("..."))
else -> Result.success(entity)
}
}
}

Prefer functional chaining with flatMap over early return statements to keep error handling consistent.

Each requirement section maps to a specific implementation artifact:

  1. Entity Definitions (ED-*) → Implement business/<Entity>.kt
  2. Value Types (VT-*) → Implement domain/<ValueType>.kt
  3. Persistence Specs (PS-*) → Implement persistence/<Entity>Persistence.kt and <Entity>Universe.kt
  4. Service Interface (SI-*) → Implement service/<Entity>Service.kt
  5. Method Specs (MS-*) → Implement each method in Service.Impl
  6. Module Config (MC-*) → Implement Module.kt

Reference requirement IDs in test names:

@Test
fun `MS-ITM-003 - update with STRICT qualifier fails when supply name mismatches`() { ... }
@Test
fun `VR-001 - entity validation fails when name is blank`() { ... }
  • Kotlin coding standards — see kotlin-coding skill
  • Testing patterns and practices — see unit-tests skill
  • Task Workflow — see implementation-task skill