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
OrNullsuffixes 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()
