Two Kotlin terms sound almost identical, show up in the same code reviews, and get mixed up in the same Slack threads. They also solve completely different problems.

  • A backing field decides how a single property stores its own value.
  • A backing property decides how a piece of internal state is exposed to everyone else.

If you can say that out loud without hesitating, you are ahead of a surprising number of codebases. This post goes deeper than the definitions: idiomatic patterns for each, the production failures I would flag in review, and a small decision rule you can apply in seconds.

backing-field-vs-property-overview

Part 1: Backing fields, the hidden storage slot

When you declare a property, Kotlin generates the storage for you, but only if the property actually needs it. You touch that storage through the field identifier, and only inside that property's own getter or setter.

class UserProfile { var handle: String = "guest" set(value) { val cleaned = value.trim().lowercase() require(cleaned.isNotEmpty()) { "handle must not be blank" } field = cleaned } } 

From the outside, handle is a plain String. Callers cannot tell that a setter exists, and the public type did not change. All the logic lives next to the data it protects.

backing-field-storage-flow

When a backing field is the right tool

Reach for it when all of these are true:

  • The rule belongs to the property itself (trim, clamp, normalize, validate).
  • The public type is the same as the stored type.
  • You do not want a second, separately named variable in the class.

Clamping is another natural fit:

class Player { var volume: Int = 50 set(value) { field = value.coerceIn(0, 100) } } 

A property does not always have a field

The compiler only generates a backing field if your code uses the default accessor or references field. A property computed from other members has no storage at all:

class Playlist(private val tracks: List<Track>) { val isEmpty: Boolean get() = tracks.isEmpty() // no backing field generated } 

This is worth knowing in reviews. A get() = ... with no field is a computation, not storage, so there is nothing to validate or drift out of sync.

Backing field failure modes

1. The self-referencing setter. Assigning to the property name inside its own setter calls the setter again.

var handle: String = "guest" set(value) { handle = value.trim() // calls set() again, forever } 

Kotlin does not reject this at compile time. I compiled and ran this exact snippet with Kotlin 2.1.20: it builds without a compiler error, and the first assignment throws StackOverflowError. Depending on your compiler and IDE, you may see a recursive-accessor warning or inspection, which is easy to miss in a noisy build. The fix is always field = ... inside accessors.

2. The initializer skips the setter. The value you write after = in the declaration is assigned straight to the field. Your validation does not run on it.

var handle: String = " Ada " // stored as-is: spaces and capitals intact set(value) { field = value.trim().lowercase() } 

If the initial value must obey the same rules, route it through the setter explicitly:

class UserProfile(initialHandle: String) { var handle: String = "" set(value) { val cleaned = value.trim().lowercase() require(cleaned.isNotEmpty()) { "handle must not be blank" } field = cleaned } init { handle = initialHandle // goes through the setter } } 

Note the placeholder "": for a moment it exists only because a field-backed property needs an initializer. Keep that window invisible to callers.

3. Reflection-based tooling ignores your invariants. Some deserializers and mappers populate fields directly instead of calling setters. When that happens, your cleaning and validation can be skipped and a state you “made impossible” shows up anyway. If an invariant matters, enforce it where objects enter the system (a factory, a constructor, a mapper you control) and do not rely solely on a setter.

Part 2: Backing properties, controlled exposure

Now the other problem. Your class needs mutable internal state, but nobody outside should be able to mutate it. A backing property is a pair of properties: a private mutable one and a public read-only one that exposes it.

data class CartUiState( val items: List<CartItem> = emptyList(), val isCheckingOut: Boolean = false, val errorMessage: String? = null, ) class CartViewModel( private val repository: CartRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(CartUiState()) val uiState: StateFlow<CartUiState> = _uiState.asStateFlow() fun onItemAdded(item: CartItem) { _uiState.update { current -> current.copy(items = current.items + item) } } } 

The UI collects uiState. It has no route to _uiState, so every change is forced through a function you own.

backing-property-exposure

On the Compose side, consumption stays boring, which is the point:

@Composable fun CartScreen(viewModel: CartViewModel = viewModel()) { val state by viewModel.uiState.collectAsStateWithLifecycle() CartContent( state = state, onItemAdded = viewModel::onItemAdded, ) } 

Why this matters in unidirectional data flow

  • State changes happen in one place. Searching for writes means searching one class.
  • Consumers get a smaller API. Observing is possible, mutating is not.
  • Mutation is an implementation detail. You can change how state is produced without touching a single screen.
  • Ownership is explicit. The class that holds _uiState is the owner, full stop.

A realistic async update

State rarely changes synchronously. Here is a checkout that handles loading, success and failure, and handles cancellation correctly:

fun onCheckoutClicked() { viewModelScope.launch { _uiState.update { it.copy(isCheckingOut = true, errorMessage = null) } try { repository.checkout(_uiState.value.items) _uiState.value = CartUiState() } catch (e: CancellationException) { throw e } catch (e: Exception) { _uiState.update { it.copy(isCheckingOut = false, errorMessage = e.message ?: "Checkout failed") } } } } 

The rethrow of CancellationException is deliberate. Swallowing it (which runCatching does by default) breaks structured concurrency: a cancelled screen keeps "failing" a coroutine that was supposed to stop quietly.

Backing property failure modes

1. The mutable type leaks. This is the classic mistake with collections:

private val _items = mutableListOf<CartItem>() val items: List<CartItem> get() = _items // same instance, only the static type is narrower 

The compile-time type is read-only, but the object is not. A cast gets you the mutable list, and iterating it on one thread while another writes can throw ConcurrentModificationException. Prefer immutable state (a data class holding a List, replaced on every change), or return a defensive copy with _items.toList().

The same logic applies to flows. Exposing the mutable flow directly as the read-only type works at compile time:

val uiState: StateFlow<CartUiState> = _uiState // a cast can still reach the mutable flow 

asStateFlow() returns a read-only view, so a cast cannot recover mutability.

2. Lost updates. This pattern looks harmless and is not atomic:

_uiState.value = _uiState.value.copy(isCheckingOut = true) 

Two coroutines can read the same snapshot, each apply their change, and the second write silently erases the first. update { } retries with a compare-and-set loop, so concurrent writers compose correctly. Treat bare .value = ...copy(...) in shared state as a review comment.

3. Two sources of truth. The moment you keep a mutable field and a separate flow for something derivable, they can disagree. Derive instead of duplicating:

data class CartUiState(val items: List<CartItem> = emptyList() /* ... */) { val totalCents: Long get() = items.sumOf { it.priceCents * it.quantity } } 

When the derived value needs other streams, build it with combine and stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initial) rather than a second hand-maintained mutable.

Bonus: a getter that allocates. This compiles and quietly creates a new wrapper on every access:

val uiState: StateFlow<CartUiState> get() = _uiState.asStateFlow() // new object per read 

Use an initializer (= _uiState.asStateFlow()) so the read-only view is created once.

failure-modes-at-a-glance

Testing both

Backing field behavior is a plain unit test of the property contract:

class UserProfileTest { @Test fun `handle is normalized on assignment`() { val profile = UserProfile(initialHandle = " Ada_L ") assertEquals("ada_l", profile.handle) } @Test fun `blank handle is rejected`() { val profile = UserProfile(initialHandle = "ada") assertFailsWith<IllegalArgumentException> { profile.handle = " " } } } 

Backing property behavior is tested through the public API only, which doubles as a check that you did not need to widen visibility:

@Test fun `adding an item updates state`() = runTest { val viewModel = CartViewModel(FakeCartRepository()) viewModel.onItemAdded(CartItem(id = "sku-1", priceCents = 1_200, quantity = 1)) assertEquals(1, viewModel.uiState.value.items.size) } 

If a test needs to reach _uiState to set up state, that is a design signal: add a constructor parameter or a seam instead.

A note on explicit backing fields

The underscore-pair pattern is a convention, and Kotlin has been prototyping language support for explicit backing fields, which would let a property declare a private mutable field with a different public type in one place. At the time of writing, my information is that it is gated behind an experimental flag. Check the current Kotlin release notes before depending on it. Until it is stable and your toolchain supports it, the backing property pair remains the idiomatic approach.

The decision rule

which-one-decision-guide

Ask two questions, in this order:

  1. Does outside code need a different type or less mutability than your internal state? Use a backing property.
  2. Does the property need to validate or normalize its own value? Use a backing field.

If both answers are no, a plain val or var is the correct and boring choice. Do not add machinery that the design does not need.

🔚 Takeaways

  • Backing field → customize storage. Same type in and out, logic lives in the accessor, and watch for initializers and reflection bypassing it.
  • Backing property → control exposure. Mutable inside, read-only outside, and make sure the object itself (not just the declared type) is actually safe to share.
  • Use update { } for state transitions, derive instead of duplicating, and never swallow CancellationException.

Short version: field is about what happens when a value goes in. Property is about who is allowed to touch it afterward.

📱 Crack Your Next Android Interview

If you’re targeting a senior Android role at a FAANG or top-tier MNC, syntax knowledge alone won’t get you through the loop. I highly recommend “Cracking Top MNC Android Interviews” — a senior-level playbook covering framework internals, Jetpack Compose, coroutines, Android-specific system design, and DSA reframed around real mobile constraints, to help you walk into bar-raiser rounds ready.

Originally published on Medium.