KISS Clean Architecture With Domain-Driven Design
Develop clean architecture while keeping the principles of KISS and DDD in mind.
Join the DZone community and get the full member experience.
Join For FreeMy original title was The Perfect Clean Architecture with Domain-Driven Design. Provocative? Yes. Egotistical? Yeah, maybe. But I am an idealistic, problem-solving organizer, and I have not yet seen a software architecture that lights my fire. IMHO, this proposal is very simple, very elegant, very adaptable, and very extensible. You tell me if I hit the bullseye or my foot.
So, being the humble guy that I am, I decided to call it, the KISS Clean Architecture with Domain-Driven Design. Apparently, the KISS principle at first meant you should keep things very, very simple, that is, "Keep it stupid simple" not so much "Keep it simple, Stupid."
You may also enjoy: Software Design Principles DRY and KISS
I like that. Clean Architecture and Domain-Driven Design simplify complexity better than anything I have seen in my twenty-some years' experience as a software craftsman. Software should be like waffles, not spaghetti.
"Controlling complexity is the essence of computer programming. " — Brian Kernighan
Consider this my clean architecture how-to. The example code below uses Kotlin with Spring. (For you Java experts, the return type follows the colon.) My apologies for not having a simple project in GitHub.
First Things First
Let me offer a fundamental reminder to start with. No matter how complicated it is, software is still basically a matter of Input, Processing, and Output. Cohesion means those three fundamental parts are kept distinct. Input and output are the less stable parts, business processing the more stable part, as DDD emphasizes. Input and output may contain considerable logic, but they are still input and output and not the concern of the business domain.
Top Layers
My top-level packages are defined by layer according to Clean Architecture. Remember: dependencies may only flow downward. You'll notice I couldn't help prepending domain with an x so that the layers are actually alphabetized in order of dependencies! Is it goofy, or does it aid comprehension?
appConfig/ // application configuration, wires it all together, knows about everything
gateway/ // all input and output, knows about domain classes below
usecase/ // use cases for all application functionality, does gateway input and output ONLY via interfaces on this layer
xdomain/ // business domain classes that do not know or care about anything else above this layer
What could be simpler! Everything is in one of these four layers. Inside each layer, packages can be organized by feature, by type, or by anything else that makes sense. But these four layers tell developers why a piece of software is where it is. They tell them what purpose it serves both from the business as well as the technical point of view. This is important: A mist in the mind is a fog in the code.
App/Config Layer
The appConfig
layer starts this application and sets up configurations for it. Doing it right means changing input or output will be just a one-line change, perhaps even a one-name change.
@SpringBootApplication
class Application(
private val hqCartGateway: HqCartGateway, // implements CartGateway interface
private val itemGatewayImpl: ItemGatewayImpl, // implements ItemGateway interface
private val promoGateway: PromoGateway) // also implements ItemGateway interface
) {
fun main(args: Array<String>) {
SpringApplication.run(Application::class.java, *args)
}
// These usecase beans are needed for proper dependency injection.
// A use case may need two different implementations of the same interface, for example, ItemGateway.
// Spring cannot know which each one should be. Other class signatures would be Spring-injected.
//
// class AddItemUseCase(
// private val hqCartGateway: CartGateway, // only 1 CartGateway implementation in the app
// private val itemGatewayImpl: ItemGateway, // 2 ItemGateway implementations
// private val promoGateway: ItemGateway)
@Bean
fun addItemUseCase(): AddItemUseCase {
return AddItemUseCase(hqCartGateway, itemGateway, promoGateway)
}
}
Gateway Layer
The gateway
layer may be the most important layer in this world of microservices. It is this application's gateway to the world. For some reason, I hear Pinky and the Brain.
"What are we gonna do tonight, Brain?"
"Same thing we always do, Pinky. Make another gateway and try to take over the world!"
Each package in the gateway
layer encapsulates a different service that this application connects to, including a local database. In terms of Domain-Driven Design, this is where the lines are drawn for Bounded Contexts.
You could also say this layer is all about the application's input-output, reading-writing, sending-receiving, publishing-consuming. The application's connections to all other services are contained here and only here. None of its packages should know about any other gateway. None should know about business domain logic. They do know about domain objects (a downward dependency) so that they can translate (adapt) their data objects to and from the application's data objects. The usecase
layer talks to the gateways only through interfaces that are on the usecase
layer.
Each package has three significant classes: Request
, Response
, and Port
. I am using Port
for the most specific, lowest-level technology code for these connections. All communication through the gateway should happen in a Port
class. Usually, a different endpoint, Port
, would mean a different gateway.
Requests
are outgoing gateway objects that can map themselves from domain objects. Responses
are incoming gateway objects that can map themselves to domain objects. Notice also that it is possible to have multiple types of Requests
and Responses
.
You might notice, in place of my gateway
layer, that some clean architecture implementations use two layers, infrastructure
and adapter
. In my mind, those terms are a bit like service
or process
, too broad in their meanings. Plus, gateway
has the nice advantage of being better connected to the real world, while the other terms point to technological constructs. More KISS!
appConfig/
gateway/
hq/
HqCartGateway.kt // see code below
HqRequest.kt
HqResponse.kt
HqPort.kt
item/
promo/
rest/
cart/
CartController.kt
CreateCartRequest.kt
CreateCartResponse.kt
UpdateCartRequest.kt
UpdateCartResponse.kt
item/
ItemController.kt
ItemRequest.kt
ItemResponse.kt
usecase/
xdomain/
@Service
class HqCartGateway(
private val hqRequest: HqRequest,
private val hqResponse: HqResponse,
private val hqPort: HqPort)
: CartGateway {
override fun createCart(cart: Cart): Cart {
val cartPostRequest = hqRequest.mapPostRequestFrom(cart)
val cartPostResponse = hqPort.createCart(cartPostRequest)
return hqResponse.mapToCart(cart.storeId, cartResponse)
}
override fun updateCart(cart: Cart): Cart {
...
}
}
class HqResponse(...) {
fun mapToCart(...): Cart {
...
}
private fun validate() {
// validate Response NOT business data
}
}
Usecase Layer
The usecase
layer contains processing for each specific behavior of the application. These use cases consist of interactions among domain objects and/or gateways. They will explicitly display what each application use case does. The messy input and output are represented by their simple interface method calls. All of that complexity has been extracted up to the gateway packages.
Ideally, this produces the invaluable benefit of being able to see the whole use case process on one screen! It shows all the important business decisions in one place.
Incidentally, each usecase
class does not wrap a whole "feature." A feature would likely be composed of multiple use cases. If a class wrapped a whole feature and put its particular use cases in private methods, for example, we all know the logic would inevitably and inexorably muddy all of the methods and quickly turn the class into spaghetti mush! Here's a better idea: wrap the features in subpackages.
appConfig/
gateway/
usecase/
cart/
CartGateway.kt
CreateCartUseCase.kt
UpdateCartUseCase.kt
AddItemUseCase.kt
RemoveItemUseCase.kt
item/
ItemGateway.kt
xdomain/
class AddItemUseCase(
private val cartGateway: CartGateway,
private val itemGateway: ItemGateway,
private val promoGateway: ItemGateway
) {
// The whole enchilada!!!
fun addItem(cart: Cart, item: Item): Cart {
val storedItem = itemGateway.findItem(item)
val promo = promoGateway.findItem(item)
storedItem.calcFinalPrice(promo)
cart.addItem(storedItem)
val storedCart = cartGateway.save(cart)
return storedCart
}
}
Domain Layer
The domain
packages are the business objects of this application, DDD's Entities, Aggregates, Value Objects. Each basically knows only about itself unless it's an Aggregate, of course. Most importantly, they know nothing about the layers above them. They don't know or care about their input, their output, or how they are used in a business use case.
Note: These objects may have many fields that are not used by their own business logic, or even in the application. This is because they must have the ability to be converted to and from all gateway Request
and Response
objects. This eliminates any entanglement of the application with its gateway data. Having all fields needed by all gateways also enables gateway
objects to remain cleaner, accessing only the domain object fields they are interested in and completely ignorant of any other gateway data. This vastly reduces the complexity of the entire application. Waffles!
appConfig/
gateway/
usecase/
xdomain/
Cart.kt
Item.kt
Order.kt
Promo.kt
data class Cart(
val storeId: String,
val cartId: String, // or UUID
var status: CartStatus? = null,
val items: ArrayList<Item> = arrayListOf()
) {
fun addItem(item: Item): Cart {
items.add(item)
validateCart()
return this
}
private fun validateCart() {
// validate business rules NOT gateway Responses
}
}
Summary
So, what does this give us? Sparkling clean, crystal clear separation of concerns! Very SOLID software.
Imagine investigating how and when tax is calculated on a cart. Would it be hard to find? Nope, just check a few usecase
classes.
Imagine adding a new feature. Would it be hard to decide where to put the code? Nope.
Imagine finding and fixing a bug? Would it be a days-long nightmare!? Nope.
KISS Clean Architecture with Domain-Driven Design makes the most elegant software and can handle the most complex software.
"Any fool can write code that a computer can understand. Good programmers write code that humans can understand." — Martin Fowler
Further Reading
SOLID Principles: Basic Building Blocks of (A Clean) Software System
Opinions expressed by DZone contributors are their own.
Comments