1. Introduction
When the project is small, it feels like folders and packages are “for big scary enterprises,” and all we need is to finish the add command and go have some tea. But the problem is that project growth happens quietly: today it’s 3 files, tomorrow it’s 12, and a week later you are looking for “where do we parse the command,” and you discover 4 parseCommand functions that differ by exactly one space.
Packages in Kotlin are a way to name the “city districts” of your project: here lives the CLI, here is the domain logic, here is storage. And when you follow the rule “who can import whom,” the structure starts working like a handrail on a staircase: it doesn’t get in the way, but it really helps you not fly down.
Kotlin technically does not require disk folders to match the package name: files can be placed “however you want.” But the human brain is not a compiler, and it really likes when domain/model/Expense.kt actually contains package domain.model. So in practice it’s almost always worth keeping the directory structure aligned with packages: navigation is easier, imports are clearer, and there are fewer accidental mistakes during moves/refactors.
You can think of it like this: the compiler doesn’t care where the file lives, but a person does. We write code for people (and just a little bit for the compiler, so it doesn’t cry).
2. Refreshing package and import
In Kotlin, the package declaration is written at the top of the file, and imports come right after it. This is not just a tradition “because that’s how it’s done”: it makes the file readable top-down—first the address (package), then dependencies (imports), then code.
At the same time, Kotlin technically does not require disk folders to match the package name: files can be placed “however you want.” But for the team and the IDE, it’s much calmer when the directory structure mirrors the package structure, especially in JVM projects alongside Java.
Mini example: a package as a file “address”
package domain.model
data class Expense(val title: String, val amount: Int)
Everything is honest here: the file says “I’m from the domain.model district.” If we want to use Expense in another package, we’ll have to import it.
3. Package structure: locking in responsibility
For packages to work as an architectural hint, the names must reflect responsibility. Let’s agree on a simple, readable structure that helps keep boundaries:
| Layer (responsibility) | Package | What lives here | What must not be here |
|---|---|---|---|
| CLI | |
reading commands, parsing a line, printing, program flow/scenario | business rules and data storage “whatever way” |
| Domain (model) | |
domain entities |
, , commands, UI formatting |
| Domain (rules/operations) | |
validation, normalization, operations on models | direct console work |
| Storage | |
storing and returning data (in memory) | command parsing and console output |
This layout is intentionally very simple. What matters is that with a single glance at a package it’s clear: “this is part of the scenario” or “this is part of the domain logic.”
4. Dependency direction: who imports whom
This is where real architecture begins, even if the project is still small. Dependency direction is the rule “who depends on whom.” In code it shows up literally: which packages contain import statements to which other packages.
We want the outer layer (CLI) to depend on the inner layer (domain), not the other way around. Then the domain can be reused anywhere: in tests, in a different interface, in another way of entering commands. Even if you aren’t doing any of that yet, the mere possibility makes the code feel calmer.
Schematically (a map of allowed imports) it looks like this:
flowchart TD
CLI[app.cli] --> DS[domain.service]
CLI --> DM[domain.model]
CLI --> ST[storage]
DS --> DM
ST --> DM
%% important restriction:
%% domain does NOT depend on app.cli
If you simplify it down to one rule: domain.* does not import app.cli.*. Never. Not even “just one tiny function printHelp().” Because it’s not a “tiny function,” it’s a hole in the boundary.
Why this matters in a real project
If domain starts importing the CLI, you get a “conceptual circular dependency”: you can no longer understand where domain rules end and the interface begins. At some point, any change will require changes everywhere at once—and the project becomes brittle.
5. Practical example: laying out code by packages
Now we’ll take our learning project (a hypothetical expense tracker) and lay it out into packages so that the structure reflects the architecture. Important: we are not making a “dream project with 200 files.” We are making a small project, but with correct geography.
Let’s imagine this structure under src/main/kotlin:
src/main/kotlin
├── app
│ └── cli
│ ├── Main.kt
│ └── ParsedCommand.kt
├── domain
│ ├── model
│ │ └── Expense.kt
│ └── service
│ └── ExpenseService.kt
└── storage
└── InMemoryExpenseStorage.kt
Note: Kotlin doesn’t force us to do it this way, but code-organization conventions strongly recommend it—especially that the directory structure should follow packages.
domain.model: domain model
Let’s start with the “cleanest” part—the model.
package domain.model
data class Expense(val title: String, val amount: Int)
There is no console, no commands, no “how to print nicely.” Just data.
domain.service: rules and operations
Now the service: it creates a valid Expense from raw parameters. Notice: the service does not store a list and does not print. It implements rules.
package domain.service
import domain.model.Expense
class ExpenseService {
fun createExpense(title: String, amount: Int): Expense {
val normalizedTitle = title.trim()
require(normalizedTitle.isNotEmpty()) { "Title must not be empty" }
require(amount > 0) { "Amount must be > 0" }
return Expense(title = normalizedTitle, amount = amount)
}
}
We import the model from domain.model; this is a normal “service → model” dependency.
storage: in-memory storage
In-memory storage is our “minimal storage layer”: it can save an expense and return all expenses. It knows nothing about commands and the console; it only knows about the model.
package storage
import domain.model.Expense
class InMemoryExpenseStorage {
private val items = mutableListOf<Expense>()
fun add(expense: Expense) {
items.add(expense)
}
fun all(): List<Expense> = items
}
An important point here: we expose List<Expense> to the outside, not MutableList<Expense>. This helps avoid turning the storage into a “public hallway” where anyone can change data however they want.
app.cli: commands and line parsing
The CLI layer is where we work with strings, spaces, and human errors like “I typed add coffee two hundred.” Domain classes should not deal with that.
Let’s create a small ParsedCommand type so main doesn’t mess with “magic indexes.”
package app.cli
data class ParsedCommand(val name: String, val args: List<String>)
And a parsing function (for now as simple as possible, with no claim to perfection):
package app.cli
fun parseCommandLine(line: String): ParsedCommand {
val parts = line.trim().split(" ").filter { it.isNotEmpty() }
val name = parts.firstOrNull()?.lowercase() ?: ""
val args = if (parts.size > 1) parts.drop(1) else emptyList()
return ParsedCommand(name = name, args = args)
}
Everything here is very “CLI-ish”: trim, split, lowercase.
app.cli.Main: application assembly and scenario
And now main. It imports everything it needs and glues the scenario together: read a line → parse → apply rules → store → show the result.
package app.cli
import domain.service.ExpenseService
import storage.InMemoryExpenseStorage
fun main() {
val service = ExpenseService()
val storage = InMemoryExpenseStorage()
print("Command: ") // Command:
val cmd = parseCommandLine(readln())
when (cmd.name) {
"add" -> {
val title = cmd.args.getOrNull(0) ?: ""
val amount = cmd.args.getOrNull(1)?.toIntOrNull() ?: 0
val expense = service.createExpense(title, amount)
storage.add(expense)
println("Added: $expense") // Added: Expense(title=..., amount=...)
}
"list" -> println(storage.all())
else -> println("Unknown command: ${cmd.name}")
}
}
From a dependency perspective everything looks good: CLI depends on domain and storage. Domain does not depend on CLI. Storage does not depend on CLI either. That means if tomorrow you want another interface (for example, not a console one), you won’t have a “train of println.”
6. Imports as a problem indicator and the utils trap
There is a useful trick: sometimes it’s enough to open a file and look at its import statements to understand whether the architecture has “drifted” or not. If you see that a file from domain.service imports something from app.cli, it is almost always a signal: “we shoved a UI detail into domain logic.”
One more nuance: Kotlin name resolution rules take import priorities into account. For example, an explicit import usually has higher priority than a star import (*) or “found in the same package.” This does not mean we should live in fear of imports. It means imports are part of the project design, not “junk at the top.”
Very soon “convenient functions” appear in a project: formatMoney, readInt, normalizeTitle, parseSomething. And your hand reaches for creating a utils package and dumping everything there. It looks like cleanup, but often turns into a “box of cables”: everything gets thrown in there when it’s not clear where it should go yet.
The problem is not the name utils, but the lack of responsibility. When a utility is not tied to a layer, it starts pulling dependencies in all directions. For example, utils.formatExpense() suddenly starts importing domain.model.Expense, and then someone adds println “for debugging,” and the utility is no longer a utility—it’s a small portal of chaos.
It’s much calmer when helper functions live next to their layer. Command parsing—in app.cli, normalization rules—in domain.service, working with the stored list—in storage. A package is not a trash can; it’s an address.
7. Typical mistakes
Mistake #1: domain code starts depending on CLI via an “innocent import.”
Usually it starts with “I just want to print an error message nicely.” And then import app.cli.printError appears. After that, the domain can no longer be used independently of the console, and the responsibility boundary disappears. It’s better for the domain to return a clear error (or throw via require), and for the CLI to decide how to present it to the user.
Mistake #2: mismatch between folders and package because “Kotlin allows it.”
Yes, it allows it: a file can live anywhere. But source-organization guidelines explicitly say that the directory structure should follow the package structure, especially on the JVM. Otherwise IDE navigation becomes weird, moving files becomes painful, and the project looks like a labyrinth without a map.
Mistake #3: storage returns MutableList to the outside, and any code can mutate the data.
From the outside it seems convenient: “I’ll be careful.” But in a week you’ll forget where exactly you were “careful” and get unexpected state changes. Return List, and keep mutation inside the storage layer—this makes it easier to control who changes data and where.
Mistake #4: star imports (import something.*) start living their own life.
A *-import can seem convenient sometimes, but in large projects it hurts readability and can lead to unexpected name conflicts. Plus, Kotlin has name resolution priorities: explicit imports “beat” what came from *, which is yet another reason not to turn imports into a lottery.
Mistake #5: the utils package turns into a “dumping ground for everything.”
While the project is small, this can feel like order. But once functions from different layers get mixed there, the package starts pulling dependencies in all directions and destroys responsibility boundaries. It’s better to keep “utilities” next to their layer and task—then imports are easier to manage, and the structure becomes self-explanatory.
GO TO FULL VERSION