Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Cooking your Ravioli "al dente" with Hexagonal Architecture

Cooking your Ravioli "al dente" with Hexagonal Architecture

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!

Jeroen Rosenberg

November 04, 2021
Tweet

More Decks by Jeroen Rosenberg

Other Decks in Programming

Transcript

  1. 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
  2. Agenda • What is Hexagonal Architecture? • Why should I

    care? • How is cooking Ravioli “al dente” relevant?
  3. Domain gets cluttered • By 3rd party integrations (dependency on

    API version) • By dependency on persistence layer and framework • By using application frameworks or SDKs
  4. @Service class DepositService( val userRepo: UserRepository, val userAccountRepo: UserAccountRepository, val

    exchangeApiClient: ExchangeApiClient, val eventBus: EventBus ) { fun deposit(userId: String, amount: BigDecimal, currency: String){ ... } }
  5. @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)) } } } }
  6. @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)
  7. @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
  8. @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
  9. @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)) } } } }
  10. 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?
  11. 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
  12. 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
  13. “so as to be still firm when bitten” Al dente

    /al ˈdɛnteɪ,al ˈdɛnti/ adverb
  14. 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
  15. 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/)
  16. Using the building blocks of DDD • Value objects •

    Entities • Domain services • Application services
  17. 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
  18. 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 ) } }
  19. 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() } }
  20. Domain Services • Stateless • Highly cohesive • Contain business

    logic that doesn’t naturally fit in value objects
  21. 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 }
  22. 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) ) }
  23. 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
  24. 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() } }
  25. 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)
  26. 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
  27. @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)) } } } }
  28. 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)
  29. // 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) ) } }
  30. // 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) ) } } }
  31. // 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) } }
  32. Summary • Start with isolated and tech agnostic domain ◦

    Bring value early ◦ Delay choices on technical implementation
  33. 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
  34. 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
  35. 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