76TechA LITTLE KNOW-HOW GOES A LONG WAY.
← Back to the blog
Kotlin

Kotlin sequences: build a predictable data processing pipeline

Build a reading queue, compare eager and lazy processing, and choose a sequence only when its behavior fits your task.

SEP 28, 2026·9 MIN READ
Laptop displaying code beside a small plant on a coffee shop table
Photo: James Harrison / Unsplash. Editorial illustration.

A reading queue sounds simple: take a catalog of lessons, keep the unfinished ones, and show three suggestions. Yet a small change in the order of those operations can change which lessons appear. Adding a lazy sequence introduces another question: when does the work actually happen? This guide uses a deliberately small catalog so you can reason about every result before considering performance.

The goal is to create a useful function with a clear contract. You should finish with a working example, an explanation of why its output is correct, and a practical method for deciding whether a sequence belongs in your own application. You do not need Android Studio for this exercise. A Kotlin environment that supports the standard library is enough; no coroutine dependency is required.

Define the expected result → Build the collection version → Introduce lazy processing → Check order and edge cases → Measure realistic workloads
The process at a glance. Open the diagram to view it at full size.

Define the reading queue before writing a pipeline

Imagine that your learning app stores lessons in editorial order. Readers choose a topic and see up to three unfinished lessons. The product rule says that this order must remain stable. The feature does not promise to show the shortest lessons, newest lessons, or alphabetically first titles. Writing that rule down prevents an innocent sorting operation from becoming an unexpected product change.

Each lesson needs an identifier, a title, a topic, and a completion flag. An identifier belongs to the record, while a position belongs to one particular list. Avoid treating an array index as a durable identifier. When somebody inserts a new lesson at the beginning, every later position changes, but the existing lesson identities should remain the same.

Our function will accept a list, a topic, and a maximum result count. It returns lesson titles in source order. A limit of zero returns an empty list. Negative limits are invalid inputs. Topic comparison ignores letter case, but it does not remove spaces or guess spelling. These choices are intentionally explicit: normalization rules should be decisions, not accidental side effects hidden inside a transformation.

Start with an ordinary collection

The following program includes its own sample data and executable checks. Paste the entire block into a Kotlin console project or Kotlin Playground. The list implementation is the reference implementation for this exercise. Read its operations from top to bottom, predicting the intermediate values before running the program.

data class Lesson(
    val id: Int,
    val title: String,
    val topic: String,
    val completed: Boolean
)

fun queueList(
    lessons: List<Lesson>, topic: String, limit: Int
): List<String> {
    require(limit >= 0) { "Limit cannot be negative" }
    return lessons
        .filter { !it.completed && it.topic.equals(topic, ignoreCase = true) }
        .map { it.title }
        .take(limit)
}

fun queueSequence(
    lessons: List<Lesson>, topic: String, limit: Int
): List<String> {
    require(limit >= 0) { "Limit cannot be negative" }
    return lessons.asSequence()
        .filter { !it.completed && it.topic.equals(topic, ignoreCase = true) }
        .map { it.title }
        .take(limit)
        .toList()
}

fun main() {
    val lessons = listOf(
        Lesson(1, "Values", "Kotlin", true),
        Lesson(2, "Collections", "Kotlin", false),
        Lesson(3, "Layouts", "Android", false),
        Lesson(4, "Sequences", "Kotlin", false),
        Lesson(5, "Testing", "Kotlin", false)
    )
    val expected = listOf("Collections", "Sequences")
    check(queueList(lessons, "kotlin", 2) == expected)
    check(queueSequence(lessons, "kotlin", 2) == expected)
    check(queueSequence(lessons, "Kotlin", 0).isEmpty())
    check(queueSequence(lessons, "Swift", 2).isEmpty())
    check(queueSequence(emptyList(), "Kotlin", 2).isEmpty())
    println(expected)
    println("All queue checks passed")
}

The expected output contains Collections followed by Sequences, then the success message. Values disappears because it is completed. Layouts disappears because its topic differs. Testing matches the predicate but falls beyond the two-item limit. Explaining those three exclusions is more useful than simply noticing that the program did not crash.

Understand the point where work begins

A collection pipeline such as the first function creates the filtered result before mapping its elements. The sequence version defers its intermediate processing until a terminal operation requests results. Here that terminal operation is toList(). Think of the sequence as a recipe for producing values, rather than a container already holding those values.

With this particular sample, the lazy pipeline can stop after finding its second accepted lesson. That reduces unnecessary downstream work. It does not prove that every sequence implementation is faster. For a tiny catalog, readability and predictable behavior are more valuable than a performance claim based only on the syntax. The official sequence guide explains the execution model and its overhead.

There is also no automatic background processing here. Calling a sequence pipeline on the main thread still performs its synchronous work on that thread when the result is requested. Replacing a list with a sequence does not turn a slow calculation into a coroutine, and it does not make a blocking database call safe for a responsive interface.

Keep filtering separate from presentation

Filtering answers whether a lesson belongs in the queue. Mapping answers what information the queue exposes. Keeping those questions separate makes review easier: a reviewer can inspect selection rules without also interpreting string formatting. If the interface later needs identifiers for navigation, return a dedicated queue item containing both identifier and title instead of reconstructing identity from displayed text.

Changing the pipeline to map first can discard information that the predicate needs. Mapping every record into its title and then trying to filter completed lessons is impossible without another lookup. That second lookup would complicate the code and could introduce ambiguity when two lessons share a title. Keep the original record available until selection decisions are complete.

Kotlin's transformation documentation describes mapping and related operations. In your project, use those operations to express a specific transition between data shapes. Avoid chaining functions simply because the result looks compact. A named local variable can make a business rule much easier to discuss with another developer.

Check operation order with a counterexample

Suppose you move take(2) before filter. The program now examines only Values and Collections. Because Values is completed, the output contains just Collections. The limit has silently changed from two accepted lessons to two candidate records. Both versions compile, so the compiler cannot tell you which one matches the intended feature.

Create a test catalog where the first five records are completed and the sixth is unfinished. Asking for one suggestion should return the sixth lesson. This is an effective regression case because it separates the two interpretations sharply. A sample where every record matches would allow the incorrect implementation to pass unnoticed.

The filtering reference is useful when choosing a predicate operation. However, a library reference cannot choose the product contract for you. Write expectations using meaningful examples from your application, including records that almost match. That habit exposes mistakes which a happy-path example with perfectly uniform data tends to hide.

Decide how duplicate records should behave

Two different lessons may legitimately share a title. Our queue preserves both because the input represents lesson records, not a set of unique names. Calling distinct() after mapping would merge those titles and potentially hide a valid suggestion. If duplicated identifiers indicate an import problem, validate identifiers before building the queue rather than silently repairing them in the display layer.

For an administrative report, grouping records by topic can reveal an imbalanced catalog. That is a separate operation with a separate purpose. A topic report should not determine queue order unless the product specification says so. The grouping documentation describes APIs for this kind of summary, but keep the report outside the queue function so neither feature inherits the other's assumptions.

A useful exercise is to add another unfinished Kotlin lesson titled Collections with a different identifier. Predict the result with a limit of ten. Then explain whether your real interface needs to display a subtitle or difficulty label to distinguish the records. Sometimes a supposed data-processing bug is actually missing information in the interface.

Separate measurement from side effects

During learning, it can be helpful to print a record identifier inside a predicate to observe visitation order. Remove that instrumentation when it stops answering a question. Production selection functions should not also send analytics events, update completion flags, or save database records. Otherwise, requesting the same suggestions twice could cause effects that have nothing to do with displaying a queue.

For timing experiments, generate realistic catalogs with different match rates. A dataset where every lesson matches behaves differently from one where only the final record matches. Compare the same returned values before comparing duration. Warm up the environment, repeat measurements, and include allocation costs where they matter. A single run in a browser playground is an illustration, not a reliable benchmark.

You can summarize lesson durations separately with aggregation operations, such as a total study time. The aggregation reference helps identify the appropriate operation. Be careful that your measurement task remains the same across implementations; timing a count on one side and construction of a full list on the other does not answer a fair performance question.

Finish with a reviewable exercise

Extend the record with an estimated duration and add a maximum duration parameter. Specify whether the duration limit applies to each lesson individually or to the combined queue. Those are different features. Start with the individual limit, where a lesson is eligible only if its estimate is within the requested range. Add an example exactly on the boundary and another one minute above it.

Keep an empty catalog case, a no-match case, a zero-result limit, and a mixed-case topic case in your checks. Add a negative-limit check using your test framework's exception assertion. Do not weaken the validation just to make an unexpected input return something. Decide whether the caller should reject that input or whether the function's contract needs deliberate revision.

Before adopting the sequence version, explain its purpose in one sentence tied to your workload. If the list version is clearer and performs adequately, retaining it is a sound engineering choice. The important outcome is a pipeline whose results, ordering, boundaries, and execution point you understand well enough to change safely.

Keep learning

Kotlin

Lesson 5: Turn messy input into trustworthy study tasks

OCT 04, 2026·8 MIN READ
Kotlin

Lesson 6: Give the study planner stable IDs and clear ownership

OCT 04, 2026·8 MIN READ