Recommended resources
The freely available resources this lesson will direct you to.
Hello! Last lesson, you used if, when, and destructuring to work with values. Now the question is how to define those values in the first place. A message, an outbox, and the possible outcomes of sending a message have different needs; giving them all the same kind of class would obscure those differences.
In this lesson, you’ll build a small notification model using ordinary classes, data classes, interfaces, enums, and sealed classes. You’ll also see how the when expression you already know becomes more useful when the set of possible values is defined by a type.
Give data and changing state different homes
Start with a message. Its recipient, body, and channel describe a value that you may want to compare with another message or copy with one field changed. A data class supplies those operations:
data class Message(
val recipient: String,
val body: String,
val channel: Channel
) {
init {
require(recipient.isNotBlank()) { "Recipient must not be blank" }
require(body.isNotBlank()) { "Body must not be blank" }
}
}
Channel will be defined shortly. Kotlin permits a type to refer to another declaration that appears later in the same file.
The primary constructor creates three read-only properties. The init block checks that a message is valid when it is constructed. Here, require rejects an invalid argument; later in the course, you’ll examine ways to handle failures at a program boundary.
Watch the ordinary-class and data-class portions of Every Kind of Class in Kotlin by Dave Leeds. Focus on what each kind of object represents, rather than trying to learn every class variety in the full video.
In Every Kind of Class in Kotlin, Dave Leeds contrasts a class that combines state and behavior with a data class intended to represent a value.
Watch ordinary classes for the basic class model, then data classes for generated equality, copying, and destructuring. Skip the intervening nested-class discussion for this lesson.
Kotlin generates equals, hashCode, toString, copy, and destructuring components for a data class from its primary-constructor properties. For example:
val original = Message("recipient-1", "Ready", Channel.EMAIL)
val revised = original.copy(body = "Sent")
val (recipient, body, channel) = original
println(original == Message("recipient-1", "Ready", Channel.EMAIL)) // true
println(revised.body) // Sent
This connects to last lesson’s destructuring: the three extracted values follow the order of properties in Message’s primary constructor. copy creates a new Message; it does not edit original. It is a shallow copy, though, so adding a mutable object as a property would not magically duplicate that object.
An outbox has a different role. It owns a queue that changes over time and enforces a limit:
class Outbox(private val capacity: Int) {
init {
require(capacity > 0) { "Capacity must be positive" }
}
private val queued = mutableListOf<Message>()
val queuedCount: Int
get() = queued.size
fun enqueue(message: Message) {
check(queued.size < capacity) { "Outbox is full" }
queued.add(message)
}
}
The queue is private, so callers cannot bypass enqueue to add messages. queuedCount is computed from the current queue rather than stored as a second, potentially inconsistent number. This is a useful reason to choose an ordinary class: the object has an identity and manages changing state through behavior.
Unlike the data class, two separately created Outbox(2) objects do not compare equal by their constructor values. Unless you override equality, == on an ordinary class has the default identity-based behavior. Kotlin classes are also final by default: another class cannot inherit from Outbox unless you explicitly make it open. You do not need inheritance merely to give an outbox useful methods.
For the constructor details, read the official Kotlin documentation. Concentrate on what val and var in a primary constructor do, and on when an init block runs; you can leave secondary constructors for another time.
Classes | Kotlin Documentation
The official Kotlin Classes documentation explains how constructor parameters become properties and how initialization can enforce a class invariant.
In “Constructors and initializer blocks,” find “Primary constructor.” Read constructor properties, comparing parameters with and without val or var. Then, in “Initializer blocks,” read initialization and validation and look at the require example immediately below. Stop before “Secondary constructors.”
Specify a capability with an interface
The outbox knows how to hold messages. It need not know how a message is dispatched. To express that separate capability, define an interface:
interface Sender {
fun send(message: Message): DispatchOutcome
}
A class implementing Sender must provide send using override. Code that accepts a Sender can depend on this contract without depending on one particular sender implementation. DispatchOutcome, like Channel, will be defined below.
An interface can declare properties and can provide method implementations, but it cannot store instance state in a backing field. An implementing class can store its own state. A class may implement more than one interface, so an interface is a way to describe a capability, not necessarily an exclusive place in a class family. This contrasts with a sealed hierarchy, which you’ll use when you do want to restrict the alternatives.
Read the opening of the official Interfaces documentation to see the declaration, implementation, and property rules together.
Interfaces | Kotlin Documentation
The official Kotlin Interfaces documentation shows what an interface can require and why its properties differ from stored class properties.
From the start of “Interfaces,” read interface basics, including “Implementing interfaces” and the start of “Properties in interfaces.” Focus on override and the absence of backing fields; the property example immediately below the selected passage illustrates the latter rule.
Close a set of choices with an enum or a sealed class
A message’s channel is one choice from a small, fixed list. An enum class fits:
enum class Channel {
EMAIL,
SMS
}
Channel.EMAIL and Channel.SMS are the defined entries, not templates from which callers create new channels with arbitrary values. An enum can also give each entry fixed properties, but there is no need to add them here.
A dispatch outcome is different. You know its kinds—accepted or rejected—but each occurrence may carry a different receipt ID or reason. Use a sealed class whose subclasses hold those values:
sealed class DispatchOutcome {
data class Accepted(val receiptId: String) : DispatchOutcome()
data class Rejected(val reason: String) : DispatchOutcome()
}
A sealed class restricts its direct subclasses. Kotlin requires those subclasses to be declared in the same package and module, so code elsewhere cannot add an unexpected direct outcome type. Each Accepted or Rejected is still a separately constructed value with its own payload. The nested data classes give those payload-bearing values data-class behavior.
The selected video segments put these two choices side by side:
Return to Every Kind of Class in Kotlin for its comparison of fixed enum entries and restricted sealed subclasses.
Watch enum entries and sealed subclasses. Notice the distinction between restricting the available instances and restricting the available kinds of instance.
Both types work particularly well with when. Because the compiler knows every direct kind of DispatchOutcome, this expression needs no else:
fun describe(outcome: DispatchOutcome): String =
when (outcome) {
is DispatchOutcome.Accepted -> "Accepted: ${outcome.receiptId}"
is DispatchOutcome.Rejected -> "Rejected: ${outcome.reason}"
}
The is check establishes which subclass you have, so its particular property is available in that branch. If you later add a new direct outcome type, this value-producing when must be updated to cover it. That is more informative than an else that quietly lumps a new case together with old ones.
The design distinction is worth keeping precise: use an enum for a fixed set of entries such as EMAIL and SMS; use a sealed hierarchy for a fixed set of cases whose instances may carry different data. Use an ordinary, unrestricted interface like Sender when new implementations should remain possible.
See the model working together
Only two pieces remain: an implementation of Sender and a small entry point. This preview implementation applies an illustrative rule: it rejects SMS messages longer than 200 characters. That number is a rule for this example, not a statement about the SMS protocol.
class PreviewSender : Sender {
override fun send(message: Message): DispatchOutcome =
if (message.channel == Channel.SMS && message.body.length > 200) {
DispatchOutcome.Rejected("SMS body exceeds preview limit")
} else {
DispatchOutcome.Accepted("preview")
}
}
fun main() {
val message = Message("recipient-1", "Delivery notice", Channel.SMS)
val outbox = Outbox(capacity = 2)
outbox.enqueue(message)
val sender: Sender = PreviewSender()
println(outbox.queuedCount) // 1
println(describe(sender.send(message))) // Accepted: preview
}
Place this snippet after the earlier declarations in one Kotlin file to run the example. Notice the different responsibilities: Message is a comparable value; Outbox protects a changing queue; Channel limits a simple choice; Sender states a capability; and DispatchOutcome limits the kinds of answer while allowing each answer to contain its own data. PreviewSender satisfies the contract without forcing dispatch behavior into Message or Outbox.
You could write the whole example with strings and Booleans, but then "sent", "rejected", or a misspelled channel would have meanings only by convention. These types make the distinctions visible to the compiler and to the next person reading the code.
Key takeaways
Choose a data class when primary-constructor values should drive equality, copying, and destructuring. Choose an ordinary class when an object manages identity, state, and behavior. Use an interface to define an implementable capability, an enum for fixed entries, and a sealed class for a restricted set of payload-bearing cases. Together, these choices make domain rules easier to express and make when branches easier to check.
Next, you’ll extend what types can do with extension functions, and examine generics, variance, and delegation—tools for reusing behavior without blurring the boundaries you established here.