I am investigationg step builder patterns in Kotlin and have a requirement where some of the steps are repeatable or loop able as follows:-
I have a Map<Header, List<Item>>
and would like to support the following use cases
Steps
1). User has to add a header first
2). User has to add atleast one item which will added to the current headers list
3.a). the user can now choose to build the map and this is a terminal step.
3.b). the user can choose to add another header and item(s) e.g. repeat steps 1).& 2).
is it possible to implement this in a kotlin step builder pattern?
Valid Examples:-
One Header with Items
val singleHeaderWithItems : Map<Header, List<Item>> = builder()
.withHeader(HeaderOne())
.withItems(HeaderOneItems())
.build()
Multiple Headers with Items
val multipleHeadersWithItems : Map<Header, List<Item>> = builder()
.withHeader(HeaderOne())
.withItems(HeaderOneItems())
.withHeader(HeaderTwo())
.withItems(HeaderTwoItems())
.withHeader(HeaderThree())
.withItems(HeaderThreeItems())
.build()
Invalid Examples:-
Items with no header
val itemsNoHeaderCompileError : Map<Header, List<Item>> = builder()
.withItems(InvalidItems()) // compile error here missing header
.build()
Header with no items
val headerNoItems : Map<Header, List<Item>> = builder()
.withHeader(InvalidHeader())
.build()// compile error here missing items
I think DSL style builders (aka type-safe builders) is more suitable for this, and is more idiomatic.
The main problem is, how to enforce the "at least one header and at least one item in each header" rule. One way to do this is to force the builder blocks to all return a "token" that can only be received by calling the method that builds a header/item at the end of the lambda.
data class Header(val name: String)
data class Item(val name: String)
@DslMarker
annotation class HeadersBuilderMarker
@HeadersBuilderMarker
class HeaderBuilderScope {
sealed interface HasHeader
private data object HeaderProof : HasHeader
val headers = mutableMapOf<Header, List<Item>>()
// by making this block return a ItemBuilderScope.HasItem,
// we make sure that the caller has called ItemBuilderScope.item at least once
fun header(name: String, block: ItemBuilderScope.() -> ItemBuilderScope.HasItem): HasHeader {
headers[Header(name)] = ItemBuilderScope().apply { block() }.items
return HeaderProof
}
}
@HeadersBuilderMarker
class ItemBuilderScope {
sealed interface HasItem
private data object ItemProof : HasItem
val items = mutableListOf<Item>()
fun item(name: String): HasItem {
items.add(Item(name))
return ItemProof
}
}
// by making this block return a HeaderBuilderScope.HasHeader,
// we make sure that the caller has called HeaderBuilderScope.header at least once
fun headersBuilder(block: HeaderBuilderScope.() -> HeaderBuilderScope.HasHeader) =
HeaderBuilderScope().apply { block() }.headers
Usage:
val headers = headersBuilder {
header("Foo") {
item("item 1")
item("item 2")
}
header("Bar") {
item("item 3")
}
}
Note that in the same package, users can implement a sealed interface
with their own implementation, and hence break this design, so make sure to put the XXXBuilderScope
s in a separate package.