JavaRush/Courses/Kotlin SELF/Report architecture as a pipeline

Report architecture as a pipeline

Open

1. A report as a pipeline

When you hear the word “report”, a beginner’s brain often pictures something like: “take a list → iterate → print”. That’s a working scheme... right up until the first requirement like “also make a top-3 categories”, “also filter only amounts ≥ X”, “also make it so that ‘Food’, ‘ food ’, and ‘FOOD’ are the same category”. At that point, the code starts to resemble a tangled mess of cables under a desk: it sort of works, but it’s scary to touch.

So we need a different perspective: a report is a pipeline.

A pipeline is when we don’t do “everything at once”, but build a chain of steps:

input data → transformations → final data → report text

Important: a report almost always starts with “raw-ish” data (how the user entered categories, amounts, spaces, letter case), and ends with something “official”: numbers, structures, sorted lines.

Let’s lock in the core idea for today:

A report is not “one magical chain”, but several simple steps arranged in the right order.

2. The order of pipeline steps

If you start writing a report “by inspiration”, it’s easy to mix up what to do earlier and what to do later. So it’s useful to keep in mind a standard order that fits a huge number of data analysis tasks.

First, almost always comes normalization. This is bringing input values to a “canonical” form: remove spaces, convert to lowercase, replace synonyms. Normalization matters because a report is built on key matching, and “food” and “FOOD” are different strings to a computer.

Then usually comes filtering. We discard what shouldn’t participate in the report: for example, negative amounts (if they’re forbidden), or expenses below a threshold, or categories like “test”.

After that comes aggregation: turning “many records” into “totals”. This is where fold, groupBy, groupingBy().eachCount(), and other things from previous days appear. groupBy() in Kotlin returns a Map where the key is the result of the grouping function and the value is the list of elements in the group. And groupingBy() gives you a way to compute a per-group result “without explicit group lists” (for example, eachCount()).

Next come sorting and selecting what we actually show (top-N via take(n)). This is closer to “presentation” than to “meaning”: we don’t change facts, we decide how to show them.

And only at the end comes string preparation: turning final data (numbers, pairs, maps) into nice text.

If it feels like “we’re making life harder” — yes, at the moment of writing the code it’s a bit harder. But a week later you’ll thank yourself when you need to add a new report without breaking the old one.

3. Type control in the pipeline

Now an important point that often breaks beginners’ brains: each pipeline step changes the result type. And that’s normal.

Type changes are not a bug, but a hint: “we moved to the next processing level”. If you track types, you literally see the report’s architecture.

Imagine that in our training console app (a hypothetical expense tracker) the data is still stored without classes, as:

// category to amount
val expenses: List<Pair<String, Int>>

Then a typical pipeline can look like this (the table intentionally “highlights” types — it’s your compass):

Step What we do Input type Output type
1 Normalize categories
List<Pair<String, Int>>
List<Pair<String, Int>>
2 Filter (for example, amounts > 0)
List<Pair<String, Int>>
List<Pair<String, Int>>
3 Aggregate “sum by category”
List<Pair<String, Int>>
Map<String, Int>
4 Prepare lines for output (via sorting)
Map<String, Int>
List<Pair<String, Int>>
or directly
String
5 Render the report text
List<Pair<String, Int>>
String

Notice: sometimes we stay in List, and sometimes we abruptly “flip over” into a Map. That transition is an important boundary.

It’s especially useful to remember groupBy(): after it you get a map of lists Map<K, List<V>>. This is often the very “turning point” where the coding style changes: instead of “processing elements” we start “processing groups”.

Mini data for examples

To keep the examples coherent, let’s agree on a small dataset. This is not the “final project architecture”; it’s just a convenient format for today: a pair (category, amount).

fun main() {
    val expenses = listOf(
        " Food " to 120,
        "FOOD" to 80,
        "taxi" to 250,
        " books" to 90
    )

    println(expenses)
    // [( Food , 120), (FOOD, 80), (taxi, 250), ( books, 90)]
}

Yes, the categories here are “dirty” (spaces, different case). That’s intentional: real life doesn’t have to be pretty.

4. Normalization as an early step

Normalization is when we bring data to a single style so that identical entities match. In reports this most often applies to keys: categories, words, names, cities. If you normalize late (after grouping), you’ll get fragmented results: “food” separately, “FOOD” separately, “ Food ” separately. The report will look “plausible” but be wrong — and that’s the most dangerous kind of wrong.

Let’s do a simple category normalization: trim() + lowercase().

fun normalizeCategory(raw: String): String {
    return raw.trim().lowercase()
}

fun main() {
    val raw = listOf("  food ", "FOOD", " taxi")
    val normalized = raw.map(::normalizeCategory)

    println(normalized) // [food, food, taxi]
}

The important thing here is that we apply normalization via map, which builds a new collection from transformation results. We don’t “fix the source”; we create a new, cleaner result.

Now let’s apply this to our expenses:

fun normalizeCategory(raw: String): String = raw.trim().lowercase()

fun main() {
    val expenses = listOf(" Food " to 120, "FOOD" to 80, "taxi" to 250)

    val normalizedExpenses = expenses.map { (cat, amount) ->
        normalizeCategory(cat) to amount
    }

    println(normalizedExpenses)
    // [(food, 120), (food, 80), (taxi, 250)]
}

Note the type: it was List<Pair<String, Int>> and it remained the same type. This is an “early step” that doesn’t change the data shape; it makes the data more suitable for analysis.

5. Grouping and changing the data shape

After normalization, grouping often comes next, because reports love phrases like “by categories”, “by users”, “by days of the week”.

In Kotlin, basic grouping is done via groupBy(): it returns a Map where the key is what the grouping function returned, and the value is the list of elements in the group.

Let’s group expenses by category:

fun normalizeCategory(raw: String): String = raw.trim().lowercase()

fun main() {
    val expenses = listOf(" Food " to 120, "FOOD" to 80, "taxi" to 250)

    val normalized = expenses.map { (cat, amount) -> normalizeCategory(cat) to amount }
    val grouped = normalized.groupBy { it.first }

    println(grouped)
    // {food=[(food, 120), (food, 80)], taxi=[(taxi, 250)]}
}

This is where the “turning point” happened. The type was List<Pair<String, Int>>, and became:

Map<String, List<Pair<String, Int>>>

That is, a map of lists. And that makes sense: each category can have many records.

If you don’t need group lists

Sometimes a report doesn’t need the group elements; it only needs statistics. For example: “how many times each category occurred”. For that, groupingBy().eachCount() is often more convenient, because it produces frequencies.

A mini example with words:

fun main() {
    val words = listOf("a", "bb", "ccc", "bb")
    val counts: Map<String, Int> = words.groupingBy { it }.eachCount()

    println(counts) // {a=1, bb=2, ccc=1}
}

The point: we immediately get Map<String, Int>, i.e. a “final map”, without intermediate lists. This is especially nice when there’s a lot of data.

For expenses, eachCount() will give “how many operations in each category”, not sums. Sometimes that’s useful as a separate report (“number of purchases by category”), but more often we still need sums — we’ll do that in the next pipeline steps.

6. Aggregation and fold

Aggregation is the moment when we say: “enough details, give me the result”. The simplest aggregation is a sum.

In Kotlin, one of the key tools for this is fold(). It neatly iterates over a collection and accumulates a result. Unlike reduce(), fold() takes an initial accumulator value, so it’s safer for empty collections and better for beginners.

Let’s compute the total expenses (this is the total report, but today the aggregation step is what matters):

fun main() {
    val amounts = listOf(10, 20, 30)
    val total = amounts.fold(0) { acc, x -> acc + x }

    println(total) // 60
}

If you ever wrote this using a var sum = 0 and a loop — you already know fold, you just used to do it manually. Kotlin only makes this pattern explicit and compact.

Now aggregation: “sum by categories”. We already got Map<String, List<Pair<String, Int>>>. Now, inside each group we need to fold the list down to a number.

fun main() {
    val grouped = mapOf(
        "food" to listOf("food" to 120, "food" to 80),
        "taxi" to listOf("taxi" to 250)
    )

    val totalsByCategory: Map<String, Int> = grouped.mapValues { (_, items) ->
        items.fold(0) { acc, item -> acc + item.second }
    }

    println(totalsByCategory) // {food=200, taxi=250}
}

Here’s the key point about type control. After mapValues, we no longer have “lists of operations”; we have “final numbers”. The pipeline moved from “details” to “result”.

And yes, mapValues is a map transformation (keys stay the same, values are new). This is exactly the “transform, don’t mutate” style that Kotlin nudges you toward with collection operations.

7. Sorting and preparing output

After aggregation, you want to show the result nicely: the largest categories on top, smaller ones below. This is not a “meaning change”; it’s view preparation.

A typical beginner problem: “How do I sort a Map?”. The answer is simple: most of the time you don’t sort the map “as is”; you convert it to a list of elements (for example, pairs), sort the list, and print that.

Example: Map<String, Int> → List<Pair<String, Int>> → sort by sum descending.

fun main() {
    val totals = mapOf("taxi" to 250, "food" to 200, "books" to 90)

    val sorted = totals.entries
        .map { it.key to it.value }
        .sortedByDescending { (_, sum) -> sum }

    println(sorted)
    // [(taxi, 250), (food, 200), (books, 90)]
}

Again, watch the types:

  • totals is a Map<String, Int>
  • totals.entries is a set of map entries (essentially, a “list of map records”)
  • .map { ... } turns it into List<Pair<String, Int>>
  • .sortedByDescending returns a new sorted list (and does not touch the source data)

Next you can do top-N via take(n) — but the principle is clear: sorting is done closer to output.

8. Rendering a report to a string

When you really want to see results quickly, your hand reaches for println() right inside aggregation. That gives instant gratification, but it ruins architecture: computation gets mixed with output. Later you’ll want to save the report as a string, output it in another format, or test it — and you’ll find that “everything prints itself”.

So today’s rule: first compute the data, then build the text.

To build text, it’s convenient to use StringBuilder, because it’s designed to assemble strings without heavy concatenation in a loop. Kotlin has handy extensions for it (for example, .appendLine()).

Let’s make a simple renderer for a list of pairs (category, amount):

fun renderTotals(rows: List<Pair<String, Int>>): String {
    val sb = StringBuilder()
    for ((category, total) in rows) {
        sb.append(category)
        sb.append(": ")
        sb.append(total)
        sb.append('\n')
    }
    return sb.toString()
}

fun main() {
    val rows = listOf("taxi" to 250, "food" to 200)
    print(renderTotals(rows))
    // taxi: 250
    // food: 200
}

Notice: the function prints nothing. It returns a string. Printing is then the responsibility of main or the CLI layer.

It’s a small thing, but it’s exactly from such small things that “maintainable code” is built.

9. Putting the whole pipeline together

Now let’s assemble a small complete pipeline example that produces an almost full report: normalization → aggregation → sorting → text.

Yes, we could do this as one chain with 8 calls. But today the focus is readable architecture: steps follow in order, and we can see where each type appears.

fun normalizeCategory(raw: String): String = raw.trim().lowercase()

fun main() {
    val expenses = listOf(
        " Food " to 120,
        "FOOD" to 80,
        "taxi" to 250,
        " books" to 90
    )

    val normalized = expenses.map { (cat, amount) ->
        normalizeCategory(cat) to amount
    }

    val grouped = normalized.groupBy { it.first }

    val totalsByCategory = grouped.mapValues { (_, items) ->
        items.fold(0) { acc, item -> acc + item.second }
    }

    val sortedRows = totalsByCategory.entries
        .map { it.key to it.value }
        .sortedByDescending { (_, sum) -> sum }

    val report = renderTotals(sortedRows)

    print(report)
    // taxi: 250
    // food: 200
    // books: 90
}

What matters here (specifically from the “report architecture” point of view):

  • We don’t output in the middle, because the middle is still “data”, and it can be reused for other reports. This is especially useful in apps where you’ll want to show different reports from the same set of operations.
  • We clearly see the type change at groupBy(), because groupBy() returns a Map. And then we clearly see that after fold inside mapValues we get numbers (totals).
  • We sort before output so the line order is predictable. (If you rely on “how the map prints”, one day you may be surprised. Surprise is not the best format for financial reports.)

10. Common mistakes when designing a report pipeline

Mistake #1: mixing normalization and formatting.
When you do trim().lowercase() and immediately turn the result into a pretty string like “Category: ...”, you tie data processing and presentation into one knot. Then it turns out you need the same normalization for grouping, but without “Category:”. Much easier: normalize first “as data”, and only at the end format “as text”.

Mistake #2: not noticing a change in the result shape.
Beginners often keep thinking “I still have a list”, even though after groupBy() they already have a Map<String, List<...>>. That’s how weird attempts arise: calling sortedBy “on a map”, or writing a lambda that expects a list element but receives a Map.Entry. If you keep the type in mind after each step, half the confusion disappears by itself.

Mistake #3: grouping by a dirty key.
If you do groupBy { it.first } before trim/lowercase, you get a “correct” map... of incorrect keys. The report starts splitting, sums drift apart, and then the user asks: “Why do I have two food categories?”. Because one is “food” and the other is “ Food ”. Normalization should come before grouping almost always.

Mistake #4: trying to sort a Map “as is”, and then being surprised.
A map is not a list. In reports, sorting is most often done on a list of elements: take entries, turn them into pairs/strings, sort, then print. This is not a “workaround”; it’s a standard view-prep pattern.

Mistake #5: using reduce without thinking about empty data.
reduce has no initial value and can crash on an empty collection. fold is safer because it has an initial accumulator. In reports, empty data is a normal situation (for example, nothing has been added yet), so fold is usually more comfortable.

1
Task
Kotlin SELF,  26 level,  0 lecture
Locked
Clean categories
Clean categories
1
Task
Kotlin SELF,  26 level,  0 lecture
Locked
Cleaning up transactions
Cleaning up transactions
1
Task
Kotlin SELF,  26 level,  0 lecture
Locked
Operation groups
Operation groups
1
Task
Kotlin SELF,  26 level,  0 lecture
Locked
Category totals
Category totals
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet