Kotlin: Functional Domain Modeling #6
In this blog we introduce the repository: a storage place for the principal aggregates of an application. The repository provides a persistent storage for the aggregates and support for retrieval from the store. Usually, a repository has an implementation based on a relational database management system. In this blog we keep the repository as a simple in-memory storage so that in the next blog we can explore injecting it into the services of an application.
Repositories
Repositories are where the larger aggregates of the application live. The nature of the repository structures do not need to fully match that of the aggregate but you can always construct an aggregate from the repository. The repository also supports querying it for a particular aggregate. Here, the aggregate in our library application is a cataloged book.
Here is a simple API for the cataloged book repository:
typealias Error = String
interface LibraryRepositoryIF {
fun store(book: CatalogBook): Either<Error, CatalogBook>
fun remove(id: String): Either<Error, CatalogBook>
fun contains(predicate: (String) -> Boolean): Boolean
fun contains(id: String): Boolean =
contains{iden: String -> (iden == id)}
fun lookUp(id: String): Option<CatalogBook>
fun adjust(id: String, f: (CatalogBook) -> CatalogBook): Either<Error, CatalogBook>
} // LibraryRepositoryIF
Note that we have kept the return types of the functions as Either to account for the possible failures that might occur when interacting with the repository.
From here we can have specific implementations to match the chosen storage mechanism. The following is an simple implementation based on the Dogs Map type. The Map class is implemented as a simple balanced binary tree. The library also includes a Map class under the package hamt. The Hash Array Mapped Trie is a structure for organizing data in a broadly-branching tree. The high-branching factor results in the data being stored in a a very shallow tree structure resulting in improved performance over the balanced binary tree representation.
This implementation for the LibraryRepository might, in turn, be used as a mocking repository during testing. Here is the in-memory variant:
object LibraryRepository : LibraryRepositoryIF {
override fun store(book: CatalogBook): Either<Error, CatalogBook> {
val catalogBook: Option<CatalogBook> = library.lookUpKey(book.catalogNumber)
return catalogBook.fold(
{
library = library.insert(book.catalogNumber, book)
right(book)
},
{ left("LibraryRepository.store: existing book with catalog number: ${book.catalogNumber}") }
)
} // store
// ...
// ---------- properties ----------------------------------
// key is catalog number
var library: Map<String, CatalogBook> = MapF.empty()
} // LibraryRepository
We show the implementation for function store. First, it looks up the book's catalog number as the key into the Map, returning an Option<CatalogBook>. The generic Option<A> class includes the fold member function with the signature:
fun <B> fold(none: () -> B, some: (A) -> B): B
The first parameter function none is called if the receiving Option is a None. The second parameter function some is called if the receiving Option is a Some with its wrapped value as parameter. In function store we call fold on the catalogBook object. If its a None then we know no such book already exists, and we insert the book into the map and return as a success operation. If it is a Some then we return as a failure.
The following test results in a successful storing of a cataloged book:
val catalogBook: CatalogBook =
CatalogBook(
"book0001",
Book(
"Kotlin in Action",
"Manning",
LocalDate.of(2017, 1, 1),
ISBN("9781617293290"),
NonEmptyListF.of(
Author(Name(LastName("JEMEROV"), FirstName("Dmitry"), none()), 1992),
Author(Name(LastName("ISAKOVA"), FirstName("Svetlana"), none()), 1992)
)
),
DeweyClassification("005"),
LocalDate.now()
)
assertEquals(
right(catalogBook),
repo.store(catalogBook)
)
The next test fails because we attempt to store a book with the same catalog number twice.
val catalogBook: CatalogBook =
CatalogBook(
"book0001",
Book(
"Jetpack Compose",
"RayWenderlich Tutorial Team",
LocalDate.of(2017, 1, 1),
ISBN("9781950325122"),
NonEmptyListF.of(
Author(Name(LastName("BALINT"), FirstName("Tine"), none()), 1992),
Author(Name(LastName("BUKETA"), FirstName("Denis"), none()), 1992)
)
),
DeweyClassification("005"),
LocalDate.now()
)
assertEquals(
left("LibraryRepository.store: existing book with catalog number: book0001"),
repo.store(catalogBook)
)
Populating the repository
In this latest version, the LibraryRepository is pre-populated with four books so we can perform our tests without having to repeatedly create and populate the repository. For that we use the Kotlin init block. During the initialization of an instance, Kotlin executes the initializer block(s) and property initializer(s) in the same order as they appear in the class/object body. Thus we have:
object LibraryRepository : LibraryRepositoryIF {
// ...
// ---------- properties ----------------------------------
// key is catalog number
var library: Map<String, CatalogBook> = MapF.empty()
init {
val catalogBooks: List<CatalogBook> = ListF.of(
CatalogBook(
"book0001",
Book(
"Kotlin in Action", // two copies
"Manning",
LocalDate.of(2017, 1, 1),
ISBN("9781617293290"),
NonEmptyListF.of(
Author(Name(LastName("JEMEROV"), FirstName("Dmitry"), OptionF.none()), 1992),
Author(Name(LastName("ISAKOVA"), FirstName("Svetlana"), OptionF.none()), 1992)
)
),
DeweyClassification("005"),
LocalDate.of(2020, 2, 2)
),
// ...
)
library = catalogBooks.foldLeft(MapF.empty()){lib, cBook -> lib.insert(cBook.catalogNumber, cBook)}
}
} // LibraryRepository
Invariants and laws
A functional domain model is characterized as a series of functions that operate on a set of types and honor a number of invariants. The sets are the data types that form the model. The functions that operate on the data types are published as the API to the user. When we define the operations in an API, the invariants define the relationships between these operations.
We need to capture some of the invariants that our APIs have to honor. They can be generic constraints or they can be derived from the domain. One of the basic laws that we should enforce for our repository is: for all cataloged books if we store one in the repository then immediately remove it then it will no longer be present in the repository.
The KwikCheck test framework for Kotlin (as described here) is modeled after the QuickCheck framework. Property-based testing is generative testing. You do not supply specific example inputs with expected outputs as with unit tests. Instead, you define properties about the code and use the generative-testing engine to create randomized inputs to ensure the defined properties are correct. KwikCheck allows us to express these properties and generate the randomized inputs.
Property-based testing is a technique where your tests describe the properties and behavior you expect your code to have, and the testing framework tries to find inputs that violate those expectations. Rather than testing a function against specific inputs we try to reason about its behavior over a range of inputs.
Test properties are presented as logical propositions. For example, for all library repositories repo and all cataloged books bk the following proposition is a statement that affirms or denies the predicate:
repo.store(bk).remove(bk.catalogNumber).contains(bk.catalogNumber) ==> false
We test this property embedded in a Unit test framework. The forAll function accepts a generator that produces a randomized CatalogBook containing property values. The generated CatalogBook is captured in the lambda parameter as catalogBook. Function prop expects a predicate for our logical proposition and wraps it into a Property instance. Function check then runs the test and delivers a CheckResult value which, since we are using a Unit test harness, we can complete with an assert.
val property = forAll(genCatalogBook){catalogBook ->
repo.store(catalogBook)
repo.remove(catalogBook.catalogNumber)
val found: Boolean = repo.contains(catalogBook.catalogNumber)
prop(!found)
}
val checkResult = property.check()
assertTrue(checkResult.isPassed())
The property we are checking require that all store/remove operations pair on the repository with any arbitrary CatalogBook means the book is not in the repository. We establish the genCatalogBook binding for a Gen<CatalogBook> that delivers a random but valid CatalogBook.
The core of the KwikCheck library comprises the classes Gen<A> and Property. The class Gen<A> is the generator class for values of type A. The class Property represents an algebraic property that may be checked for its truth. Its most important member function is check which delivers a result.
Here is the binding for genCatalogBook:
val genCatalogBook: Gen<CatalogBook> = genCatalogNumber.bind{ nbr ->
genBook.bind{bk ->
GenF.value(CatalogBook(nbr, bk, DeweyClassification("005"), LocalDate.now()))
}
}
The Gen class is monadic and supports the bind operation. As shown, we generate a valid catalog number with genCatalogNumber, bind it to the lambda parameter nbr, we then generate a valid Book instance with genBook and bind it to parameter bk, finally creating a valid CatalogBook.
Continuing in a similar manner we produce genCatalogNumber:
val genCatalogNumber: Gen<String> = GenF.genPosInt(1, 9999).bind{ nbr ->
val suffix: String = "0000$nbr".takeLast(4)
GenF.value("book$suffix")
}
and so on.
A typical instance produced by genCatalogNumber is:
CatalogBook(
catalogNumber=book2256,
book=Book(
title=iwsdxtptcighjjnUlxCdmfskpiFdtaaszwgvklHfv,
publisher=ajfxOrgzpcgnrotxutsgjmyciptequiqdnUvpwksxIwrfkqsvppafhUrySwuljuoxzgodioupnrrjgezqveEoss,
publicationDate=2003-09-21,
isbn=ISBN(isbn=1677465921),
authors=[
Author(
name=Name(
lastName=LastName(lastName=EBCHRBHWVR),
firstName=FirstName(firstName=Edjpftzfefm),
middleName=None
),
yearOfBirth=1996
),
Author(
name=Name(
lastName=LastName(lastName=ZAWNMYDAQS),
firstName=FirstName(firstName=Uriygbbzcfl),
middleName=None
),
yearOfBirth=2003
)
]
),
dewey=DeweyClassification(dewey=005),
openDate=2022-10-28,
withdrawnDate=None
)
The title and publisher are randomly generated strings, the publication date is randomly generated from 2000 to 2020, the ISBN is generated from the 10-digit ISBN. Note how the last names are randomly generated fully capitalized alphabetic strings, while the first names are randomly generated alphabetic strings with a leading capital. All these domain generators are derived from the many base generators provided by KwikCheck.
The code for the Dogs library can be found at:
https://github.com/KenBarclay/TBA