Skip to content

DBIO Design Pattern

Overview

The DBIO type is a core building block of the persistence layer in Arda. It represents a Functional Effect that encapsulates a database operation (or any side-effecting I/O operation) that yields a result of type Result<R>.

It is designed to separate the description of a database operation from its execution, enabling composition, transaction management, and lazy evaluation.

Definition

In Arda’s system, DBIO is defined as a type alias for a suspending function that returns a Result:

typealias DBIO<R> = suspend () -> Result<R>

This simple definition brings powerful functional programming capabilities to the persistence layer without introducing heavy frameworks.

Key Characteristics

  1. Lazy: A DBIO instance is just a function. Creating it does not execute the operation. It must be invoked (called) to run.
  2. Side-Effecting: It encapsulates operations that perform I/O (Database access) and can be executed in a transactional context using any Coroutine Executor, allowing for increased concurrent execution of potentially slow operations.
  3. Fail-Safe: It wraps the result in Result<R>, forcing the caller to handle potential failures (exceptions) explicitly.
  4. Suspendable: It supports Kotlin coroutines, allowing non-blocking I/O.

The Functional Effect Pattern

DBIO follows the “IO Monad” pattern typically found in functional languages (like Haskell’s IO or Scala’s ZIO/Cats Effect).

By treating database operations as values (DBIO<R>), we can pass them around, compose them, and transform them without actually running them. This allows us to build complex workflows from simple, atomic operations.

Composition and Combinators

The power of DBIO comes from its combinators, which allow you to chain and combine operations.

Mapping (map)

Transform the result of a successful operation.

val getCount: DBIO<Int> = ...
val getDoubleCount: DBIO<Int> = getCount.map { it * 2 }

Chaining (liftMap / flatMap)

Use the result of one operation to determine the next operation. This is essential for sequential dependencies.

val findUser: DBIO<User> = ...
fun findPosts(user: User): DBIO<List<Post>> = ...

val userPosts: DBIO<List<Post>> = findUser.liftMap { user ->
    findPosts(user)
}

Sequencing (then)

Execute one operation after another, ignoring the result of the first.

val logAccess: DBIO<Unit> = ...
val getData: DBIO<Data> = ...

val loggedGetData: DBIO<Data> = logAccess.then(getData)

Zipping (both)

Run two operations and combine their results into a Pair.

val op1: DBIO<Int> = ...
val op2: DBIO<String> = ...

val combined: DBIO<Pair<Int, String>> = op1.both(op2)

Parallel Execution (parallel)

Run two operations concurrently using coroutines and return the first successful result (or the last failure).
Note: This specific implementation of parallel in code seems to implement a “race” or “fallback” logic (orElse), or parallel execution where one result is preferred. Check implementation details carefully.
(Based on the provided code, parallel uses async but does r1.await().orElse(r2.await()), effectively trying both and returning the first success).

Validations

Validation logic often returns Result<Unit>. These can be lifted into DBIO and chained.

fun validate(input: Input): Result<Unit> = ...

val op: DBIO<Entity> = validate(input).lift().then(createEntity(input))

Transaction Management

DBIO operations are agnostic of transactions by default. However, they can be lifted into a transactional context using TDBIO, which
requires the presence of a Transaction object explicitly.

typealias TDBIO<R> = suspend Transaction.() -> Result<R>
  • transactional(): Converts DBIO<T> to TDBIO<T>.
  • io(tx): Runs a TDBIO<T> using a specific Exposed Transaction and returns a DBIO<T>.

This allows you to compose multiple DBIO operations and then execute them all within a single transaction bubble at the Service or Endpoint layer.

Usage in Persistence Universe

The Universe interface uses DBIO for all its operations (create, read, update, delete, list).

interface Universe<EP, M> {
    suspend fun create(...): DBIO<BitemporalEntity<EP, M>>
    suspend fun read(...): DBIO<BitemporalEntity<EP, M>?>
}

When you call universe.create(...), nothing is written to the database yet. You receive a DBIO “program” that describes the creation. You must execute it to apply the change.

Example: Composed Service Method

fun createWithDependent(parentPayload: Parent, childPayload: Child): DBIO<Pair<Parent, Child>> {
    // 1. Create Parent
    val createParentOp = parentUniverse.create(parentPayload, ...)

    // 2. Use parent result to create child
    return createParentOp.liftMap { createdParent ->
        val childWithRef = childPayload.copy(parentId = createdParent.eId)
        childUniverse.create(childWithRef, ...).map { createdChild ->
            createdParent to createdChild
        }
    }
}

// Execution (in Endpoint or Application Service)
suspend fun handleRequest() {
    val operation = createWithDependent(p, c)
    val result = operation() // Executes the chain
    // handle result
}

Benefits

  1. Testability: You can unit test the composition logic without mocking the database if you abstract the factories.
  2. Referential Transparency: DBIO values are immutable descriptions.
  3. Control: You control exactly when side effects happen.
  4. Error Handling: Built-in Result pattern ensures errors are treated as values and not just exceptions that bubble up uncontrollably.

Comments