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:

  1. Ubiquitous Language — using a unified language for communication between developers and domain experts
  2. Bounded Context — clear boundaries within which a model has a specific meaning
  3. Focus on Core Domain — concentration of efforts on key business logic
  4. Model-Driven Design — code should reflect the domain model

Main tactical patterns of DDD:

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:

// 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:

// 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:

// 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:

// 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:

// 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:

7. Testability

DDD places great importance on testing business logic. SimpleMVI ensures:

@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:

  1. Clean Domain Model — business logic is isolated in the Actor
  2. Clear Boundaries — Store represents Aggregate Root with clear boundaries
  3. Domain Events — SideEffect naturally expresses domain events
  4. Testability — easy to test business logic in isolation
  5. Scalability — the structure allows for easy addition of new aggregates

When to Use SimpleMVI + DDD

This approach is effective when:

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