Domain-Driven Design on Android - From Buzzword to Working Architecture
12 Mar 2026Domain-Driven Design (DDD) is frequently mentioned in Android projects, conference talks, and architectural diagrams. In practice, however, it is rarely applied. What most Android teams call âDDDâ is usually a mix of MVVM, Clean Architecture, and some renamed packages. The result often looks organized but still couples UI, frameworks, and business rules in ways that make the system hard to test and harder to evolve.
This article is opinionated: DDD is absolutely viable on Android, but only if you stop treating it as a layering exercise and start treating it as a modeling discipline. The goal is not prettier diagrams; the goal is portable, testable business logic that survives UI rewrites and platform changes.
Below is what actually applying DDD on Android looks like, and what you can do, concretely, to improve your project.
The Core Problem: Android Projects Fake DDD
Most Android codebases suffer from three recurring issues:
- Primitive obsession: IDs, money, dates, and states are passed around as
String,Int, orDouble. - Business rules in ViewModels: Validation, state transitions, and invariants live next to UI state.
- Aggregates ignored: Entities are mutated freely without enforcing consistency boundaries.
Teams say âwe use DDD,â but the domain layer is either anemic or nonexistent.
Real DDD on Android means:
- The domain layer owns the rules
- The UI is a dumb consumer
- The domain is framework-free
Correct Usage #1: Value Objects (Stop Passing Primitives)
If your domain methods accept String, Int, or Double directly, you are not modeling a domain; you are passing data around.
Bad (Typical Android Code)
fun placeOrder(
userId: String,
totalPrice: Double,
currency: String
)
Nothing here enforces correctness:
- Is
totalPriceallowed to be negative? - Is
currencyvalid? - Is
userIdempty?
All validation is delayed, or forgotten.
Good: Explicit Value Objects
@JvmInline
value class UserId(val value: String) {
init {
require(value.isNotBlank())
}
}
@JvmInline
value class Money(val amount: BigDecimal) {
init {
require(amount >= BigDecimal.ZERO)
}
}
Now your domain API becomes:
fun placeOrder(
userId: UserId,
total: Money
)
Why This Matters
- Invalid states are unrepresentable
- Validation happens once
- Tests become trivial
- Bugs stop leaking across layers
Actionable improvement:
Search your domain code for String, In``t, and Double. Every time one represents a concept, replace it with a value object.
Correct Usage #2: Aggregates Enforced in the Domain Layer
In many Android apps, entities are treated like mutable bags of data. Repositories expose them, ViewModels mutate them, and invariants are violated silently.
That is not DDD.
What an Aggregate Actually Is
An aggregate is:
- A consistency boundary
- A root entity that controls all modifications
- The only entry point for state changes
Example: Order Aggregate
class Order(
val id: OrderId,
private val items: MutableList<OrderItem>,
private var status: OrderStatus
) {
fun addItem(item: OrderItem) {
items.add(item)
require(status == OrderStatus.DRAFT)
}
fun confirm() {
require(items.isNotEmpty())
status = OrderStatus.CONFIRMED
}
}
Notice:
- No setters
- State transitions are explicit
- Rules live inside the aggregate
Repository Returns the Aggregate
interface OrderRepository {
fun load(orderId: OrderId): Order
fun save(order: Order)
}
The UI never mutates items directly. The ViewModel asks the aggregate to do things.
Actionable improvement: If your entities have public setters or mutable fields accessed from outside the domain layer, your aggregates are broken.
Correct Usage #3: UI Unaware of Business Rules
ViewModels are not the domain layer. They are orchestration code.
If your ViewModel contains logic like:
- âIf status is X, user can do Yâ
- âValidate input before savingâ
- âCalculate totalsâ
You have already lost.
Bad ViewModel Example
fun onConfirmClicked() {
if (items.isEmpty()) {
showError("Order is empty")
return
}
order.status = CONFIRMED
}
This logic is business logic disguised as UI logic.
Correct Approach: Use Cases (Application Services)
class ConfirmOrderUseCase(
private val repository: OrderRepository
) {
fun execute(orderId: OrderId) {
val order = repository.load(orderId)
order.confirm()
repository.save(order)
}
}
ViewModel Becomes Thin
fun onConfirmClicked() {
confirmOrderUseCase.execute(currentOrderId)
}
The ViewModel:
- Does not know why confirmation might fail
- Does not enforce rules
- Only reacts to outcomes
Actionable improvement: Delete business logic from ViewModels. If a rule affects multiple screens, it does not belong in the UI layer.
Testing Becomes Trivial (And Fast)
Once business rules live in the domain, tests stop depending on Android.
Domain Test Example
@Test
fun `cannot confirm empty order`() {
val order = Order(
id = OrderId("123"),
items = mutableListOf(),
status = OrderStatus.DRAFT
)
assertFailsWith<IllegalStateException> {
order.confirm()
}
}
No:
- Instrumentation tests
- Robolectric
- Mocked ViewModels
Just Kotlin and JUnit.
Actionable improvement: If your tests require Android runtime to verify business rules, your architecture is wrong.
The Real Benefit: Portability
When done correctly, your domain layer:
- Has zero Android imports
- Can run on JVM, backend, or another platform
- Survives UI rewrites (XML â Compose â whatever comes next)
This is not theoretical. Teams that apply DDD properly often:
- Share domain logic between Android and backend
- Rebuild UI layers without touching core rules
- Catch bugs earlier with fast unit tests
Practical Migration Strategy
You do not need a rewrite:
- Start with value objects: Replace primitives in new code first.
- Move rules inward: Every time you see validation in UI, move it to the domain.
- Lock down aggregates: Remove setters. Expose behavior, not state.
- Enforce boundaries: Domain must not depend on Android, Retrofit, Room, or Compose.
Closing Points
Most Android projects claim to use DDD and do not. They adopt the vocabulary without the discipline. The fix is not another architecture diagram; it is relentless enforcement of modeling rules.
If your UI knows business rules, your domain is anemic. If your domain passes primitives, it is not a model. If your business logic is hard to test, your architecture is lying to you.
Apply DDD properly, and Android becomes just another delivery mechanism, not the center of your system.