kotlinbuilder

How to create a step builder in Kotlin that supports repeatable steps


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

Solution

  • 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 XXXBuilderScopes in a separate package.