Domain-Driven Design (DDD) and modern MVI architectures have much in common in their approaches to organizing business logic. In this article, we'll explore how SimpleMVI naturally supports the core concepts of DDD and helps create a clean, well-structured domain architecture.
You can read more about SimpleMVI in my previous article: https://hackernoon.com/this-tiny-kotlin-library-might-be-the-cleanest-way-to-build-cross-platform-apps
Introduction
What is Domain-Driven Design
Domain-Driven Design is an approach to developing complex software proposed by Eric Evans in his eponymous book. DDD focuses on creating software systems that reflect the real subject domain of the business.
Key principles of DDD:
- Ubiquitous Language — using a unified language for communication between developers and domain experts
- Bounded Context — clear boundaries within which a model has a specific meaning
- Focus on Core Domain — concentration of efforts on key business logic
- Model-Driven Design — code should reflect the domain model
Main tactical patterns of DDD:
- Entity — an object with identity that passes through various states
- Value Object — an immutable object without identity
- Aggregate — a cluster of related objects with a single entry point (Aggregate Root)
- Domain Event — an event that is significant to the business
- Repository — an abstraction for accessing aggregates
- Domain Service — operations that don't belong to a specific entity
Why SimpleMVI is a Natural Fit for DDD
SimpleMVI is initially designed with principles that align perfectly with the DDD philosophy:
1. Business Logic Isolation
DDD requires clear separation of domain logic from infrastructure details. SimpleMVI ensures this through:
- Actor contains pure business logic without dependencies on UI or infrastructure
- Store encapsulates the domain model and is the single point of access to it
- Strict separation of responsibilities between components
// Business logic is isolated in the Actor
class OrderActor : DefaultActor<OrderIntent, OrderState, OrderSideEffect>() {
override fun handleIntent(intent: OrderIntent) {
// Only domain logic, no UI or infrastructure
when (intent) {
is OrderIntent.PlaceOrder -> validateAndPlaceOrder(intent)
}
}
}
2. Immutable State
DDD strives for predictability and consistency of the model. SimpleMVI ensures this through:
- Immutable State that cannot be modified directly
- All changes occur through explicit commands (Intent)
- Guarantee of aggregate integrity with each change
// State is always immutable and consistent
data class OrderState(
val orderId: OrderId,
val items: List<OrderItem>,
val status: OrderStatus
) {
// Invariants are checked during creation
init {
require(items.isNotEmpty() || status == OrderStatus.DRAFT) {
"Order must have items unless it's in DRAFT status"
}
}
}
3. Explicit Commands and Events
DDD distinguishes between commands (what we want to do) and events (what happened). SimpleMVI naturally supports this concept:
- Intent represents commands
- SideEffect represents domain events
- Clear separation between intentions and facts
// Command (what we want to do)
sealed interface OrderIntent {
data class PlaceOrder(val items: List<Item>) : OrderIntent
}
// Event (what happened)
sealed interface OrderSideEffect {
data class OrderPlaced(val orderId: OrderId) : OrderSideEffect
}
4. Aggregate Root as Store
In DDD, Aggregate Root is the single entry point for working with an aggregate. The Store in SimpleMVI serves exactly the same function:
- All operations go through the Store
- Store guarantees transactional integrity
- Encapsulation of the internal structure of the aggregate
// Store as Aggregate Root
class OrderStore : Store<OrderIntent, OrderState, OrderSideEffect> {
// Single entry point for all operations with the order
override fun accept(intent: OrderIntent) {
// Integrity guarantee during processing
}
}
5. Support for Ubiquitous Language
SimpleMVI does not impose technical terms and allows the use of domain language:
- Intent, State, SideEffect names can reflect business terms
- No mandatory suffixes or prefixes
- Code reads like a description of the business process
// Code uses business language, not technical terms
sealed interface ShoppingCartIntent {
data class AddProduct(val product: Product) : ShoppingCartIntent
data class RemoveProduct(val productId: ProductId) : ShoppingCartIntent
data object Checkout : ShoppingCartIntent
}
6. Explicit Lifecycle
DDD implies managing the lifecycle of aggregates. SimpleMVI provides:
- Explicit
init()
anddestroy()
methods - Control over the creation and destruction of aggregates
- Possibility of resource release
7. Testability
DDD places great importance on testing business logic. SimpleMVI ensures:
- Isolated business logic in Actor
- Deterministic behavior
- Simplicity of writing unit tests
@Test
fun `order cannot be placed without items`() {
val store = OrderStore()
store.init()
store.accept(OrderIntent.PlaceOrder(emptyList()))
assertTrue(store.sideEffects.first() is OrderSideEffect.ValidationFailed)
}
All these features make SimpleMVI not just compatible with DDD, but practically an ideal tool for implementing domain-oriented design in modern Kotlin applications.
Key Implementation Principles
Store as Aggregate Root with Business Logic in Actor
In DDD, Aggregate Root is the entry point for all operations with an aggregate. The Store in SimpleMVI plays exactly the same role:
class OrderStore : Store<OrderStore.Intent, OrderStore.State, OrderStore.SideEffect> by createStore(
name = storeName<OrderStore>(),
initialState = State.Empty,
actor = OrderActor()
) {
// Commands (Intents)
sealed interface Intent {
data class Create(val customerId: CustomerId) : Intent
data class AddItem(val productId: ProductId, val quantity: Int) : Intent
data object Complete : Intent
data object Cancel : Intent
}
// Aggregate State
data class State(
val orderId: OrderId?,
val customerId: CustomerId?,
val items: List<OrderItem>,
val status: OrderStatus,
val totalAmount: Money
) {
companion object {
val Empty = State(
orderId = null,
customerId = null,
items = emptyList(),
status = OrderStatus.DRAFT,
totalAmount = Money.ZERO
)
}
}
// Domain Events
sealed interface SideEffect {
data class OrderCreated(val orderId: OrderId) : SideEffect
data class ItemAdded(val productId: ProductId, val quantity: Int) : SideEffect
data class OrderCompleted(val orderId: OrderId) : SideEffect
data class OrderCancelled(val orderId: OrderId) : SideEffect
// Error events
data class ValidationFailed(val reason: String) : SideEffect
}
}
Modeling Value Objects and Entities in State
DDD distinguishes Value Objects and Entities. In SimpleMVI, both types are represented as part of the State:
// Value Objects
data class Money(
val amount: BigDecimal,
val currency: Currency
) {
companion object {
val ZERO = Money(BigDecimal.ZERO, Currency.USD)
}
}
data class ProductId(val value: String)
data class CustomerId(val value: String)
data class OrderId(val value: String)
// Entity
data class OrderItem(
val itemId: String, // identity
val productId: ProductId,
val quantity: Int,
val unitPrice: Money,
val totalPrice: Money
)
// Enum for status
enum class OrderStatus {
DRAFT,
CONFIRMED,
COMPLETED,
CANCELLED
}
Handling Domain Events via SideEffect
SideEffects in SimpleMVI are perfect for representing domain events:
class OrderActor : DefaultActor<OrderStore.Intent, OrderStore.State, OrderStore.SideEffect>() {
override fun handleIntent(intent: OrderStore.Intent) {
when (intent) {
is OrderStore.Intent.Create -> handleCreate(intent)
is OrderStore.Intent.AddItem -> handleAddItem(intent)
is OrderStore.Intent.Complete -> handleComplete()
is OrderStore.Intent.Cancel -> handleCancel()
}
}
private fun handleComplete() {
// Checking invariants
if (state.items.isEmpty()) {
sideEffect(OrderStore.SideEffect.ValidationFailed("Cannot complete empty order"))
return
}
if (state.status != OrderStatus.CONFIRMED) {
sideEffect(OrderStore.SideEffect.ValidationFailed("Only confirmed orders can be completed"))
return
}
reduce {
copy(status = OrderStatus.COMPLETED)
}
// Generating domain event
state.orderId?.let { orderId ->
sideEffect(OrderStore.SideEffect.OrderCompleted(orderId))
}
}
}
Interaction Between Aggregates
In DDD, aggregates interact through domain events. SimpleMVI supports this pattern:
class OrderViewModel {
private val orderStore = OrderStore()
private val inventoryStore = InventoryStore()
private val paymentStore = PaymentStore()
init {
// Subscribing to order events
scope.launch {
orderStore.sideEffects.collect { sideEffect ->
when (sideEffect) {
is OrderStore.SideEffect.OrderCompleted -> {
// Initiating payment process
paymentStore.accept(
PaymentStore.Intent.ProcessPayment(
orderId = sideEffect.orderId,
amount = orderStore.state.totalAmount
)
)
}
is OrderStore.SideEffect.ItemAdded -> {
// Reserving item in inventory
inventoryStore.accept(
InventoryStore.Intent.ReserveItem(
productId = sideEffect.productId,
quantity = sideEffect.quantity
)
)
}
else -> {}
}
}
}
}
}
Practical Example: Order Management
Let's look at a complete implementation of an Order aggregate using SimpleMVI:
Order Aggregate Implementation
class OrderActor : DefaultActor<OrderStore.Intent, OrderStore.State, OrderStore.SideEffect>() {
private val orderIdGenerator = OrderIdGenerator()
override fun handleIntent(intent: OrderStore.Intent) {
when (intent) {
is OrderStore.Intent.Create -> handleCreate(intent)
is OrderStore.Intent.AddItem -> handleAddItem(intent)
is OrderStore.Intent.Complete -> handleComplete()
is OrderStore.Intent.Cancel -> handleCancel()
}
}
private fun handleCreate(intent: OrderStore.Intent.Create) {
// Checking preconditions
if (state.orderId != null) {
sideEffect(OrderStore.SideEffect.ValidationFailed("Order already exists"))
return
}
val newOrderId = orderIdGenerator.generate()
reduce {
copy(
orderId = newOrderId,
customerId = intent.customerId,
status = OrderStatus.DRAFT
)
}
sideEffect(OrderStore.SideEffect.OrderCreated(newOrderId))
}
private fun handleAddItem(intent: OrderStore.Intent.AddItem) {
// State validation
if (state.status != OrderStatus.DRAFT) {
sideEffect(OrderStore.SideEffect.ValidationFailed("Cannot add items to ${state.status} order"))
return
}
// Business rules validation
if (intent.quantity <= 0) {
sideEffect(OrderStore.SideEffect.ValidationFailed("Quantity must be positive"))
return
}
// Getting price (in a real application through repository)
val unitPrice = getProductPrice(intent.productId)
val newItem = OrderItem(
itemId = generateItemId(),
productId = intent.productId,
quantity = intent.quantity,
unitPrice = unitPrice,
totalPrice = Money(
amount = unitPrice.amount * intent.quantity.toBigDecimal(),
currency = unitPrice.currency
)
)
reduce {
copy(
items = items + newItem,
totalAmount = calculateTotalAmount(items + newItem)
)
}
sideEffect(OrderStore.SideEffect.ItemAdded(intent.productId, intent.quantity))
}
private fun calculateTotalAmount(items: List<OrderItem>): Money {
return items.fold(Money.ZERO) { acc, item ->
Money(
amount = acc.amount + item.totalPrice.amount,
currency = acc.currency
)
}
}
}
Commands: CreateOrder, AddItem, CompleteOrder
Commands are represented as Intents with clear semantics:
sealed interface Intent {
// Order creation command
data class Create(
val customerId: CustomerId,
val deliveryAddress: Address? = null
) : Intent
// Item addition command
data class AddItem(
val productId: ProductId,
val quantity: Int,
val specialInstructions: String? = null
) : Intent
// Order confirmation command
data object Confirm : Intent
// Order completion command
data object Complete : Intent
// Order cancellation command
data class Cancel(val reason: String) : Intent
}
Events: OrderCreated, ItemAdded, OrderCompleted
Domain events are represented as SideEffects:
sealed interface SideEffect {
// Order creation event
data class OrderCreated(
val orderId: OrderId,
val customerId: CustomerId,
val timestamp: Instant = Instant.now()
) : SideEffect
// Item addition event
data class ItemAdded(
val orderId: OrderId,
val productId: ProductId,
val quantity: Int,
val timestamp: Instant = Instant.now()
) : SideEffect
// Order completion event
data class OrderCompleted(
val orderId: OrderId,
val totalAmount: Money,
val timestamp: Instant = Instant.now()
) : SideEffect
// Validation error events
data class ValidationFailed(
val reason: String,
val timestamp: Instant = Instant.now()
) : SideEffect
}
Best Practices
DDD-Style Naming
Use domain language (Ubiquitous Language) when naming components:
// ✅ Good: domain language is used
class OrderStore : Store<...>
sealed interface OrderStatus
data class OrderItem(...)
// ❌ Bad: technical names
class DataStore : Store<...>
sealed interface Status
data class Item(...)
Invariant Validation
Always check business rules in the Actor:
private fun handleAddItem(intent: OrderStore.Intent.AddItem) {
// Invariant: cannot add items to a completed order
if (state.status == OrderStatus.COMPLETED) {
sideEffect(
OrderStore.SideEffect.ValidationFailed(
"Cannot modify completed order"
)
)
return
}
// Invariant: quantity must be positive
if (intent.quantity <= 0) {
sideEffect(
OrderStore.SideEffect.ValidationFailed(
"Quantity must be positive"
)
)
return
}
// Business logic
// ...
}
Testing Domain Logic
SimpleMVI makes testing domain logic simple:
@Test
fun `should not allow adding items to completed order`() {
// Given
val store = OrderStore()
store.init()
// Create and complete order
store.accept(OrderStore.Intent.Create(customerId))
store.accept(OrderStore.Intent.Complete)
// When
store.accept(OrderStore.Intent.AddItem(productId, quantity = 1))
// Then
val sideEffects = store.sideEffects.test()
assertTrue(sideEffects.latestValue is OrderStore.SideEffect.ValidationFailed)
}
Conclusion
Main Advantages of the Approach
SimpleMVI and Domain-Driven Design complement each other perfectly:
- Clean Domain Model — business logic is isolated in the Actor
- Clear Boundaries — Store represents Aggregate Root with clear boundaries
- Domain Events — SideEffect naturally expresses domain events
- Testability — easy to test business logic in isolation
- Scalability — the structure allows for easy addition of new aggregates
When to Use SimpleMVI + DDD
This approach is effective when:
- You have complex domain logic with many business rules
- Clear separation of responsibilities is required
- The ability to easily test business logic is important
- The project is developed by a team and requires a clear structure
- Long-term support and development of the system is planned
SimpleMVI provides a simple but powerful foundation for implementing Domain-Driven Design in modern Kotlin applications, allowing focus on business logic rather than technical implementation details.
More information is available in the SimpleMVI GitHub repository: https://github.com/arttttt/SimpleMVI