Hexagonal Architecture (Part#2)

In Part#1, we saw what Hexagonal architecture is and how is it better than Layered code structure or Feature code structure.

We also saw that Hexagonal code structure is just a replica of the Hexagonal Box diagram and hence it helps maintain the code base as the software grows.

In Part#2, we will manifest the architecture by implementing the “Send Money” use-case we discussed briefly in Part#1.

package buckpal.account.domainpublic class Account {
private AccountId id;
private Money baselineBalance;
private ActivityWindow activityWindow;
// constructor and getters omitted public Money calculateBalance() {
return Money.add(
this.baselineBalance,
this.activityWindow.calculateBalance(this.id));
}
public boolean withdraw(Money money, AccountId tgtAccountId) {
if(!mayWithdraw(money)) {
return false;
}
Activity withdrawal = new Activity(
this.id,
this.id,
tgtAccountId,
LocalDateTime.now(),
money);
this.activityWindow.addActivity(withdrawal);
return true;
}
private boolean mayWithdraw(Money money) {
return Money.add(
this.calculateBalance(),
money.negate())
.isPositive();
}
public boolean deposit(Money money, AccountId srcAccountId) {
Activity deposit = new Activity(
this.id,
srcAccountId,
this.id,
LocalDateTime.now(),
money);

this.activityWindow.addActivity(deposit);
return true;
}
}

The Account entity provides the current snapshot of an actual account. Every withdrawal from and deposit into an account is captured in an Activity entity. Since it would not be wise to load all activities of an account into memory, the Account entity holds only a window of the last few days or weeks of activities, captured in the ActivityWindow value object.

The baselineBalance attribute represents the balance the account had right before the first activity of the ActivityWindow. This helps to calculate the current account balance.

An application core usually does these things —

  1. Takes input
  2. Validates business rules
  3. Manipulates the model state
  4. Returns the output

Why doesn’t the use-case validate the input? Because use-case code should only care about the domain logic and shouldn’t be polluted with input validation.

package buckpal.application@ReqiuiredArgsConstructor
@Transactional
public class SendMoneyService implements SendMoneyUseCase {
private final LoadAccountPort loadAccountPort;
private final AccountLock accountLock;
private final UpdateAccountStatePort udpateAccountStatePort;
@Override
public boolean SendMoney(SendMoneyCommand command) {
// TODO: validate business rules
// TODO: manipulate model state
// TODO: return output
}
}

The input validation should however happen in application layer, otherwise our application might get invalid input from outside the application core. We will let the input model take care of it.

package buckpal.application.port.in@Getter
public class SendMoneyCommand {
private final AccountId srcAccountId;
private final AccountId tgtAccountId;
private final Money money;
public SendMoneyCommand(
AccountId srcAccountId,
AccountId tgtAccountId,
Money money) {
this.srcAccountId = srcAccountId;
this.tgtAccountId = tgtAccountId;
this.money = money;
requireNonNull(srcAccountId);
requireNonNull(tgtAccountId);
requireNonNull(money);
requireGreaterThan(money, 0);
}
}

If any of the input validation condition does not meet, we simply refuse object creation by throwing an exception during construction.

By making the fields of SendMoneyCommand final, we effectively make it immutable. So, once constructed successfully, we can be sure that the state is valid and cannot be changed to something invalid.

The business rule validation can either go into the model entity or the use-case. For example, we have already implemented the rule “the source account must not be overdrawn” in the model entity by implementing “mayWithdraw” method.

If it’s not feasible to validate a business rule in a domain entity, we can simply do it in the use-case code.

The input and output models should not be shared between use-cases. For example the SendMoneyCommand is used as input model for SendMoneyService use-case and it should not be used for any other use-case, even if the input for the other use-case is same. It is because, the input model might need to change for one of the use-cases in future, therefore, we should avoid any such coupling between different use-cases.

In our target Hexagonal architecture, all communication with the outside world goes through adapters.

The below diagram shows the architectural elements that are relevant to a web adapter (or any incoming adapter).

The web adapter takes request from the outside and translates them into calls to our applicate core.

A web adapter usually does these things —

  1. Maps HTTP requests to Java objects
  2. Performs authorization checks
  3. Validates input
    But haven’t we already discussed input validation as a responsibility of the input model. Here we are talking about the http input to the web adapter and not the input to the use-case.
  4. Maps input to the input model of the use case
  5. Calls the use case
  6. Maps the output of the use case back to HTTP
  7. Returns an HTTP response

Approach#1 :

Create a single AccountController that accepts requests for all operations that relate to accounts.

package buckpal.adapter.web@RestController
@RequiredArgsConstructor
class AccountController {

private final GetAccountBalanceQuery getAccountBalanceQuery;
private final LostAccountsQuery listAccountsQuery;
private final LoadAccountQuery loadAccountQuery;
private final SendMoneyUseCase sendMoneyUseCase;
private final CreateAccountUseCase createAccountUseCase;
@GetMapping("/accounts")
List<AccountResource> listAccounts() {
...
}
@GetMapping("/accounts/id")
AccountResource getAccount(@PathVariable("accountId") Long accountId) {
...
}
@GetMapping("/accounts/{id}/balance)
long getAccountBalance(@PathVariable("accountId") Long accountId) {
...
}
@PostMapping("/accounts")
AccountResource createAccount(@RequestBody AccountResource account) {
...
}
@Postmapping(
"/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}")
void sendMoney(
@PathVariable("sourceAccountId") Long sourceAccountId,
@PathVariable("targetAccountId") Long targetAccountId,
@PathVariable("amount") Long amount) {
...
}
}

Cons —

  1. Implementing all operations in a single controller will increase the number of lines in this class drastically. It is always harder to grasp more than 50 lines of code in a class
  2. The test classes also become bulkier
  3. All operations into a single controller class encourages the reuse of data structures. For example, AccountResource model class is used by many operations above. This creates coupling between the use-cases.

Approach#2 [Recommended] :

Create separate controller, potentially in separate package, for each operation.

package buckpal.adapter.web@RestController
@RequiredArgsConstructor
public class SendMoneyController {
private final SendMoneyUseCase; @PostMapping(
"/accounts/send/{sourceAccountId}/{taregtAccountId}/{amount}")
void sendMoney (
@PathVariable("sourceAccountId") Long sourceAccountId,
@PathVariable("targetAccountId") Long targetAccountId,
@PathVariable("amount") Long amount) {
SendMoneyCommand command = new SendMoneyCommand(
new AccountId(sourceAccountId),
new AccountId(targetAccountId),
Money.of(amount));
sendMoneyUseCase.sendMoney(command);
}
}

Pros —

  1. Each controller can have its own model such as CreateAccountResource or UpdateAccountResource. These specialized model classes may even be private to the controller’s package so they cannot be reused somewhere else
  2. This slicing of controllers makes parallel work on different operations a breeze. We won’t have merge conflicts if 2 developers work on different operations

The below diagram shows the architectural elements that are relevant to a persistence adapter (or any outgoing adapter).

The ports are effectively a layer of indirection between the application services and the persistence code. This means that refactoring the persistence code will not lead to a code change in the core.

A persistence adapter usually does —

  1. Takes input
  2. Maps input into a database format
  3. Send input to the database
  4. Maps database output into an application format
  5. Return output

Slicing Port Interfaces : Approach#1

Single interface that provides all database operations for a certain entity —

Cons —

  1. Unnecessary code dependencies : Each service that relies on database will have a dependency on this single broad port interface, even if it uses only a single method from that interface.
  2. Difficult to test : Imagine we write test for RegisterAccount service, which method of the Interface do we have to create a mock for? Every new developer will have to look into src code to understand what to mock.

Slicing Port Interfaces : Approach#2

Broad interfaces should be split into specific ones, so the clients know the methods they need.

Very narrow ports like these makes coding a plug-and-play experience. One-method-per-port may not be application in all circumstances. There may be groups of database operations that are so cohesive and often used together that we may want to bundle them together in a single interface.

We already implemented Account entity in Implementing the Domain section.

We will use Spring data JPA to talk to the database, and hence we will use Entity annotated classes representing database state of an account —

@Entity
@Table(name = "account")
@Data
@AllArgsConstructor
@NoArgsConstructor
class AccountJpaEntity {
@Id
@GeneratedValue
private Long id;
}

Code block for activity table —

@Entity
@Table(name = "activity")
@data
@AllArgsConstructor
@NoArgsConstructor
class ActivityJpaEntity {
@Id
@GeneratedValue
private Long id;
@Column private LocalDateTime timestamp;
@Column private Long ownerAccountId;
@Column private Long sourceAccountId;
@Column private Long targetAccountId;
@Column private Long amount;
}

Next, we use Spring Data to create repository interfaces that provide basic CRUD functionality out of box, as well as custom queries to load certain activities from the database —

interface AccountRepository extends JpaRepository<AccountJpaEntity, Long> { 
}
interface ActivityRepository extends JpaRepository<ActivityJpaEntity, Long> {

@Query("select a from ActivityJpaEntity a
where a.ownerAccountId = :ownerAccountId and
a.timestamp >= :since")
List<ActivityJpaEntity> findByOwnerSince(
@Param("ownerAccountId") Long ownerAccountId,
@Param("since") LocalDateTime since);
@Query("select sum(a.amount) from ActivityJpaEntity a
where a.targetAccountId = :accountId and
a.ownerAccountId = :accountId and
a.timestamp = :until")
Long getDepositBalanceUntil(
@Param("accountId") Long accountId,
@Param("until") LocaldateTime until);
@Query("select sum(a.amount) from ActivityJpaEmtity a
where a.sourceAccountId = :accountId and
a.ownerAccountId = :accountId and
a.timestamp < :until")
Long getWithdrawalBalanceUntil(
@Param("accountId") Long accountId,
@Param("until") LocalDateTime until);
}

Spring Boot will automatically find these repositories and Spring Data will work its magic to provide an implementation behind the repository interface that will actually talk to the database.

Now that we have JPA entities and repositories in place, we can implement the persistence adapter —

@RequiredArgsConstructor
@Component
class AccountPersistenceAdapter implements LoadAccountPort, UpdateAccountPort {

private final AccountRepository accountRepository;
private final ActivityRepository activityRepository;
private final AccountMapper accountMapper;
@Overrise
public Account loadAccount(
AccountId accountId,
LocalDateTime baselineDate) {

AccountJpaEntity account = accountRepository
.findById(accountId.getValue())
.orElseThrow(EntityNotFoundException::new);
List<ActivityJpaEntity> activities = activityRepository
.findByOwnerSince(accountId.getValue(), baselineDate);
Long withdrawalBalance = orZero(activityRepository
.getWithdrawalBalanceUntil(
accountId.getValue(),
baslineDate));
Long depositBalance = orZero(activityRepository
.getDepositBalanceUntil(
accountId.getValue(),
baselineDate));
return accountMapper.mapToDomainEntity(
account,
activities,
withdrawalBalance,
depositBalance);
}
private Long orZero(Long value) {
return value == null ? 0L : value;
}
@Override
public void updateActivities(Account account) {
for(Activity activity : account
.getActivityWindow()
.getActivities()) {
if(activity.getid() == null) {
activityRepository.save(
accountMapper.mapToJpaEntity(activity));
}
}
}
}

We have a two-way mapping between the Account and Activity domain models and the AccountJpaEntity and ActivityJpaEntity database models. We will talk more about Mapping between Boundaries in the next post.

Where do we put the transaction boundaries?

A transaction should span all write operations that are performed within a certain use case so that all those operations can be rolled back together if one of them fails.

Since the persistence adapter doesn’t know which other database operations are part of the same use case, it cannot decide when to open and close a transaction. We have to delegate this responsibility to the services that orchestrate the calls to the persistence adapter. We can use Transactional annotation for this —

package buckpal.application.service;@Transactional
public class SendMoneyService implements SendMoneyUseCase {
...
}

Reference

These are my notes from this amazing book by Tom Hombergs — Get Your Hands Dirty on Clean Architecture.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store