Hexagonal architecture (a.k.a. ports and adapters) is a fancy name for designing your application in a way that the core domain is separated from the outside world by input and output ports. With a little bit of imagination one can visualise this as a hexagon made up of domain objects, use cases that operate on them, and input and output ports that provide an interface to the outside world.
Many projects involve integration or communication with external software systems. Think of databases, 3rd party services, but also application platforms or SDKs. Such integrations and dependencies can quickly get in your way, clutter your core domain and reduce the testability of your core business logic. In this talk, I will demonstrate how a hexagonal architecture helps you to reduce dependencies on external software systems and enables you to apply standard software engineering best practices on the core domain of your application, such as testability, separation of concerns, and reusability.
Join this talk to learn the ins and outs (pun intended) of the hexagonal architecture paradigm and get practical advice and examples to apply to your software projects right away!
2. Jeroen Rosenberg
● Software Consultant @ Xebia
● Founder of Amsterdam Scala
● Currently doing Kotlin & Java projects
● Father of three
● I like Italian food :)
@jeroenrosenberg
jeroenr
https://jeroenrosenberg.medium.com
3.
4. Agenda
● What is Hexagonal Architecture?
● Why should I care?
● How is cooking Ravioli “al dente” relevant?
10. Domain gets cluttered
● By 3rd party integrations (dependency on API version)
● By dependency on persistence layer and framework
● By using application frameworks or SDKs
11. @Service
class DepositService(
val userRepo: UserRepository,
val userAccountRepo: UserAccountRepository,
val exchangeApiClient: ExchangeApiClient,
val eventBus: EventBus
) {
fun deposit(userId: String, amount: BigDecimal, currency: String){ ... }
}
12. @Service
class DepositService(…) {
@Transactional
fun deposit(userId: String, amount: BigDecimal, currency: String){
require(amount > BigDecimal.ZERO) { “Amount must be larger than 0” }
require(Currencies.isSupported(currency)) { “$currency is not supported”}
userRepo.findById(userId)?.let { user ->
userAccountRepo.findByAccountId(user.accountId)?.let { account ->
val rateToUsd = if (currency != “USD”) {
exchangeApiClient.getRate(RateRequest(currency, “USD”)).rate
} else { 1.0 }
val rateToPref = if (account.currency != “USD”) {
exchangeApiClient.getRate(RateRequest(“USD”, account.currency)).rate
} else { 1.0 }
val oldBalance = account.balance
account.balance += amount * rateToUsd.toBigDecimal() * rateToPref.toBigDecimal()
userAccountRepo.save(account)
val update = BalanceUpdate(
userId, oldBalance, account.balance
)
eventBus.publish(ProducerRecord(“balance-updates”, update))
}
}
}
}
13. @Service
class DepositService(…) {
@Transactional
fun deposit(userId: String, amount: BigDecimal, currency: String){
require(amount > BigDecimal.ZERO) { “Amount must be larger than 0” }
require(Currencies.isSupported(currency)) { “$currency is not supported”}
userRepo.findById(userId)?.let { user ->
userAccountRepo.findByAccountId(user.accountId)?.let { account ->
val rateToUsd = if (currency != “USD”) {
exchangeApiClient.getRate(RateRequest(currency, “USD”)).rate
} else { 1.0 }
val rateToPref = if (account.currency != “USD”) {
exchangeApiClient.getRate(RateRequest(“USD”, account.currency)).rate
} else { 1.0 }
val oldBalance = account.balance
account.balance += amount * rateToUsd.toBigDecimal() * rateToPref.toBigDecimal()
userAccountRepo.save(account)
val update = BalanceUpdate(
userId, oldBalance, account.balance
)
eventBus.publish(ProducerRecord(“balance-updates”, update))
}
}
}
}
Entity (Proxy)
14. @Service
class DepositService(…) {
@Transactional
fun deposit(userId: String, amount: BigDecimal, currency: String){
require(amount > BigDecimal.ZERO) { “Amount must be larger than 0” }
require(Currencies.isSupported(currency)) { “$currency is not supported”}
userRepo.findById(userId)?.let { user ->
userAccountRepo.findByAccountId(user.accountId)?.let { account ->
val rateToUsd = if (currency != “USD”) {
exchangeApiClient.getRate(RateRequest(currency, “USD”)).rate
} else { 1.0 }
val rateToPref = if (account.currency != “USD”) {
exchangeApiClient.getRate(RateRequest(“USD”, account.currency)).rate
} else { 1.0 }
val oldBalance = account.balance
account.balance += amount * rateToUsd.toBigDecimal() * rateToPref.toBigDecimal()
userAccountRepo.save(account)
val update = BalanceUpdate(
userId, oldBalance, account.balance
)
eventBus.publish(ProducerRecord(“balance-updates”, update))
}
}
}
}
Dependency on API version
15.
16. @Service
class DepositService(…) {
@Transactional
fun deposit(userId: String, amount: BigDecimal, currency: String){
require(amount > BigDecimal.ZERO) { “Amount must be larger than 0” }
require(Currencies.isSupported(currency)) { “$currency is not supported”}
userRepo.findById(userId)?.let { user ->
userAccountRepo.findByAccountId(user.accountId)?.let { account ->
val rateToUsd = if (currency != “USD”) {
exchangeApiClient.getRate(RateRequest(currency, “USD”)).rate
} else { 1.0 }
val rateToPref = if (account.currency != “USD”) {
exchangeApiClient.getRate(RateRequest(“USD”, account.currency)).rate
} else { 1.0 }
val oldBalance = account.balance
account.balance += amount * rateToUsd.toBigDecimal() * rateToPref.toBigDecimal()
userAccountRepo.save(account)
val update = BalanceUpdate(
userId, oldBalance, account.balance
)
eventBus.publish(ProducerRecord(“balance-updates”, update))
}
}
}
}
Orchestration
17. @Service
class DepositService(…) {
@Transactional
fun deposit(userId: String, amount: BigDecimal, currency: String){
require(amount > BigDecimal.ZERO) { “Amount must be larger than 0” }
require(Currencies.isSupported(currency)) { “$currency is not supported”}
userRepo.findById(userId)?.let { user ->
userAccountRepo.findByAccountId(user.accountId)?.let { account ->
val rateToUsd = if (currency != “USD”) {
exchangeApiClient.getRate(RateRequest(currency, “USD”)).rate
} else { 1.0 }
val rateToPref = if (account.currency != “USD”) {
exchangeApiClient.getRate(RateRequest(“USD”, account.currency)).rate
} else { 1.0 }
val oldBalance = account.balance
account.balance += amount * rateToUsd.toBigDecimal() * rateToPref.toBigDecimal()
userAccountRepo.save(account)
val update = BalanceUpdate(
userId, oldBalance, account.balance
)
eventBus.publish(ProducerRecord(“balance-updates”, update))
}
}
}
}
19. Issues
● Testability: not unit testable or requires lots of mocking
● API/Platform version dependency
○ What if you need to support multiple platform versions?
○ What if API versions change?
20. Issues
● Testability: not unit testable or requires lots of mocking
● API/Platform version dependency
○ What if you need to support multiple platform versions?
○ What if API versions change?
● Orchestration mixed with business logic is hard to maintain
21. Issues
● Testability: not unit testable or requires lots of mocking
● API/Platform version dependency
○ What if you need to support multiple platform versions?
○ What if API versions change?
● Orchestration mixed with business logic is hard to maintain
● Mutability leads to mistakes
35. “so as to be still firm when bitten”
Al dente
/al ˈdɛnteɪ,al ˈdɛnti/
adverb
36. When you push modularity too far...
● Coupling is too loose
● Low cohesion
● Bloated call stacks
● Navigation through code will be more difficult
● Transaction management will be hard
38. “A particular field of thought, activity or interest”
domain
/də(ʊ)ˈmeɪn/
noun
39. “A particular field of thought, activity or interest”
What an organisation does
40. “A particular field of thought, activity or interest”
How an organisation does it
41.
42. Bounded Context
● A distinct part of the domain
● Split up domain in smaller, independent models with clear boundaries
● No ambiguity
● Could be separate module, jar, microservice
● Context mapping DDD pattern (https://www.infoq.com/articles/ddd-contextmapping/)
43. Using the building blocks of DDD
● Value objects
● Entities
● Domain services
● Application services
44. Value objects
● Defined by their value
● Immutable
● Thread-safe and side-effect free
● Small and coherent
● Contain business logic that can be
applied on the object itself
● Contain validation to ensure its value
is valid
● You will likely have many
45. Value objects
● Defined by their value
● Immutable
● Thread-safe and side-effect free
● Small and coherent
● Contain business logic that can be
applied on the object itself
● Contain validation to ensure its value
is valid
● You will likely have many
enum class Currency { USD, EUR }
data class Money(
val amount: BigDecimal,
val currency: Currency,
) {
fun add(o: Money): Money {
if(currency != o.currency)
throw IllegalArgumentException()
return Money(
amount.add(o.amount),
currency
)
}
}
47. Entities
● Defined by their identifier
● Mutable
● You will likely have a few
@Document
data class UserAccount(
@Id
val _id: ObjectId = ObjectId(),
var name: String,
var createdAt: Instant = Instant.now(),
var updatedAt: Instant
) {
var auditTrail: List<String> = listOf()
fun updateName(name: String) {
this.auditTrail = auditTrail.plus(
“${this.name} -> ${name}”
)
this.name = name
this.updatedAt = Instant.now()
}
}
49. Domain Services
● Stateless
● Highly cohesive
● Contain business logic that doesn’t naturally fit in value objects
// in Domain Module
interface CurrencyExchangeService {
fun exchange(money: Money, currency: Currency): Money
}
50. Domain Services
● Stateless
● Highly cohesive
● Contain business logic that doesn’t naturally fit in value objects
// in Infrastructure Module
class CurrencyExchangeServiceImpl : CurrencyExchangeService {
fun exchange(money: Money, currency: Currency): Money {
val amount = moneta.Money.of(money.amount, money.currency.toString())
val conversion = MonetaryConversions.getConversion(currency.toString())
val converted = amount.with(conversion)
return Money(
converted.number.numberValueExact(BigDecimal::class.java),
Currency.valueOf(converted.currency.currencyCode)
)
}
51. Application Services
● Stateless
● Orchestrates business operations (no business logic)
○ Transaction control
○ Enforce security
● Communicates through ports
● Use DTOs for communication
○ Little conversion overhead
○ Domain can evolve without having to change clients
52. Application Services
● Stateless
● Orchestrates business operations (no business logic)
○ Transaction control
○ Enforce security
● Implements a port in the case an external system wants to access your app
● Uses a port (implemented by an adapter) to access an external system
● Use DTOs for communication
○ Little conversion overhead
○ Domain can evolve without having to change clients
// in Infrastructure Module
@Service
class UserAccountAdminServiceImpl(
private val userRepository: UserRepository
) : UserAccountAdminService {
@Transactional
fun resetPassword(userId: Long) {
val user = userRepository.findById(userId)
user.resetPassword()
userRepository.save()
}
}
53.
54. Domain Module
● Use simple, safe and consistent value objects to model
your domain
○ Generate with Immutables/Lombok/AutoValue
○ Use Java 14 record types or Kotlin data classes
● Implement core business logic and functional (unit) tests
● No dependencies except itself (and 3rd party libraries with
low impact on domain)
● Expose a clear API / “ports”
○ Communicate using value objects / DTOs
● Could be a separate artifact (maven)
55. Infrastructure Modules
● Separate module that depends on Core Domain Module
● Specific for an application platform / library version
○ Easy to write version specific adapters
● Write adapters
○ Converting to/from entities, DTOs or proxy objects
○ 3rd party integrations
○ REST endpoints
○ DAO’s
● Integration tests if possible
56. @Service
class DepositService(…) {
@Transactional
fun deposit(userId: String, amount: BigDecimal, currency: String){
require(amount > BigDecimal.ZERO) { “Amount must be larger than 0” }
require(Currencies.isSupported(currency)) { “$currency is not supported”}
userRepo.findById(userId)?.let { user ->
userAccountRepo.findByAccountId(user.accountId)?.let { account ->
val rateToUsd = if (currency != “USD”) {
exchangeApiClient.getRate(RateRequest(currency, “USD”)).rate
} else { 1.0 }
val rateToPref = if (account.currency != “USD”) {
exchangeApiClient.getRate(RateRequest(“USD”, account.currency)).rate
} else { 1.0 }
val oldBalance = account.balance
account.balance += amount * rateToUsd.toBigDecimal() * rateToPref.toBigDecimal()
userAccountRepo.save(account)
val update = BalanceUpdate(
userId, oldBalance, account.balance
)
eventBus.publish(ProducerRecord(“balance-updates”, update))
}
}
}
}
57. enum class Currency { USD, EUR }
data class ExchangeRate(val rate: BigDecimal, val currency: Currency)
data class Money(val amount: BigDecimal, val currency: Currency) {
val largerThanZero = amount > BigDecimal.ZERO
fun add(o: Money): Money {
if(currency != o.currency) throw IllegalArgumentException()
return Money(amount.add(o.amount), currency)
}
fun convert(exchangeRate: ExchangeRate): Money {
return Money(amount.multiply(exchangeRate.rate), exchangeRate.currency)
}
}
data class UserAccountDTO(val balance: Money)
data class UserDTO(val userAccountId: String, val preferredCurrency: Currency)
58. // in Domain Module
interface ExchangeRateService {
fun getRate(source: Currency, target: Currency): ExchangeRate
}
// in Infrastructure Module
class ExchangeRateServiceImpl : ExchangeRateService {
override fun getRate(source: Currency, target: Currency): ExchangeRate {
val rate = MonetaryConversions
.getConversion(source.toString())
.getExchangeRate(moneta.Money.of(1, target.toString()))
return ExchangeRate(
rate.factor.numberValueExact(BigDecimal::class.java),
Currency.valueOf(rate.currency.currencyCode)
)
}
}
59. // in Domain Module
@Service
class DepositService(val exchangeRateService: ExchangeRateService) {
fun deposit(user: UserDTO, account: UserAccountDTO, amount: Money): UserAccountDTO{
require(amount.largerThanZero) { “Amount must be larger than 0” }
val rateToUsd = if (amount.currency != Currency.USD) {
exchangeRateService.getRate(amount.currency, Currency.USD)
} else { ExchangeRate(BigDecimal.ONE, Currency.USD) }
val rateToPref = if (user.preferredCurrency != Currency.USD) {
exchangeRateService.getRate(Currency.USD, user.preferredCurrency)
} else { ExchangeRate(BigDecimal.ONE, Currency.USD) }
return account.copy(
balance = account.balance.add(
amount
` .convert(rateToUsd)
.convert(rateToPreferred)
)
}
}
}
60. // in Domain Module
@Service
class DepositOrchestrationService(
val depositService: DepositService,
val userService: UserService,
val userAccountService: UserAccountService,
val eventPublisherService: EventPublisherService,
) {
@Transactional
fun deposit(request: DepositRequest): DepositResponse {
val userDTO = userService.getUser(request.userId)
val accountDTO = userAccountService.getUserAccount(userDTO.userAccountId)
val oldBalance = accountDTO.balance
val updated = depositService.deposit(userDTO, accountDTO, request.amount)
userAccountService.save(accountDTO.copy(balance = updated.balance))
val update = BalanceUpdate(
request.userId, oldBalance, updated.balance
)
eventPublisherService.publish(update)
return DepositResponse(request.userId, oldBalance, updated.balance)
}
}
61.
62. Summary
● Start with isolated and tech agnostic domain
○ Bring value early
○ Delay choices on technical implementation
63. Summary
● Start with isolated and tech agnostic domain
○ Bring value early
○ Delay choices on technical implementation
● The domain as a stand-alone module with embedded functional tests
64. Summary
● Start with isolated and tech agnostic domain
○ Bring value early
○ Delay choices on technical implementation
● The domain as a stand-alone module with embedded functional tests
● Modularity
○ As much adapters as needed w/o impacting other parts of the software
○ Tech stack can be changed independently and with low impact on the business
65. Summary
● Start with isolated and tech agnostic domain
○ Bring value early
○ Delay choices on technical implementation
● The domain as a stand-alone module with embedded functional tests
● Modularity
○ As much adapters as needed w/o impacting other parts of the software
○ Tech stack can be changed independently and with low impact on the business
● Only suitable if you have a real domain
○ overkill when merely transforming data from one format to another