Skip to content

Kotlin Guidelines


Kotlin is used to model the StrataStax domain clearly, safely, and explicitly.

StrataStax combines object-oriented domain modeling with functional techniques.

The goal is not to write Kotlin in a particular style for its own sake. The goal is code that:

  • expresses the domain clearly
  • makes invalid states difficult to create
  • makes behavior easy to find
  • keeps side effects at clear boundaries
  • uses the type system to prevent mistakes
  • remains simple when no additional abstraction is needed

Choose the simplest construct that accurately expresses the concept.


These rules protect the architecture and the correctness of the system.

They are not style preferences.


1.1 Domain code must not depend on infrastructure

Section titled “1.1 Domain code must not depend on infrastructure”

The domain must not perform I/O or depend on persistence, HTTP, messaging, frameworks, or other infrastructure concerns.

Good:

data class Match(
val id: MatchId,
val status: MatchStatus
) {
fun finalize(): Match {
require(status == MatchStatus.OPEN)
return copy(status = MatchStatus.FINALIZED)
}
}

Bad:

data class Match(
val id: MatchId,
val status: MatchStatus
) {
suspend fun finalize(repository: MatchRepository): Match {
// Domain object performing persistence
...
}
}

The domain describes what the domain means. Application and infrastructure layers deal with how things are retrieved, stored, or communicated.


1.2 Domain invariants must be enforced at the domain boundary

Section titled “1.2 Domain invariants must be enforced at the domain boundary”

An invalid domain object should not be constructible through its normal public API.

Good:

@JvmInline
value class Percentage private constructor(
val value: Int
) {
companion object {
fun of(value: Int): Percentage {
require(value in 0..100)
return Percentage(value)
}
}
}

Now:

Percentage.of(50)

is valid, while:

Percentage.of(150)

fails immediately.

Avoid creating objects that require every caller to remember their invariants.


1.3 Domain concepts must not be represented by ambiguous primitives

Section titled “1.3 Domain concepts must not be represented by ambiguous primitives”

When a primitive has domain meaning, represent that meaning in the type system.

Bad:

fun findMatch(id: UUID): Match?
fun findGame(id: UUID): Game?

It is easy to accidentally pass the wrong UUID.

Good:

@JvmInline
value class MatchId(val value: UUID)
@JvmInline
value class GameId(val value: UUID)
fun findMatch(id: MatchId): Match?
fun findGame(id: GameId): Game?

This is particularly important for identifiers and other values where mixing types would be a meaningful bug.


1.4 Domain behavior must respect domain invariants

Section titled “1.4 Domain behavior must respect domain invariants”

Operations that change domain state must not allow invalid transitions.

Good:

data class Match(
val id: MatchId,
val status: MatchStatus
) {
fun finalize(): Match {
require(status == MatchStatus.OPEN)
return copy(status = MatchStatus.FINALIZED)
}
}

Bad:

data class Match(
val id: MatchId,
val status: MatchStatus
)

combined with:

match.copy(status = MatchStatus.FINALIZED)

everywhere in the application.

The latter spreads knowledge of domain rules across the codebase.


1.5 Domain code must not hide side effects

Section titled “1.5 Domain code must not hide side effects”

Pure domain logic should not secretly perform:

  • database access
  • HTTP calls
  • messaging
  • filesystem access
  • logging with important semantics
  • clock access
  • random number generation

For example:

Good:

fun calculateTotal(items: List<Money>): Money =
items.fold(Money.zero(items.first().currency)) { total, item ->
total + item
}

The result depends only on the input.

Side effects belong at explicit boundaries.


Avoid the not-null assertion.

Bad:

val player = players.firstOrNull { it.id == id }!!

Prefer:

val player = players.firstOrNull { it.id == id }
?: throw PlayerNotFound(id)

Or, where the absence is an expected business outcome:

return players
.firstOrNull { it.id == id }

!! removes information from the type system instead of handling the possibility explicitly.


1.7 Expected business outcomes must be represented explicitly

Section titled “1.7 Expected business outcomes must be represented explicitly”

Do not use exceptions for normal business alternatives.

Bad:

try {
claimPlayer(playerId)
} catch (AlreadyClaimedException) {
...
}

Good:

sealed interface ClaimPlayerResult {
data class Claimed(
val player: CanonicalPlayer
) : ClaimPlayerResult
data object AlreadyClaimed : ClaimPlayerResult
data object ApprovalRequired : ClaimPlayerResult
}

The caller can now see the possible business outcomes directly.


1.8 Exceptions must represent exceptional conditions

Section titled “1.8 Exceptions must represent exceptional conditions”

Use exceptions for:

  • violated invariants
  • invalid construction
  • infrastructure failures
  • programming errors

Do not use exceptions as ordinary control flow.

For example:

require(status == MatchStatus.OPEN)

is appropriate when finalizing an already finalized match violates an invariant.

A business outcome such as “player already claimed” should normally not be an exception.


1.9 Infrastructure failures must not become domain concepts

Section titled “1.9 Infrastructure failures must not become domain concepts”

A database timeout is not a domain state.

Avoid:

sealed interface MatchResult {
data class Found(val match: Match) : MatchResult
data object DatabaseTimeout : MatchResult
}

The domain should not need to understand database failure modes.

Infrastructure failures can propagate as exceptions or be translated at an application boundary.


These are the normal choices in StrataStax.

They are strong defaults, not absolute laws.

A deviation is fine when it produces clearer or safer code.


Use val by default.

Default:

data class Match(
val id: MatchId,
val participants: List<MatchParticipant>,
val status: MatchStatus
)

When state changes, return a new value:

fun addParticipant(
participant: MatchParticipant
): Match =
copy(
participants = participants + participant
)

Prefer this over exposing mutable domain state:

class Match {
val participants = mutableListOf<MatchParticipant>()
}

Immutability makes state transitions explicit and easier to reason about.


2.2 Prefer immutable collections in domain models

Section titled “2.2 Prefer immutable collections in domain models”

Prefer:

data class Group(
val members: Set<PlayerId>
)

over:

data class Group(
val members: MutableSet<PlayerId>
)

The domain object should own its state rather than exposing a mutable collection.

Mutable collections may still be appropriate inside controlled implementation boundaries.


2.3 Prefer data class for immutable domain state

Section titled “2.3 Prefer data class for immutable domain state”

When a domain concept primarily consists of immutable state, start with a data class.

data class MatchParticipant(
val player: PlayerRef,
val outcome: Outcome?,
val score: Score?
)

Add behavior when that behavior is genuinely part of the concept.

Do not add methods merely to make the class appear “rich”.


2.4 Put intrinsic behavior on the domain concept

Section titled “2.4 Put intrinsic behavior on the domain concept”

Behavior belongs on a domain type when it is naturally part of that concept.

data class Match(
val status: MatchStatus
) {
fun finalize(): Match {
require(status == MatchStatus.OPEN)
return copy(status = MatchStatus.FINALIZED)
}
}

This is preferable to:

object MatchUtils {
fun finalize(match: Match): Match = ...
}

However, not all behavior belongs on a domain object.

Application orchestration remains outside:

class FinalizeMatch(
private val repository: MatchRepository
) {
suspend fun execute(id: MatchId): Match {
val match = repository.find(id)
?: throw MatchNotFound(id)
val finalized = match.finalize()
repository.update(finalized)
return finalized
}
}

The distinction is:

Match.finalize()
↓
domain behavior
FinalizeMatch.execute()
↓
application orchestration
MatchRepository
↓
persistence

2.5 Prefer pure transformations for independent operations

Section titled “2.5 Prefer pure transformations for independent operations”

Not every operation needs to become a method.

For example:

fun calculateStandings(
matches: List<Match>
): Standings =
...

is often clearer than forcing the behavior onto Match:

match.calculateStandings(...)

A useful rule is:

If behavior is intrinsic to one domain concept, consider a method. If it transforms or combines multiple concepts, consider a pure function.


Use expressions when they make the intent obvious.

val label = when (status) {
MatchStatus.OPEN -> "Open"
MatchStatus.FINALIZED -> "Finalized"
}

Avoid unnecessary mutable variables and statement-heavy code.

But clarity wins over expression purity.


2.7 Prefer the simplest collection operation that expresses intent

Section titled “2.7 Prefer the simplest collection operation that expresses intent”

Use:

val winners =
participants.filter { it.outcome == Outcome.Win }

when filtering is clearly what is happening.

Use:

val ids =
participants.map(MatchParticipant::playerId)

when mapping is clearly what is happening.

But do not force a complicated chain of operations when a loop is easier to understand.

This is perfectly acceptable:

val result = mutableListOf<PlayerId>()
for (participant in participants) {
if (participant.outcome == Outcome.Win) {
result += participant.playerId
}
}

The goal is clarity, not maximum use of functional operators.


Use an enum when variants are simply named values.

enum class MatchStatus {
OPEN,
FINALIZED
}

Do not turn every enum into a sealed hierarchy.


2.9 Prefer sealed types when variants have meaningful differences

Section titled “2.9 Prefer sealed types when variants have meaningful differences”

Use sealed types when variants carry different information or behavior.

sealed interface PaymentMethod
data class Ideal(
val issuer: String
) : PaymentMethod
data class CreditCard(
val lastFourDigits: String
) : PaymentMethod
data object Cash : PaymentMethod

This allows exhaustive handling:

fun describe(method: PaymentMethod): String =
when (method) {
is Ideal -> method.issuer
is CreditCard -> "**** ${method.lastFourDigits}"
Cash -> "Cash"
}

The rule is not “sealed types are better than enums”.

The rule is:

Use the type that best expresses the differences between the variants.


2.10 Prefer nullable values when absence is genuinely optional

Section titled “2.10 Prefer nullable values when absence is genuinely optional”

Nullability is not inherently bad.

This can be perfectly correct:

data class MatchParticipant(
val player: PlayerRef,
val score: Score?
)

If a participant legitimately may not have a score, Score? expresses that fact.

Do not replace every nullable value with a sealed hierarchy merely to avoid ?.

Use a richer type when the different states have meaningful domain semantics.


2.11 Prefer Result for generic success/failure

Section titled “2.11 Prefer Result for generic success/failure”

Kotlin’s Result is useful when the distinction is fundamentally:

success
failure

For example:

fun parseConfiguration(
input: String
): Result<Configuration>

Do not use Result merely because a function can return different business outcomes.

For meaningful business alternatives, prefer a domain-specific sealed type:

sealed interface ClaimResult {
data class Claimed(val player: Player) : ClaimResult
data object AlreadyClaimed : ClaimResult
data object ApprovalRequired : ClaimResult
}

2.12 Keep constructors simple when possible

Section titled “2.12 Keep constructors simple when possible”

Use a constructor when invariants are simple.

data class Score(
val value: Int
) {
init {
require(value >= 0)
}
}

Use a factory when construction requires:

  • parsing
  • normalization
  • multiple validation steps
  • choosing between variants
  • non-obvious construction logic

For example:

class EmailAddress private constructor(
val value: String
) {
companion object {
fun of(value: String): EmailAddress {
val normalized = value.trim().lowercase()
require('@' in normalized)
return EmailAddress(normalized)
}
}
}

Do not introduce factories just because factories are considered “DDD”.


2.13 Avoid utility classes for domain behavior

Section titled “2.13 Avoid utility classes for domain behavior”

Avoid:

object MatchUtils
object PlayerUtils
object ValidationUtils

when the functions actually describe domain behavior.

Prefer:

match.finalize()

or:

fun calculateStandings(matches: List<Match>): Standings

A pure function is preferable to a utility object when the behavior is independent of an object.


2.14 Keep application services thin but meaningful

Section titled “2.14 Keep application services thin but meaningful”

Application services/use cases may coordinate:

  • repositories
  • domain operations
  • transactions
  • external systems

They should not duplicate domain rules.

Prefer:

suspend fun execute(id: MatchId): Match {
val match = repository.find(id)
?: throw MatchNotFound(id)
val finalized = match.finalize()
repository.update(finalized)
return finalized
}

Avoid a service that merely moves property manipulation out of the model:

suspend fun execute(id: MatchId) {
val match = repository.find(id)!!
require(match.status == MatchStatus.OPEN)
repository.update(
match.copy(status = MatchStatus.FINALIZED)
)
}

The latter spreads the invariant outside Match.


These are powerful tools, but should be introduced deliberately.


3.1 Use value classes when they add domain meaning

Section titled “3.1 Use value classes when they add domain meaning”

Good:

@JvmInline
value class MatchId(val value: UUID)

Good:

@JvmInline
value class EmailAddress private constructor(
val value: String
)

But do not create a type for every primitive merely because you can.

This:

val retryCount: Int

does not automatically need:

RetryCount

Use a domain type when it improves correctness, readability, or prevents meaningful mistakes.


3.2 Use extension functions when they improve the API

Section titled “3.2 Use extension functions when they improve the API”

Simple conversions are a good use:

fun String.toEmailAddress(): EmailAddress =
EmailAddress.of(this)

Extensions can also express useful domain behavior:

fun Match.winner(): MatchParticipant? =
participants.firstOrNull {
it.outcome == Outcome.Win
}

But avoid hiding large workflows behind extensions:

fun List<Match>.processAllOrdersAndSendEmails() {
...
}

The issue is not “business logic in extensions”.

The issue is hiding important behavior behind an inappropriate receiver.


3.3 Use sealed hierarchies for state machines

Section titled “3.3 Use sealed hierarchies for state machines”

When states have different allowed operations, sealed types can model the state machine directly.

For example:

sealed interface MatchState {
data object Open : MatchState
data object Finalized : MatchState
}

If the states carry different data or allow fundamentally different behavior, this can be stronger than an enum plus runtime checks.

However, do not introduce a sealed state hierarchy when:

enum class MatchStatus {
OPEN,
FINALIZED
}

is already perfectly expressive.


Calculations are often naturally functional.

fun calculateTotal(
items: List<Money>
): Money =
items.fold(Money.zero(items.first().currency)) { total, item ->
total + item
}

This is especially useful for:

  • scoring
  • rankings
  • filtering
  • projections
  • transformations
  • validation calculations

Pure functions are easy to test because they have no external dependencies.


3.5 Use mutation inside controlled boundaries when it is clearer

Section titled “3.5 Use mutation inside controlled boundaries when it is clearer”

Immutability is the default for domain state.

It is not a ban on mutation.

An in-memory repository may reasonably use:

class InMemoryMatchRepository : MatchRepository {
private val matches = mutableMapOf<MatchId, Match>()
override suspend fun save(match: Match) {
matches[match.id] = match
}
override suspend fun find(id: MatchId): Match? =
matches[id]
}

The mutable state is:

  • local
  • encapsulated
  • controlled
  • not exposed as domain state

That is a perfectly reasonable use of mutation.


3.6 Use loops when they improve clarity or performance

Section titled “3.6 Use loops when they improve clarity or performance”

Functional code is not inherently better.

This:

val ids =
participants
.filter { it.outcome == Outcome.Win }
.map { it.playerId }

is excellent when the transformation is simple.

But for a more involved operation:

val standings = mutableMapOf<PlayerId, Int>()
for (match in matches) {
for (participant in match.participants) {
val points = pointsFor(participant)
standings.merge(participant.playerId, points, Int::plus)
}
}

a loop may be easier to understand and avoid unnecessary intermediate collections.

Use the clearest implementation.


3.7 Use local mutable variables for complex algorithms

Section titled “3.7 Use local mutable variables for complex algorithms”

A pure function can still contain local mutation.

For example:

fun calculateRanking(
matches: List<Match>
): Ranking {
val scores = mutableMapOf<PlayerId, Int>()
for (match in matches) {
...
}
return Ranking(scores)
}

This does not violate the architectural goal of immutability.

The important distinction is:

controlled local mutation
↓
implementation detail
shared mutable domain state
↓
architectural complexity

Prefer the former when it makes an algorithm substantially clearer.


3.8 Use explicit types when they communicate intent

Section titled “3.8 Use explicit types when they communicate intent”

Type inference is usually excellent:

val match = repository.find(id)

But explicit types can improve documentation at boundaries:

val result: ClaimPlayerResult =
claimPlayer(command)

or when inference becomes difficult to follow.

Do not add types merely because Kotlin allows them.


3.9 Use abstractions only when they have a purpose

Section titled “3.9 Use abstractions only when they have a purpose”

Do not create an interface simply because there is one implementation.

Avoid:

interface MatchService
class MatchServiceImpl : MatchService

Prefer:

class FinalizeMatch(
private val repository: MatchRepository
)

Introduce an abstraction when it provides a meaningful boundary, such as:

  • domain/application boundary
  • infrastructure boundary
  • replaceable dependency
  • architectural contract
  • meaningful polymorphism

Abstraction is a tool, not a goal.


StrataStax does not prescribe “object-oriented” or “functional” Kotlin universally.

Choose based on where the behavior belongs.

Prefer a method:

match.finalize()

A method or extension may be appropriate:

match.winner()

Prefer a function:

calculateStandings(matches)

Use an application component:

finalizeMatch.execute(matchId)

Use a dedicated infrastructure component:

matchRepository.find(matchId)

The resulting architecture should make the flow obvious:

Application
│
▼
┌────────────────┐
│ Domain objects │
└────────────────┘
│
▼
Pure transformations
│
▼
Infrastructure ports
│
▼
Database / HTTP

A domain model should describe the domain rather than its persistence representation.

For example:

data class Match(
val id: MatchId,
val game: GameRef,
val participants: List<MatchParticipant>,
val status: MatchStatus
)

A database representation may look completely different:

data class MatchRow(
val id: Long,
val publicId: UUID,
val gameId: Long,
val status: String
)

That is not duplication for its own sake.

The two types represent different concepts:

MatchRow
= persistence representation
Match
= domain representation

Do not force persistence structures into the domain merely to reduce mapping code.


6. Rich Domain Modeling Without Overengineering

Section titled “6. Rich Domain Modeling Without Overengineering”

“Rich domain model” does not mean every class needs behavior.

This is perfectly valid:

data class GameRef(
val id: GameId,
val name: String
)

There may simply be no meaningful behavior that belongs on GameRef.

Likewise:

data class MatchParticipant(
val player: PlayerRef,
val score: Score?,
val outcome: Outcome?
)

can remain primarily data.

Rich modeling means:

Put meaningful domain rules and behavior in the model when they belong there.

It does not mean:

Add methods to every data class.

Avoid artificial richness such as:

fun getId(): MatchId = id
fun getStatus(): MatchStatus = status
fun isValid(): Boolean = true

That adds ceremony without adding domain meaning.


Nullability is a domain modeling decision.

Use nullable:

val score: Score?

when absence is genuinely meaningful.

Use a non-null type:

val game: GameRef

when the domain requires it.

Use a sealed type when null would hide important distinctions:

sealed interface ClaimState {
data object Unclaimed : ClaimState
data object PendingApproval : ClaimState
data class Claimed(val player: CanonicalPlayer) : ClaimState
}

The goal is not “zero nulls”.

The goal is:

Make the meaning of absence explicit.


StrataStax distinguishes three fundamentally different situations.

Something violates an invariant.

require(status == MatchStatus.OPEN)

or:

throw InvalidMatchState(...)

Something is a normal possibility in the domain.

sealed interface ClaimResult {
data class Claimed(val player: Player) : ClaimResult
data object AlreadyClaimed : ClaimResult
data object ApprovalRequired : ClaimResult
}

Something external failed.

database unavailable
HTTP timeout
serialization failure
connection failure

These should normally be exceptions at the infrastructure boundary.

The core principle is:

Exceptions protect invariants and represent failures. Domain result types represent meaningful business alternatives.


Kotlin provides many powerful language features.

Do not use them merely because they are available.

Prefer:

val winner = participants.firstOrNull {
it.outcome == Outcome.Win
}

over an unnecessarily clever abstraction.

Prefer:

when (status) {
MatchStatus.OPEN -> ...
MatchStatus.FINALIZED -> ...
}

over indirection when the when directly communicates the business rule.

Prefer a small explicit class over a generic abstraction when the abstraction obscures the domain.

Code should be easy to read before it is impressive to write.


Generated code is infrastructure.

Do not apply domain-style rules mechanically to generated code.

For example, generated persistence code may legitimately contain:

data class MatchRecord(
val id: Long,
val status: String
)

Generated code should optimize for:

  • correctness
  • predictable generation
  • interoperability
  • performance
  • stable contracts

Hand-written domain code should optimize for:

  • domain clarity
  • invariants
  • behavior
  • type safety

Do not distort the domain model merely to make generated code simpler.


When writing Kotlin, ask these questions in order:

If yes, model it explicitly.

MatchId
GameId
EmailAddress
Match
Player

2. Does this value have meaningful domain identity?

Section titled “2. Does this value have meaningful domain identity?”

Consider a value class.

@JvmInline
value class MatchId(val value: UUID)

Enforce it at construction.

require(value >= 0)

4. Does this behavior intrinsically belong to the concept?

Section titled “4. Does this behavior intrinsically belong to the concept?”

Use a method.

match.finalize()

5. Does this operation combine or transform multiple concepts?

Section titled “5. Does this operation combine or transform multiple concepts?”

Consider a pure function.

calculateStandings(matches)

6. Are there meaningful alternative outcomes?

Section titled “6. Are there meaningful alternative outcomes?”

Model them explicitly.

sealed interface ClaimResult

Use nullable.

Score?

Keep it outside the domain.

MatchRepository
BggClient
QueryExecutor

9. Would an abstraction make the code clearer?

Section titled “9. Would an abstraction make the code clearer?”

If not, don’t introduce it.

10. Is the result simpler and clearer than the alternatives?

Section titled “10. Is the result simpler and clearer than the alternatives?”

If yes, stop.


StrataStax Kotlin follows these principles:

  • Keep domain code independent from infrastructure.
  • Enforce domain invariants at domain boundaries.
  • Use meaningful domain types instead of ambiguous primitives.
  • Keep side effects outside pure domain logic.
  • Represent expected business outcomes explicitly.
  • Use exceptions for invariant violations and failures.
  • Do not use !!.
  • Prefer immutable state.
  • Prefer val.
  • Prefer immutable collections in domain models.
  • Prefer data classes for immutable state.
  • Put intrinsic behavior on domain concepts.
  • Use pure functions for independent transformations.
  • Use enums for simple closed values.
  • Use sealed types for meaningful variants.
  • Use nullable values when absence is genuinely optional.
  • Keep application orchestration outside domain objects.
  • Prefer the clearest implementation over stylistic purity.
  • Avoid unnecessary utility classes and abstractions.
  • Value classes for meaningful domain primitives.
  • Extension functions for clear, focused APIs.
  • Sealed state machines for genuinely different states.
  • Pure functions for calculations and transformations.
  • Local mutation for algorithms where it improves clarity or performance.
  • Controlled mutable state inside infrastructure components.
  • Loops when they are clearer or more efficient than collection chains.
  • Factories when construction is non-trivial.
  • Result for generic success/failure.
  • Explicit types where they communicate intent.
  • Abstractions where they provide a meaningful architectural or polymorphic boundary.

Choose the simplest Kotlin construct that makes the domain explicit, the behavior obvious, and invalid states difficult to create.

Kotlin’s type system, functional features, and object-oriented features are tools.

They are not the architecture.