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 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 Ubiquitous Language — using a unified language for communication between developers and domain experts Ubiquitous Language Bounded Context — clear boundaries within which a model has a specific meaning Bounded Context Focus on Core Domain — concentration of efforts on key business logic Focus on Core Domain Model-Driven Design — code should reflect the domain model Model-Driven Design 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 Entity — an object with identity that passes through various states Entity Value Object — an immutable object without identity Value Object Aggregate — a cluster of related objects with a single entry point (Aggregate Root) Aggregate Domain Event — an event that is significant to the business Domain Event Repository — an abstraction for accessing aggregates Repository Domain Service — operations that don't belong to a specific entity Domain Service 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 Actor contains pure business logic without dependencies on UI or infrastructure Actor Store encapsulates the domain model and is the single point of access to it Store 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) } } } // 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 Immutable State that cannot be modified directly State 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" } } } // 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 Intent represents commands Intent SideEffect represents domain events SideEffect 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 } // 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 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 } } // 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 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 } // 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() and destroy() methods Control over the creation and destruction of aggregates Possibility of resource release Explicit init() and destroy() methods init() destroy() 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 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) } @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 } } 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 } // 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)) } } } 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 -> {} } } } } } 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 ) } } } 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 } 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 } 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(...) // ✅ 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 // ... } 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) } @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 Clean Domain Model — business logic is isolated in the Actor Clean Domain Model Clear Boundaries — Store represents Aggregate Root with clear boundaries Clear Boundaries Domain Events — SideEffect naturally expresses domain events Domain Events Testability — easy to test business logic in isolation Testability Scalability — the structure allows for easy addition of new aggregates Scalability 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 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 https://github.com/arttttt/SimpleMVI