CS 346: Application Development

Jeff Avery

Estimated study time: 44 minutes

Table of contents

Sources and References

These notes draw on the following primary and supplementary sources:

  • Primary: CS 346 course outline and lecture materials, University of Waterloo, Winter 2026 (Jeff Avery, Adrian Reetz, Caroline Kierstead)
  • MIT 6.031 Elements of Software Construction — foundational software engineering principles (readability, correctness, changeability)
  • CMU 17-214 / 17-514 Principles of Software Construction — design patterns, architecture, testability
  • JetBrains Kotlin Documentation — language reference, coroutines, standard library (kotlinlang.org)
  • Jetpack Compose Documentation — Android and Desktop UI toolkit (developer.android.com/jetpack/compose)
  • Ktor Documentation — server-side Kotlin web framework (ktor.io)
  • Exposed Documentation — JetBrains Kotlin SQL framework (github.com/JetBrains/Exposed)
  • Supabase Documentation — open-source Backend-as-a-Service (supabase.com/docs)
  • Android Developer Guides — Room persistence library, Gradle for Android (developer.android.com)
  • Kotlin Multiplatform Documentation — KMP and Compose Multiplatform (kotlinlang.org/docs/multiplatform)

Chapter 1: Foundations of Full-Stack Development

1.1 What Is Application Development?

Modern application development is rarely a solitary activity. It is a sociotechnical practice that spans requirements gathering, system design, coding, testing, and deployment — and it requires coordinating the efforts of multiple engineers working toward a shared goal. CS 346 situates you within this practice by having you build a real, full-stack application as part of a team over a full academic term.

The term full-stack means that your application will have meaningful components at every layer: a user interface that a human interacts with, application logic that enforces business rules, data persistence that survives process restarts, and possibly remote services that expose functionality over a network. Each layer introduces its own complexity, and the central challenge of application development is managing that complexity so that the system remains comprehensible, correct, and changeable over time.

MIT 6.031 identifies three properties that every software system should strive for: correctness (the program does what it is supposed to do under all reasonable conditions), changeability (the code can be modified without heroic effort), and readability (the code communicates its intent clearly to human readers). These three properties are not independent. A program that is readable is usually easier to make correct; a program that is modular is usually easier to change. Throughout this course, every architectural decision, every design pattern, and every testing strategy ultimately serves these three goals.

1.2 Working in a Team: Iterative Development

Professional software is developed iteratively. Rather than attempting to specify everything upfront, teams work in short cycles — often called sprints or iterations — delivering working software at the end of each cycle. This allows requirements to evolve in response to user feedback and reduces the risk of building the wrong thing.

At Waterloo, CS 346 projects are structured around roughly three-week iterations. Each iteration begins with planning (deciding what to build), proceeds through implementation and testing, and concludes with a review and retrospective. The retrospective is not a formality; it is the primary mechanism by which a team improves its own processes.

Sprint: A fixed-length time-box (typically one to four weeks) during which a team commits to completing a defined set of work. At the end of the sprint, the team demonstrates working software.

Effective teamwork in software requires explicit coordination mechanisms. GitLab serves as both a version-control host and a project-management tool in this course. Issues represent units of work (features, bugs, tasks), and a milestone groups issues into an iteration. Every code change is associated with a branch, which is reviewed via a merge request before being integrated into the main branch. This workflow, sometimes called trunk-based development with short-lived feature branches, keeps the main branch releasable at all times.

Good commit hygiene matters more in a team than when working alone. Commits should be atomic (one logical change per commit), their messages should explain why a change was made rather than merely what changed, and branches should be short-lived to minimise merge conflicts. These habits are not arbitrary bureaucracy; they are the residue of hard-won experience in large engineering organisations.

1.3 Requirements Engineering

Before a single line of code is written, a development team needs to understand what the software is supposed to do. Requirements engineering is the discipline of discovering, documenting, and managing those needs. It sits at the intersection of software engineering and human communication, and it is notoriously difficult to do well.

Requirements are conventionally divided into two categories. Functional requirements describe what the system must do — the behaviours and features it must exhibit. Non-functional requirements (sometimes called quality attributes) describe how well the system must do those things — its performance, reliability, security, usability, and maintainability. Neglecting non-functional requirements is a common source of project failures; a system that correctly implements every feature but takes ten seconds to respond to a button press will not be accepted by its users.

User Story: A short, informal description of a software feature written from the perspective of the end user. The canonical form is: "As a [type of user], I want [some goal] so that [some reason]."

User stories are a lightweight technique for capturing functional requirements. They are intentionally incomplete — they are conversation starters, not specifications. The details are fleshed out through discussion between developers and stakeholders, often captured as acceptance criteria attached to the story. A story is considered done when all its acceptance criteria are satisfied and the code passes automated tests that verify those criteria.

Non-functional requirements are harder to write as user stories, but they can still be made concrete. Instead of writing “the application should be fast,” write “search results must appear within 500 milliseconds for a database of 10,000 records on a device with 4 GB of RAM.” Measurable requirements can be verified; vague ones cannot.

Issue tracking in GitLab gives teams a persistent, searchable record of what has been decided, what is in progress, and what has been done. Each issue should have a clear title, a description that provides enough context for any team member to pick it up, and labels that categorise it (e.g., feature, bug, tech-debt). Linking issues to merge requests creates an audit trail that is invaluable when debugging regressions months later.


Chapter 2: The Kotlin Programming Language

2.1 Why Kotlin?

Kotlin is a statically typed, multi-paradigm programming language developed by JetBrains and released as open source in 2011. It runs on the Java Virtual Machine (JVM), compiles to JavaScript, and — through Kotlin/Native — compiles to native binaries for platforms including iOS, macOS, Linux, and Windows. In 2017, Google announced first-class support for Kotlin on Android, and it has since become the preferred language for Android development.

Kotlin was designed to eliminate the most common pain points of Java while remaining fully interoperable with the existing Java ecosystem. It eliminates entire categories of null-pointer exceptions through its type system, reduces boilerplate through data classes and extension functions, and brings first-class support for functional programming idioms that make code more concise and composable. For CS 346, Kotlin is not merely a tool of convenience — it is a language whose design philosophy directly supports the correctness, changeability, and readability goals articulated in Chapter 1.

2.2 Object-Oriented Programming in Kotlin

Kotlin’s class system will feel familiar to Java programmers, but it has important differences. By default, classes in Kotlin are final (cannot be subclassed) and properties are public. This inversion of Java’s defaults pushes developers toward composition over inheritance, which is generally better for changeability.

class Person(val name: String, var age: Int) {
    fun greet(): String = "Hello, I'm $name"
}

The primary constructor is declared inline with the class header. The val keyword declares an immutable property (read-only after construction), while var declares a mutable one. Prefer val whenever possible — immutability eliminates an entire class of bugs related to shared mutable state.

Data classes are classes whose primary purpose is to hold data. Kotlin generates equals(), hashCode(), toString(), and copy() methods automatically:

data class Point(val x: Double, val y: Double)

val p1 = Point(3.0, 4.0)
val p2 = p1.copy(y = 0.0)  // Point(3.0, 0.0)

The copy() function is particularly valuable in immutable architectures: rather than mutating an object, you create a new one with some fields changed.

Sealed classes are used to model restricted class hierarchies — situations where you know all possible subclasses at compile time. They are ideal for representing algebraic data types such as the result of an operation that can either succeed or fail:

sealed class Result<out T> {
    data class Success<T>(val value: T) : Result<T>()
    data class Failure(val error: Throwable) : Result<Nothing>()
}

When you when-switch on a sealed class, the compiler enforces exhaustiveness — you must handle every case. This transforms a runtime bug (forgetting to handle a case) into a compile-time error.

Interfaces in Kotlin support default method implementations, making them more powerful than Java interfaces but less heavyweight than abstract classes:

interface Repository<T> {
    fun findById(id: Int): T?
    fun findAll(): List<T>
    fun save(entity: T): T
    fun delete(id: Int): Boolean
}

Interfaces are the primary tool for dependency inversion: higher-level modules depend on abstractions (interfaces), not concrete implementations. This is the “D” in SOLID and is essential for testability.

2.3 Null Safety

Null references are famously described as Tony Hoare’s “billion-dollar mistake.” Kotlin addresses this at the type-system level. Every type in Kotlin is either nullable (can hold null, written with a trailing ?) or non-nullable (cannot hold null). Attempting to assign null to a non-nullable type is a compile error.

val name: String = "Alice"      // cannot be null
val nickname: String? = null    // can be null

// Safe call operator: returns null instead of throwing NPE
val length = nickname?.length

// Elvis operator: provide a default when the value is null
val displayName = nickname ?: "Anonymous"

// Not-null assertion: throws NullPointerException if null (use sparingly)
val forced = nickname!!.length

The ?. (safe call) and ?: (Elvis) operators make null-handling explicit and localised. Well-written Kotlin code almost never uses !!; reaching for it is a code smell indicating that a design decision about nullability should be revisited.

2.4 Functional Programming in Kotlin

Kotlin treats functions as first-class values: they can be stored in variables, passed as arguments, and returned from other functions. This enables higher-order functions — functions that take other functions as parameters or return them.

The Kotlin standard library provides a rich set of higher-order functions for collections:

val numbers = listOf(1, 2, 3, 4, 5, 6)

val evens = numbers.filter { it % 2 == 0 }       // [2, 4, 6]
val squares = numbers.map { it * it }              // [1, 4, 9, 16, 25, 36]
val sum = numbers.reduce { acc, n -> acc + n }     // 21
val groups = numbers.groupBy { if (it % 2 == 0) "even" else "odd" }

Lambda expressions are the anonymous function literals used in these calls. The it identifier is the implicit name for the single parameter of a lambda when no explicit name is given.

Extension functions allow you to add methods to existing classes without modifying them or using inheritance. They are resolved statically, so they do not actually modify the class — they are syntactic sugar for top-level utility functions:

fun String.isPalindrome(): Boolean = this == this.reversed()

"racecar".isPalindrome()  // true

Extension functions are one of Kotlin’s most expressive features. They are used extensively in Jetpack Compose (to add composable methods to DSL scopes), in the standard library (e.g., let, run, apply, also, with), and in frameworks like Ktor.

The scope functionslet, run, apply, also, with — deserve particular attention because they appear constantly in idiomatic Kotlin:

val result = someNullable?.let { value ->
    // only executed if someNullable is not null
    process(value)
}

val configured = ServerConfig().apply {
    host = "localhost"
    port = 8080
}

apply returns the receiver (useful for configuration), let returns the result of the lambda (useful for transformation), and also returns the receiver but executes the lambda for side effects (useful for logging).

2.5 Gradle and the Build System

Gradle is the build system used for Kotlin and Android projects in this course. It manages dependency resolution, compilation, testing, and packaging through a directed acyclic graph of tasks. The build configuration is written in Kotlin (using build.gradle.kts files), which means the full power of the Kotlin language is available in your build scripts.

A minimal build.gradle.kts for a JVM application looks like this:

plugins {
    kotlin("jvm") version "1.9.22"
    application
}

group = "com.example"
version = "1.0.0"

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
    testImplementation(kotlin("test"))
}

application {
    mainClass.set("com.example.MainKt")
}

Multi-module projects divide a large application into separate Gradle subprojects, each with its own build.gradle.kts. This enforces boundaries between layers (e.g., app, data, domain, network) and allows independent compilation and testing. The root settings.gradle.kts includes all subprojects:

include(":app", ":data", ":domain", ":network")

Understanding the Gradle task execution model helps you debug build issues. When you run ./gradlew build, Gradle constructs a task graph from the declared dependencies between tasks (compileKotlintestjarbuild) and executes them in topological order. Tasks are incremental by default: if the inputs have not changed since the last run, the task is skipped (marked UP-TO-DATE). This makes Gradle builds fast in practice, but it can occasionally cause confusion when a build does not pick up a change — in that case, ./gradlew clean build forces a full rebuild.


Chapter 3: User Interface Development with Jetpack Compose

3.1 From Imperative to Declarative UI

Historically, graphical user interfaces were built imperatively: the programmer would create a widget object, set its properties by calling mutator methods, register event listeners, and manually update the widget tree in response to state changes. Java Swing and Android’s XML layout system are examples of this paradigm. The programmer is responsible for keeping the UI consistent with the application state — a task that grows quadratically complex as the state space expands.

Jetpack Compose is Google’s modern UI toolkit for Android (and, via Compose Multiplatform, for desktop and web). It is built on a declarative paradigm: instead of mutating a widget tree, you describe the UI as a function of state. When the state changes, Compose re-invokes the relevant parts of your description and reconciles the output with what is currently on screen — a process called recomposition.

Declarative UI: A style of UI programming in which the developer describes what the UI should look like for a given state, rather than specifying how to transition the UI from one state to another. The framework handles the transitions.

This inversion dramatically reduces the surface area for bugs. There is no possibility of the UI drifting out of sync with the model because the UI is always derived from the model. The tradeoff is that the framework must be smart about which parts of the UI need to be redrawn — otherwise declarative UIs would be prohibitively expensive.

3.2 Composable Functions

In Compose, the unit of UI is the composable function — a Kotlin function annotated with @Composable. Composable functions describe a piece of UI and can call other composable functions:

@Composable
fun Greeting(name: String) {
    Text(text = "Hello, $name!", style = MaterialTheme.typography.headlineMedium)
}

@Composable
fun GreetingScreen() {
    Column(modifier = Modifier.padding(16.dp)) {
        Greeting(name = "World")
        Greeting(name = "Kotlin")
    }
}

Composable functions have two important constraints. First, they can only be called from other composable functions (or from a setContent block that launches a composition). Second, they should be pure with respect to state — given the same inputs and the same observable state, they should produce the same UI. Side effects (network requests, database writes) should be managed through Compose’s effect APIs (LaunchedEffect, SideEffect, DisposableEffect).

Layout in Compose uses three fundamental building blocks: Column (vertical), Row (horizontal), and Box (overlapping). These replace the XML layout attributes of the old system. The Modifier type is a chainable descriptor of visual and behavioural properties (size, padding, background, click handlers, etc.):

Box(
    modifier = Modifier
        .fillMaxWidth()
        .height(200.dp)
        .background(Color.Blue)
        .clickable { /* handle click */ }
) {
    Text("Centered", modifier = Modifier.align(Alignment.Center))
}

3.3 State Management and Recomposition

The central question in any reactive UI framework is: when should the UI update? In Compose, the answer is: whenever observable state changes. The mutableStateOf function creates a State object whose changes trigger recomposition of any composable that read its value during the last composition:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

The remember function caches the mutableStateOf result across recompositions — without it, a new state object would be created every time Counter recomposes, resetting the count to zero. remember preserves state for as long as the composable is in the composition.

However, remember only survives recomposition — not configuration changes (such as screen rotation on Android) or process death. For state that must survive configuration changes, use rememberSaveable, which uses the same Bundle mechanism as the traditional onSaveInstanceState.

In larger applications, state should be hoisted out of deeply nested composables and into a ViewModel or a common ancestor. State hoisting means replacing a composable’s internal state with parameters (state down) and callbacks (events up), making the composable stateless and therefore easier to test and reuse.

3.4 Android Activity Lifecycle and Compose Integration

On Android, a Compose application is hosted inside an Activity. The Activity has a well-defined lifecycle: it is created, started, resumed, paused, stopped, and eventually destroyed. Understanding this lifecycle is important because it determines when your application has access to the screen, when it should release expensive resources, and when it might be interrupted by incoming calls or switching to another app.

In a Compose-first Android project, you typically override only onCreate and call setContent to establish the root composition:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyAppTheme {
                NavHost(/* ... */)
            }
        }
    }
}

The ComponentActivity base class (from AndroidX) is the modern replacement for the older AppCompatActivity. It integrates with Compose’s rememberSaveable, ViewModels, and the Navigation component.


Chapter 4: Software Architecture

4.1 Why Architecture Matters

Software architecture is the set of significant decisions about a system’s structure — how it is divided into parts, what each part is responsible for, and how the parts communicate. Architecture matters because it determines the system’s ability to accommodate change. A poorly architected system accumulates technical debt: shortcuts taken early that make future changes progressively harder and more expensive.

The goal of good architecture is separation of concerns — ensuring that each module of the system has a clear, well-bounded responsibility and that changes to one concern do not cascade into unrelated parts of the system. This principle underlies every architectural pattern discussed in this chapter.

SOLID Principles: A set of five design principles for object-oriented software: Single Responsibility (a class has one reason to change), Open/Closed (open for extension, closed for modification), Liskov Substitution (subtypes must be substitutable for their supertypes), Interface Segregation (clients should not depend on interfaces they do not use), and Dependency Inversion (depend on abstractions, not concretions).

4.2 Model-View-Controller

Model-View-Controller (MVC) is one of the oldest and most widely known architectural patterns. It divides a user interface application into three roles:

  • The Model holds the application’s data and business logic. It is independent of any UI framework.
  • The View renders the model’s state and presents it to the user. It observes the model and updates itself when the model changes.
  • The Controller handles user input, translates it into operations on the model, and selects which view to display.

MVC was originally designed for desktop applications and works well when views can directly observe and render model objects. It becomes awkward in frameworks where the view layer has strict threading requirements (e.g., Android’s UI thread constraint) or where a single user action must coordinate multiple model changes across multiple views.

4.3 Model-View-ViewModel

Model-View-ViewModel (MVVM) is the architecture recommended by Google for Android development and is the primary pattern for CS 346 projects. It introduces the ViewModel as an intermediary between the View (Compose composables) and the Model (domain entities and repositories).

The ViewModel’s responsibilities are:

  1. Hold and manage UI-related state, exposing it via observable streams (typically StateFlow or LiveData).
  2. Handle user events (button clicks, text input) and translate them into calls to the domain layer.
  3. Survive configuration changes (the ViewModel is not destroyed when an Activity is rotated).
class TaskViewModel(private val repository: TaskRepository) : ViewModel() {

    private val _tasks = MutableStateFlow<List<Task>>(emptyList())
    val tasks: StateFlow<List<Task>> = _tasks.asStateFlow()

    init {
        viewModelScope.launch {
            repository.observeTasks().collect { _tasks.value = it }
        }
    }

    fun addTask(title: String) {
        viewModelScope.launch {
            repository.insert(Task(title = title))
        }
    }
}

The composable collects the state:

@Composable
fun TaskScreen(viewModel: TaskViewModel = viewModel()) {
    val tasks by viewModel.tasks.collectAsState()
    // render tasks, call viewModel.addTask on user action
}

This separation has profound implications for testability. The ViewModel contains no Android framework types (it does not know about Context, Activity, or Compose), so it can be unit-tested with plain JVM tests. The composable is a thin rendering layer that, ideally, contains no logic at all.

4.4 Clean Architecture

Clean Architecture, introduced by Robert C. Martin, extends MVVM by adding explicit layers and enforcing a strict dependency rule: source code dependencies can only point inward — outer layers depend on inner layers, never the reverse.

The canonical layers are:

LayerContentsDepends On
PresentationViewModels, ComposablesDomain
DomainUse cases, entities, repository interfacesNothing
DataRepository implementations, DAOs, network clientsDomain (implements interfaces)

The domain layer is the most important. It contains the business rules — the logic that would still be true if you replaced the database, the UI framework, or the network library. By keeping the domain layer free of framework dependencies, you make it trivially testable and highly portable.

A use case (also called an interactor) encapsulates a single business operation:

class AddTaskUseCase(private val repository: TaskRepository) {
    suspend operator fun invoke(title: String): Result<Task> {
        if (title.isBlank()) return Result.failure(IllegalArgumentException("Title cannot be blank"))
        val task = Task(title = title.trim(), createdAt = Instant.now())
        return runCatching { repository.insert(task) }
    }
}

By overloading the invoke operator, the use case can be called like a function: addTaskUseCase("Buy milk"). This pattern makes use cases easy to mock in tests and easy to reason about — each one does exactly one thing.

4.5 Design Patterns in Application Development

Beyond layered architecture, several classical design patterns appear repeatedly in application development. These patterns are catalogued in the Gang of Four (GoF) book and are extensively analysed in CMU 17-214.

The Observer pattern (publish-subscribe) is the foundation of reactive UI. A subject maintains a list of observers and notifies them when its state changes. In Kotlin, this is idiomatically expressed via StateFlow or SharedFlow, which are observable streams that composables and ViewModels can collect from.

The Repository pattern provides a clean abstraction over data sources. Rather than letting the ViewModel know whether data comes from a local SQLite database, a remote REST API, or an in-memory cache, it asks a repository interface. The concrete implementation — which may combine multiple sources — is hidden behind the interface.

The Factory pattern decouples object creation from object use. In a dependency-injection context (using Hilt, Koin, or manual DI), factories are often generated automatically, but understanding the pattern helps you design injectable components correctly.

The Command pattern encapsulates an operation as an object, enabling undo/redo, queuing, and logging. In UI applications, user actions (button presses, text edits) can be modelled as command objects that carry both the operation and enough information to reverse it.


Chapter 5: Data Persistence and SQL

5.1 The Relational Model

The relational model, introduced by E. F. Codd in 1970, organises data into tables (relations), where each row represents an entity instance and each column represents an attribute. Relationships between tables are expressed through foreign keys — columns in one table that reference the primary key of another.

The relational model remains dominant for structured data because it supports powerful declarative queries, enforces integrity constraints, and provides ACID (Atomicity, Consistency, Isolation, Durability) transaction semantics. For most application development projects, a relational database is the right default choice.

ACID Transactions: A set of properties that guarantee reliable processing of database transactions. Atomicity: all operations in a transaction succeed or all fail. Consistency: a transaction brings the database from one valid state to another. Isolation: concurrent transactions do not interfere with each other. Durability: committed transactions survive system failures.

SQL (Structured Query Language) is the language for interacting with relational databases. The core operations are:

-- Retrieve all tasks belonging to a user, ordered by creation date
SELECT t.id, t.title, t.is_complete
FROM tasks t
INNER JOIN users u ON t.user_id = u.id
WHERE u.email = 'alice@example.com'
ORDER BY t.created_at DESC;

-- Insert a new task
INSERT INTO tasks (title, user_id, created_at) VALUES ('Buy milk', 42, NOW());

-- Update a task's completion status
UPDATE tasks SET is_complete = TRUE WHERE id = 7;

-- Delete a task
DELETE FROM tasks WHERE id = 7;

Understanding SQL directly — not just through an ORM — is essential for diagnosing performance problems, writing efficient queries, and understanding what your ORM is doing on your behalf.

5.2 JetBrains Exposed

Exposed is JetBrains’ own SQL framework for Kotlin. It provides two APIs: a DSL (Domain-Specific Language) API that maps directly to SQL constructs, and a DAO (Data Access Object) API that provides an Active Record-style interface. Both APIs operate over JDBC drivers, making Exposed compatible with SQLite, PostgreSQL, MySQL, and H2.

5.2.1 Exposed DSL API

The DSL API lets you write type-safe SQL in Kotlin:

object Tasks : Table("tasks") {
    val id = integer("id").autoIncrement()
    val title = varchar("title", 255)
    val isComplete = bool("is_complete").default(false)
    val userId = integer("user_id").references(Users.id)
    val createdAt = datetime("created_at")
    override val primaryKey = PrimaryKey(id)
}

// Query
transaction {
    Tasks.select { Tasks.isComplete eq false }
         .orderBy(Tasks.createdAt, SortOrder.DESC)
         .map { row -> Task(row[Tasks.id], row[Tasks.title]) }
}

// Insert
transaction {
    Tasks.insert {
        it[title] = "Buy milk"
        it[userId] = 42
        it[createdAt] = LocalDateTime.now()
    }
}

All database operations must occur within a transaction block, which Exposed wraps in a JDBC transaction. If the block throws an exception, the transaction is rolled back automatically.

5.2.2 Exposed DAO API

The DAO API provides a more object-oriented interface:

class TaskEntity(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<TaskEntity>(Tasks)
    var title by Tasks.title
    var isComplete by Tasks.isComplete
    var user by UserEntity referencedOn Tasks.userId
}

// Query
transaction {
    TaskEntity.find { Tasks.isComplete eq false }.toList()
}

// Create
transaction {
    TaskEntity.new {
        title = "Buy milk"
        isComplete = false
    }
}

The DSL API is better suited for complex queries and bulk operations; the DAO API is more convenient for simple CRUD operations and when you want Kotlin objects to closely mirror database rows.

5.3 Android Room

On Android, Room is the official persistence library. It is an annotation-based ORM built on top of SQLite. Room has three major components:

  • Entity: A Kotlin data class annotated with @Entity, representing a table.
  • DAO (Data Access Object): An interface annotated with @Dao containing query methods.
  • Database: An abstract class annotated with @Database that serves as the main access point.
@Entity(tableName = "tasks")
data class TaskEntity(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val title: String,
    val isComplete: Boolean = false
)

@Dao
interface TaskDao {
    @Query("SELECT * FROM tasks ORDER BY id DESC")
    fun observeAll(): Flow<List<TaskEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(task: TaskEntity)

    @Update
    suspend fun update(task: TaskEntity)

    @Delete
    suspend fun delete(task: TaskEntity)
}

@Database(entities = [TaskEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun taskDao(): TaskDao
}

Room performs all database access on a background thread by default (requiring suspend functions or returning Flow) and enforces that you never access the database on the main thread. It also validates your SQL queries at compile time — a major advantage over writing raw SQL strings.

Database migrations are essential for apps that are already deployed. When you change the schema (add a column, rename a table), you must provide a Migration object that upgrades existing databases from the old version to the new one. Failing to do this — or providing an incorrect migration — will cause crashes for users updating from an older version.


Chapter 6: Web Services and APIs

6.1 REST Principles

Representational State Transfer (REST) is an architectural style for building web services, articulated by Roy Fielding in his 2000 doctoral dissertation. A RESTful API exposes resources (nouns) identified by URLs, and operations on those resources are expressed using standard HTTP verbs.

The key HTTP verbs and their conventional semantics are:

VerbSemanticsIdempotent?
GETRetrieve a resource or collectionYes
POSTCreate a new resourceNo
PUTReplace a resource entirelyYes
PATCHPartially update a resourceNo
DELETERemove a resourceYes

Idempotent means that making the same request multiple times produces the same result as making it once. This property matters for reliability: if a network failure causes you to retry a request, you want to know whether retrying is safe.

HTTP status codes communicate the outcome of a request. The most important ranges are: 2xx (success), 4xx (client error — the request was malformed or unauthorised), and 5xx (server error — the server failed to process a valid request). Using these correctly is not just good practice; it enables generic error-handling middleware to behave correctly.

APIs exchange data in JSON (JavaScript Object Notation), a human-readable format with native representations of objects, arrays, strings, numbers, booleans, and null. In Kotlin, JSON serialisation is typically handled by the kotlinx.serialization library, which generates serialisers at compile time.

6.2 Building APIs with Ktor

Ktor is JetBrains’ asynchronous web framework for Kotlin. It is built on coroutines, making it non-blocking by default. A Ktor application is configured through a plugin system that adds features (routing, authentication, serialisation, logging) incrementally.

A minimal Ktor application:

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        install(ContentNegotiation) {
            json()
        }
        routing {
            tasksRoutes()
        }
    }.start(wait = true)
}

fun Route.tasksRoutes() {
    route("/tasks") {
        get {
            val tasks = taskRepository.findAll()
            call.respond(tasks)
        }
        post {
            val dto = call.receive<CreateTaskDto>()
            val task = addTaskUseCase(dto.title)
            call.respond(HttpStatusCode.Created, task)
        }
        put("/{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
                ?: return@put call.respond(HttpStatusCode.BadRequest)
            val dto = call.receive<UpdateTaskDto>()
            val updated = taskRepository.update(id, dto)
            call.respond(updated)
        }
        delete("/{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
                ?: return@put call.respond(HttpStatusCode.BadRequest)
            taskRepository.delete(id)
            call.respond(HttpStatusCode.NoContent)
        }
    }
}

Ktor’s routing DSL is an extension function-based API: Route.get, Route.post, etc. are extension functions on Route, which means routes can be organised into modular extension functions and assembled in the main application. This is a good example of how Kotlin extension functions enable internal DSLs that look like first-class language features.

Ktor also provides a client library for making HTTP requests from Kotlin applications, using the same plugin architecture as the server. This is useful for integrating with third-party APIs or for testing your own Ktor server endpoints.

6.3 Supabase as Backend-as-a-Service

Supabase is an open-source Backend-as-a-Service (BaaS) built on top of PostgreSQL. It provides a hosted database with an auto-generated REST and realtime API, authentication, file storage, and edge functions — all accessible through client libraries for Kotlin/Android, JavaScript, Swift, and other platforms.

Backend-as-a-Service (BaaS): A cloud service model in which the provider manages server-side infrastructure, databases, authentication, and APIs, allowing developers to focus entirely on client-side and business logic code.

For CS 346 projects, Supabase can serve as the backend for a team application without requiring the team to deploy and maintain their own server. The Kotlin client library (supabase-kt) provides idiomatic Kotlin APIs for all Supabase capabilities:

// Querying the database
val tasks = supabase.postgrest["tasks"]
    .select()
    .decodeList<Task>()

// Inserting a record
supabase.postgrest["tasks"].insert(Task(title = "Buy milk"))

// Realtime subscription
supabase.realtime.createChannel("tasks-changes")
    .postgresChangeFlow<PostgresAction>(schema = "public") {
        table = "tasks"
    }
    .onEach { change -> /* handle change */ }
    .launchIn(viewModelScope)

Supabase’s Row Level Security (RLS) policies are SQL expressions that run on every query, restricting which rows a user can see or modify based on their identity. This allows you to enforce access control at the database level, ensuring that even a bug in your application cannot expose another user’s data.


Chapter 7: Concurrency and Asynchronous Programming

7.1 The Problem with Threads

Concurrency is the practice of making progress on multiple tasks within the same time period. It is necessary for responsive applications: a user interface that blocks its thread waiting for a network response will freeze, and a server that blocks a thread per request will exhaust its thread pool under load.

The traditional solution — creating operating-system threads — has significant costs. Each thread consumes stack memory (typically 256 KB to 1 MB) and incurs context-switching overhead. A server handling ten thousand simultaneous requests with one thread per request would require gigabytes of memory just for thread stacks. Shared mutable state across threads introduces race conditions — bugs that are notoriously difficult to reproduce and debug because they depend on precise interleaving of execution.

7.2 Kotlin Coroutines

Kotlin coroutines are lightweight concurrency primitives that allow you to write asynchronous code in a sequential style. A coroutine is a computation that can be suspended at well-defined points (at suspend function calls) and resumed later, without blocking the underlying thread. Thousands of coroutines can be multiplexed onto a small pool of threads with negligible overhead.

A function marked suspend can only be called from another suspend function or from a coroutine. The suspend keyword is a compiler marker that transforms the function into a state machine — the bytecode that Kotlin generates handles saving and restoring the function’s local variables at suspension points.

The primary coroutine builders are:

  • launch: starts a coroutine and returns a Job (a handle for cancellation). Fire-and-forget.
  • async: starts a coroutine and returns a Deferred<T> — a future result. Await with .await().
  • runBlocking: bridges blocking and coroutine worlds; blocks the current thread until the coroutine completes. Mainly for tests and main functions.
// In a ViewModel (viewModelScope is automatically cancelled when ViewModel is destroyed)
fun loadData() {
    viewModelScope.launch {
        val result = withContext(Dispatchers.IO) {
            repository.fetchFromNetwork()  // blocking network call, off the main thread
        }
        _uiState.value = result  // back on the main thread
    }
}

// Parallel decomposition with async/await
viewModelScope.launch {
    val usersDeferred = async(Dispatchers.IO) { userRepository.findAll() }
    val tasksDeferred = async(Dispatchers.IO) { taskRepository.findAll() }
    val users = usersDeferred.await()
    val tasks = tasksDeferred.await()
    // both fetches ran concurrently
}

7.3 Dispatchers and Structured Concurrency

Dispatchers control which thread or thread pool a coroutine runs on:

DispatcherUse
Dispatchers.MainAndroid main thread; UI updates
Dispatchers.IOI/O-bound work (network, file, database)
Dispatchers.DefaultCPU-bound work (sorting, image processing)
Dispatchers.UnconfinedNo specific thread; rarely used directly

Structured concurrency is the principle that coroutines are always launched in a scope, and the scope defines the lifetime of all coroutines within it. If the scope is cancelled (e.g., because the ViewModel is destroyed), all coroutines in that scope are cancelled automatically. This prevents the coroutine leak problem that was common with raw threads: coroutines that outlive their owner and continue consuming resources or posting updates to a dead UI.

Cancellation in Kotlin coroutines is cooperative: a coroutine is not forcibly killed, but it receives a CancellationException at the next suspension point. Properly written suspend functions (including all standard library functions) respond to cancellation promptly. Long-running CPU-bound loops should periodically call yield() or check isActive to support cancellation.

7.4 StateFlow, SharedFlow, and Kotlin Flow

Kotlin Flow is a cold, asynchronous stream — it produces values lazily when it is collected, and each collector gets its own independent stream. Flow is built on coroutines and supports the full operator vocabulary of reactive programming (map, filter, flatMapLatest, combine, debounce, etc.).

// A flow that emits one value per second
val ticker: Flow<Int> = flow {
    var i = 0
    while (true) {
        emit(i++)
        delay(1000)
    }
}

// Collecting a flow
viewModelScope.launch {
    ticker
        .take(10)
        .filter { it % 2 == 0 }
        .collect { value -> println(value) }
}

StateFlow is a hot, state-holding flow that always has a current value and replays the latest value to new collectors. It is the idiomatic way to expose UI state from a ViewModel. SharedFlow is a more general hot flow that can replay a configurable number of past values and can be used for one-time events.

The distinction between cold and hot flows matters for UI programming. A cold flow (plain Flow) starts producing values only when collected, and multiple collectors each trigger independent executions of the producer. A hot flow (StateFlow, SharedFlow) produces values regardless of whether anyone is collecting, and multiple collectors share the same stream of values.

In Compose, you collect a StateFlow using collectAsState(), which subscribes to the flow in a lifecycle-aware way and provides the current value as Compose state, triggering recomposition when it changes.


Chapter 8: Testing Strategies

8.1 Why Test?

Testing is the practice of systematically verifying that a software system behaves as intended. The case for testing is not merely that it catches bugs — although it does. More importantly, a comprehensive test suite enables confident refactoring: you can restructure code to improve its design without fear of silently breaking existing behaviour. This is the key connection between testing and the changeability goal from MIT 6.031.

The test pyramid is a conceptual model for the distribution of tests in a healthy project. The base of the pyramid consists of many fast, cheap unit tests that verify individual functions and classes in isolation. The middle layer consists of integration tests that verify that multiple components work together correctly. The apex consists of few, slow, expensive end-to-end tests that verify complete user workflows. Inverting the pyramid — relying heavily on end-to-end tests and neglecting unit tests — produces slow, brittle test suites that give little guidance when they fail.

8.2 Unit Testing with JUnit and Kotest

JUnit 5 (Jupiter) is the standard unit testing framework for JVM languages. Tests are Kotlin functions annotated with @Test:

class TaskUseCaseTest {

    private val fakeRepository = FakeTaskRepository()
    private val useCase = AddTaskUseCase(fakeRepository)

    @Test
    fun `adding a task with a valid title succeeds`() {
        val result = runBlocking { useCase("Buy milk") }
        assertTrue(result.isSuccess)
        assertEquals("Buy milk", result.getOrThrow().title)
    }

    @Test
    fun `adding a task with a blank title fails`() {
        val result = runBlocking { useCase("  ") }
        assertTrue(result.isFailure)
    }
}

Kotlin’s backtick syntax for function names allows test names to be full English sentences, which dramatically improves the readability of test reports.

Kotest is a Kotlin-native testing framework with a richer set of test styles (string spec, behaviour spec, describe spec) and a powerful assertion library:

class TaskSpec : StringSpec({
    "a task created with a valid title should have that title" {
        val task = Task(title = "Review PR")
        task.title shouldBe "Review PR"
    }

    "tasks should be equal if they have the same id" {
        val t1 = Task(id = 1, title = "A")
        val t2 = Task(id = 1, title = "B")
        t1 shouldBe t2  // if equals is implemented based on id
    }
})

8.3 Test Doubles

A test double is an object that stands in for a real dependency during testing. The taxonomy of test doubles, from Gerard Meszaros’ xUnit Test Patterns:

Test Double Types:
  • Dummy: an object that is passed around but never actually used (e.g., to satisfy a constructor parameter).
  • Stub: an object that provides canned answers to calls made during the test, without any logic.
  • Fake: a working implementation that takes shortcuts not suitable for production (e.g., an in-memory repository instead of a database).
  • Mock: an object that records calls made to it and can verify that expected interactions occurred.

In Kotlin, fakes are often the most useful test double. An in-memory implementation of a repository is simple to write, fully functional within tests, and does not require a mocking framework:

class FakeTaskRepository : TaskRepository {
    private val tasks = mutableListOf<Task>()

    override suspend fun insert(task: Task): Task {
        val saved = task.copy(id = tasks.size + 1)
        tasks.add(saved)
        return saved
    }

    override suspend fun findAll(): List<Task> = tasks.toList()

    override fun observeTasks(): Flow<List<Task>> = flow {
        emit(tasks.toList())
    }
}

MockK is the idiomatic Kotlin mocking library. It integrates well with coroutines and Kotlin’s language features:

val mockRepo = mockk<TaskRepository>()
coEvery { mockRepo.insert(any()) } returns Task(id = 1, title = "Buy milk")
coVerify { mockRepo.insert(match { it.title == "Buy milk" }) }

8.4 Integration Testing and Test-Driven Development

Integration tests verify that multiple components work together. They are slower than unit tests but catch a different class of bugs — mismatches in assumptions between components that are individually correct. For database code, integration tests typically spin up an in-memory H2 database, run the schema setup, and execute real queries through the DAO layer.

Test-Driven Development (TDD) is a practice in which tests are written before the production code they test. The cycle is: write a failing test (Red), write the minimum code to make it pass (Green), refactor for clarity and design (Refactor). TDD advocates argue that it produces more focused, modular code because you are forced to think about the interface before the implementation. Even if you do not follow strict TDD, the discipline of asking “how will I test this?” before writing code improves design decisions.


Chapter 9: Packaging, Deployment, and Kotlin Multiplatform

9.1 Packaging Applications

Before a Kotlin application can be distributed, it must be packaged into a form that end users or servers can run. The appropriate packaging format depends on the target platform.

For server-side Kotlin (e.g., a Ktor API server), the standard packaging is a fat JAR (also called an uber JAR) — a single .jar file that includes the application’s compiled classes and all of its dependencies. Gradle produces this with the shadow plugin:

// build.gradle.kts
plugins {
    id("com.github.johnrengelman.shadow") version "8.1.1"
}

tasks.shadowJar {
    manifest {
        attributes["Main-Class"] = "com.example.MainKt"
    }
}

Running the fat JAR requires only a Java runtime: java -jar app.jar. This simplicity makes fat JARs the most portable form of JVM application distribution.

For desktop applications built with Compose for Desktop, the compose.desktop Gradle plugin provides native packaging — generating .msi installers on Windows, .dmg disk images on macOS, and .deb/.rpm packages on Linux. These packages bundle the JVM runtime, so end users do not need to install Java separately.

9.2 Containerisation with Docker

Docker enables applications to be packaged with their entire runtime environment — OS libraries, configuration, dependencies — into a portable container image. Containers run identically on any machine with Docker installed, eliminating the “works on my machine” problem.

A Dockerfile for a Ktor application:

FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY build/libs/app-all.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Best practices for production Docker images include: using a small base image (Alpine-based images are much smaller than their full Debian counterparts), using multi-stage builds to avoid including build tools in the final image, and running the application as a non-root user.

Docker Compose allows you to define and run multi-container applications. A web application stack might include the Ktor server, a PostgreSQL database, and a reverse proxy, all defined in a single docker-compose.yml file and started with docker compose up.

9.3 CI/CD Basics

Continuous Integration (CI) is the practice of automatically building and testing every code change as it is pushed to the repository. Continuous Deployment (CD) extends this to automatically deploying passing builds to a staging or production environment.

GitLab CI/CD is configured through a .gitlab-ci.yml file at the root of the repository:

stages:
  - build
  - test
  - deploy

build:
  stage: build
  image: gradle:8.5-jdk17
  script:
    - gradle shadowJar
  artifacts:
    paths:
      - build/libs/*.jar

test:
  stage: test
  image: gradle:8.5-jdk17
  script:
    - gradle test

CI pipelines catch integration failures early — before they reach the main branch — and provide a consistent, reproducible build environment that is not subject to differences in individual developers’ machines.

9.4 Kotlin Multiplatform

Kotlin Multiplatform (KMP) allows a single Kotlin codebase to compile to multiple targets: JVM (Android, server), JavaScript (web), and native (iOS, macOS, Windows, Linux). The key mechanism is expect/actual declarations, which define a platform-agnostic interface (expect) and a platform-specific implementation (actual).

// commonMain — shared code
expect class Platform() {
    val name: String
}

// androidMain — Android implementation
actual class Platform actual constructor() {
    actual val name: String = "Android ${android.os.Build.VERSION.SDK_INT}"
}

// iosMain — iOS implementation
actual class Platform actual constructor() {
    actual val name: String = UIDevice.currentDevice.systemName()
}

In practice, teams share as much business logic as possible in commonMain — domain entities, use cases, repository interfaces, serialisation models — and confine platform-specific code (UI, sensors, file access) to the platform-specific source sets. This maximises code reuse without sacrificing native platform integration.

Compose Multiplatform, also from JetBrains, extends Jetpack Compose to desktop and (experimentally) iOS, enabling shared UI code as well. For CS 346 projects, this means that a single Compose UI can run on both Android and the JVM desktop with minimal adaptation.

KMP vs Cross-Platform Alternatives: KMP differs from frameworks like React Native or Flutter in that it compiles to native code for each platform rather than running in a common runtime. Business logic in KMP achieves native performance and can interoperate with platform APIs directly. UI sharing via Compose Multiplatform is optional — teams can also write native UI per platform while sharing all other code.

The practical value of KMP for a CS 346 project team is significant. If the application has an Android client and a desktop client, KMP allows the data layer (repositories, network clients, database access) to be implemented once and shared between both clients, reducing duplication and ensuring behavioural consistency across platforms.

9.5 Putting It All Together: A Full-Stack Architecture

A well-architected CS 346 project might look like this:

ModuleTechnologyLayer
:shared:domainPure Kotlin (KMP)Domain
:shared:dataExposed / Room (per platform)Data
:serverKtor + Exposed + PostgreSQLBackend
:androidJetpack Compose + RoomPresentation
:desktopCompose for DesktopPresentation

The :shared:domain module — containing entities, use cases, and repository interfaces — has no dependencies on Android, Ktor, or any database library. It can be unit-tested in isolation on the JVM with a fake repository. The :server module depends on :shared:domain and provides concrete data access via Exposed; the :android and :desktop modules also depend on :shared:domain and provide their own data access (Room on Android, Exposed on desktop).

This architecture exemplifies Clean Architecture’s dependency rule: the domain knows nothing about the outer layers. It also exemplifies the MVVM pattern: each client module has ViewModels that depend on domain use cases and expose StateFlow to the UI layer. And it exemplifies the testing pyramid: the domain layer and ViewModels are covered by fast, isolated unit tests, while the data layer and API routes are covered by integration tests.

The journey from a blank repository to a deployed, multi-platform application is long, and it involves every concept introduced in this course: collaborative development processes, requirements engineering, Kotlin language features, UI with Compose, architecture patterns, domain modelling, database persistence, REST APIs, asynchronous programming, testing discipline, and finally packaging and deployment. Each piece reinforces the others, and the whole is greater than the sum of its parts.

Back to top