How do I write domain-specific extensions for collections in Kotlin?

Domain-specific collection extensions in Kotlin are usually extension functions or extension properties on Iterable<T>, List<T>, Set<T>, Map<K, V>, or more specific collection types that encode concepts from your domain.

They let you write expressive code like:

val overdueInvoices = invoices.overdue()
val activeUsers = users.active()
val totalRevenue = orders.totalRevenue()

instead of repeatedly writing filtering, grouping, or aggregation logic inline.

1. Start with simple extension functions

Suppose you have a domain model:

import java.math.BigDecimal
import java.time.LocalDate

data class Invoice(
    val id: String,
    val customerId: String,
    val amount: BigDecimal,
    val dueDate: LocalDate,
    val paid: Boolean
)

You can add collection extensions for common domain queries:

import java.time.LocalDate

fun Iterable<Invoice>.overdue(today: LocalDate = LocalDate.now()): List<Invoice> =
    filter { invoice ->
        !invoice.paid && invoice.dueDate.isBefore(today)
    }

fun Iterable<Invoice>.paid(): List<Invoice> =
    filter { it.paid }

fun Iterable<Invoice>.unpaid(): List<Invoice> =
    filterNot { it.paid }

Usage:

val overdueInvoices = invoices.overdue()
val unpaidInvoices = invoices.unpaid()

2. Add domain-specific aggregation

Collections often need totals, counts, averages, or summaries.

import java.math.BigDecimal

fun Iterable<Invoice>.totalAmount(): BigDecimal =
    fold(BigDecimal.ZERO) { total, invoice ->
        total + invoice.amount
    }

fun Iterable<Invoice>.totalPaidAmount(): BigDecimal =
    paid().totalAmount()

fun Iterable<Invoice>.totalOutstandingAmount(): BigDecimal =
    unpaid().totalAmount()

Usage:

val outstanding = invoices.totalOutstandingAmount()

This is easier to understand than:

val outstanding = invoices
    .filterNot { it.paid }
    .fold(BigDecimal.ZERO) { total, invoice -> total + invoice.amount }

3. Group collections using domain language

You can wrap common groupBy patterns:

fun Iterable<Invoice>.groupedByCustomer(): Map<String, List<Invoice>> =
    groupBy { it.customerId }

fun Iterable<Invoice>.overdueByCustomer(
    today: LocalDate = LocalDate.now()
): Map<String, List<Invoice>> =
    overdue(today).groupedByCustomer()

Usage:

val invoicesByCustomer = invoices.groupedByCustomer()
val overdueByCustomer = invoices.overdueByCustomer()

4. Prefer Iterable<T> when possible

If your function only needs to iterate, prefer Iterable<T>:

fun Iterable<Invoice>.overdue(): List<Invoice> =
    filter { !it.paid && it.dueDate.isBefore(LocalDate.now()) }

Use List<T> only when list-specific behavior matters, such as ordering by index:

fun List<Invoice>.firstOverdueOrNull(
    today: LocalDate = LocalDate.now()
): Invoice? =
    firstOrNull { !it.paid && it.dueDate.isBefore(today) }

Use Sequence<T> if you want lazy processing for large pipelines:

fun Sequence<Invoice>.overdue(
    today: LocalDate = LocalDate.now()
): Sequence<Invoice> =
    filter { !it.paid && it.dueDate.isBefore(today) }

5. Use extension properties for simple derived facts

If a value does not need parameters, an extension property can read nicely:

val Iterable<Invoice>.totalAmount: BigDecimal
    get() = fold(BigDecimal.ZERO) { total, invoice ->
        total + invoice.amount
    }

val Iterable<Invoice>.hasOverdueInvoices: Boolean
    get() = any { !it.paid && it.dueDate.isBefore(LocalDate.now()) }

Usage:

if (invoices.hasOverdueInvoices) {
    println("Some invoices are overdue")
}

println(invoices.totalAmount)

Use properties for cheap, parameterless concepts. Use functions when the operation accepts arguments or does meaningful work.

6. Create richer domain operations

For example, with an order domain:

import java.math.BigDecimal

data class Order(
    val id: String,
    val customerId: String,
    val status: OrderStatus,
    val total: BigDecimal
)

enum class OrderStatus {
    Draft,
    Submitted,
    Paid,
    Cancelled
}

Extensions:

fun Iterable<Order>.paid(): List<Order> =
    filter { it.status == OrderStatus.Paid }

fun Iterable<Order>.submitted(): List<Order> =
    filter { it.status == OrderStatus.Submitted }

fun Iterable<Order>.cancelled(): List<Order> =
    filter { it.status == OrderStatus.Cancelled }

fun Iterable<Order>.totalRevenue(): BigDecimal =
    paid().fold(BigDecimal.ZERO) { total, order ->
        total + order.total
    }

fun Iterable<Order>.forCustomer(customerId: String): List<Order> =
    filter { it.customerId == customerId }

Usage:

val revenue = orders.totalRevenue()
val customerOrders = orders.forCustomer("customer-123")
val paidCustomerOrders = orders.forCustomer("customer-123").paid()

7. Avoid making extensions too generic

This is usually good:

fun Iterable<Order>.totalRevenue(): BigDecimal =
    paid().fold(BigDecimal.ZERO) { total, order -> total + order.total }

This is probably too vague:

fun Iterable<Order>.goodOnes(): List<Order> =
    filter { it.status == OrderStatus.Paid }

Choose names that reflect your domain clearly: overdue, billable, fulfilled, activeSubscriptions, totalRevenue, groupedByCustomer, etc.

8. Consider nullable and empty collections

Be explicit about what happens for empty collections:

fun Iterable<Invoice>.largestInvoiceOrNull(): Invoice? =
    maxByOrNull { it.amount }

fun Iterable<Invoice>.averageAmountOrZero(): BigDecimal {
    val invoices = toList()

    if (invoices.isEmpty()) {
        return BigDecimal.ZERO
    }

    val total = invoices.totalAmount()
    return total.divide(BigDecimal(invoices.size))
}

Prefer OrNull suffixes when returning nullable results:

fun Iterable<Invoice>.oldestUnpaidInvoiceOrNull(): Invoice? =
    unpaid().minByOrNull { it.dueDate }

9. Use generic extensions when there is a reusable domain interface

If several entities share domain behavior, define an interface:

interface HasCustomer {
    val customerId: String
}

data class Invoice(
    val id: String,
    override val customerId: String,
    val amount: BigDecimal,
    val paid: Boolean
) : HasCustomer

data class Order(
    val id: String,
    override val customerId: String,
    val total: BigDecimal
) : HasCustomer

Then write a generic extension:

fun <T : HasCustomer> Iterable<T>.forCustomer(customerId: String): List<T> =
    filter { it.customerId == customerId }

fun <T : HasCustomer> Iterable<T>.groupedByCustomer(): Map<String, List<T>> =
    groupBy { it.customerId }

Usage:

val customerInvoices = invoices.forCustomer("customer-123")
val customerOrders = orders.forCustomer("customer-123")

10. Keep mutability clear

Prefer returning new collections:

fun Iterable<Order>.withoutCancelled(): List<Order> =
    filter { it.status != OrderStatus.Cancelled }

Be careful with extensions that mutate the receiver:

fun MutableList<Order>.removeCancelled() {
    removeAll { it.status == OrderStatus.Cancelled }
}

If an extension mutates state, make that obvious in the name: removeCancelled, sortByPriorityInPlace, markAllAsPaid, etc.

11. Organize extensions by domain

A common structure is:

src/main/kotlin/
  billing/
    Invoice.kt
    InvoiceCollectionExtensions.kt
  orders/
    Order.kt
    OrderCollectionExtensions.kt

Example:

package billing

fun Iterable<Invoice>.overdue(): List<Invoice> =
    filter { !it.paid }

fun Iterable<Invoice>.totalAmount(): BigDecimal =
    fold(BigDecimal.ZERO) { total, invoice -> total + invoice.amount }

Then import them where needed:

import billing.overdue
import billing.totalAmount

Good rules of thumb

  • Use Iterable<T> for most collection extensions.
  • Use List<T> only when order or index matters.
  • Use Sequence<T> for lazy pipelines.
  • Use extension properties for cheap, parameterless values.
  • Use extension functions for parameterized or non-trivial operations.
  • Prefer clear domain names over technical names.
  • Return new collections unless mutation is intentional.
  • Use OrNull suffixes for nullable lookups.
  • Keep extensions close to the domain they describe.

A typical domain-specific collection extension looks like this:

fun Iterable<Invoice>.overdue(today: LocalDate = LocalDate.now()): List<Invoice> =
    filter { !it.paid && it.dueDate.isBefore(today) }

And its value is that the rest of your application can speak in domain terms:

val amountToCollect = invoices
    .overdue()
    .totalAmount()