Nim and Hexagonal Architecture
Implementing Ports and Adapters in Nim for Robust and Maintainable Software Development
Addressing the challenge of developing robust, maintainable, and flexible software applications is often daunting. Hexagonal Architecture, or Ports and Adapters, is an innovative design pattern conceived by Alistair Cockburn. This pattern assists in overcoming these hurdles by detaching the core business logic from external dependencies. This article will explore implementing Hexagonal Architecture using my favourite programming language, Nim, to build clean and modular software.
Understanding Hexagonal Architecture
Hexagonal Architecture is an architectural software pattern popularised by Alistair Cockburn. This pattern promotes decoupling the core application logic from external components such as databases, web frameworks, or external services. This separation ensures that the application remains adaptable to these external components without requiring substantial changes to the core logic.
At the heart of Hexagonal Architecture are three key components:
The Core: This is where the business logic of your applications resides. It should be completely isolated from external dependencies and not know how data is stored, fetched, or presented.
Adapters: Adapters are responsible for bridging the gap between the core and the external world. They include input and output adapters:
Input adapters receive external requests and convert them into a format that the core can understand.
Output adapters take the data produced by the core and present it to the external world in a suitable format.
External Dependencies: These are the external components that your application interacts with, such as databases, APIs, or UI frameworks.
Organising Your Project Structure
To effectively implement Hexagonal Architecture in Nim (and potentially any other language), establishing a well-structured directory setup is essential to separate the core business logic from the adapters.
The presented structure works well for me, as it is sensible and separate concerns. However, it is not strictly by the book.
Domains: The
domains
directory defines your application's core business logic, including domain entities, use cases, and other business rules.Models: The
models
directory stores any data structures or models your application requires. As a caveat, I typically only store models related to the core.Ports: The
ports
directory contains interfaces or contracts that define how the core interacts with the adapters (this will become clearer later on).Adapters: The
adapters
directory defines the logic that connects your application to external components such as databases, APIs, or UI frameworks.
So, what does this typically look like?
project/
src/
domains/
models/
ports/
adapters/
Implementation Time
Now, let's delve into implementing Hexagonal Architecture in Nim, step by step. But before we begin, we should come up with a simple application to build. For this purpose, we'll create a basic wallet that keeps track of transactions.
Apologies in advance, there isn't any syntax highlighting because Hashnode's code block doesn't support Nim syntax yet...
The first step is to initiate a new Nim project. I will be using nimble for this purpose.
nimble init nim-hexagonal-architecture
Now, proceed to create the directories I mentioned earlier (and then some). They should look like this:
nim-hexagonal-architecture/
db/
.gitkeep
src/
domains/
models/
ports/
adapters/
nim_hexagonal_architecture.nim
.env
nim_hexagonal_architecture.nimble
When embarking on a new project like this, I find it beneficial to first establish the structure of my models. In this particular scenario, we are constructing a basic wallet to monitor transactions. As a result, it is logical to create models for both our wallet and transactions. The wallet model will embody the most recent state of the wallet, taking into account its current balance and other relevant information. On the other hand, the transaction model will represent an individual transaction.
By defining these models upfront, we can ensure that the foundation of our application is solid, and that we have a clear understanding of the data we will be working with. This approach allows us to maintain consistency and clarity throughout the development process, ultimately leading to a more robust and well-structured application.
# File: src/models/wallet.nim
import uuids
import ../utils/[uuid]
type
Wallet* = ref object
id*: UUID = DEFAULT_UUID
latestTransactionId*: UUID = DEFAULT_UUID
balance*: int = 0
# File: src/models/transaction.nim
import uuids
import ../utils/[uuid]
type
TransactionType* {.pure.} = enum
CREDIT = "credit"
DEBIT = "debit"
type
Transaction* = ref object
id*: UUID = DEFAULT_UUID
walletId*: UUID = DEFAULT_UUID
transactionType*: TransactionType = CREDIT
amount*: int = 0
You may have observed that we've imported a package named uuids in the src/models/transaction.nim
file. This package provides us with the functionality to generate UUIDs in compliance with the RFC-4122 standard.
In addition to importing the uuids
package, I've also created two const
values, which will be used to set default UUIDs when instantiating or comparing the models later in our application. These default values are particularly helpful in cases where we need to initialize a new transaction or wallet object without a specific UUID or compare an existing object to a default one. By having these default values in place, we can streamline the process of working with our transaction and wallet models, making it easier to manage and maintain our codebase.
# File: src/utils/uuid.nim
import uuids
const DEFAULT_UUID_STRING*: string = "00000000-0000-0000-0000-000000000000"
const DEFAULT_UUID*: UUID = parseUUID(DEFAULT_UUID_STRING)
Now, let's shift our focus to the heart of Hexagonal Architecture by developing the primary business logic that drives our application. To do this, we will begin by working on the domain entity, which represents the core concepts of our system. Simultaneously, we will create our initial and sole port for our core, which will serve as an interface for communication between the core and its external components.
By working on these two aspects concurrently, we can streamline the development process and save time. This is particularly important for the purpose of this article, as our main goal is to explore and demonstrate the implementation of Hexagonal Architecture in Nim. We want to ensure that our attention remains on the architectural pattern itself, rather than getting lost in the intricacies of the wallet's specific functions and features.
# File: src/domain/wallet.nim
import std/[strformat]
import uuids
import ../models/[transaction, wallet, wallet_exception]
import ../ports/[event_store]
import ../utils/[uuid]
type
WalletDomain* = ref object
eventStore*: EventStorePort
proc reduceTransactions(self: WalletDomain, wallet: Wallet, transactions: seq[Transaction]): Wallet =
for _, transaction in transactions:
case transaction.transactionType:
of CREDIT:
wallet.balance += transaction.amount
of DEBIT:
wallet.balance -= transaction.amount
if wallet.balance < 0:
raise InsufficientBalanceException.newException(&"Insufficient balance {$wallet.balance}.")
wallet.latestTransactionNonce += 1
wallet.latestTransactionId = transaction.id
result = wallet
proc processTransaction(self: WalletDomain, walletId: UUID, transactionId: UUID, transactionType: TransactionType, amount: int): Wallet =
if amount < 0:
case transactionType:
of CREDIT:
raise NegativeCreditException.newException(&"Negative credit amount {$amount}.")
of DEBIT:
raise NegativeDebitException.newException(&"Negative debit amount {$amount}.")
let transactions = self.eventStore.getTransactionsByWalletId(walletId)
var wallet = Wallet(id: walletId)
wallet = self.reduceTransactions(wallet, transactions)
if transactionId == wallet.latestTransactionId:
raise DuplicateTransactionException.newException(&"Transaction {$transactionId} already processed.")
if transactionId == DEFAULT_UUID:
raise InvalidTransactionException.newException(&"Invalid transaction id {$transactionId}.")
let transaction = Transaction(
id: transactionId,
transactionType: transactionType,
transactionNonce: wallet.latestTransactionNonce,
amount: amount
)
wallet = self.reduceTransactions(wallet, @[transaction])
self.eventStore.addTransaction(transaction)
result = wallet
proc getWallet*(self: WalletDomain, walletId: UUID): Wallet =
let transactions = self.eventStore.getTransactionsByWalletId(walletId)
if transactions.len == 0:
raise WalletNotFoundException.newException(&"Wallet {$walletId} not found.")
let wallet = Wallet(id: walletId)
result = self.reduceTransactions(wallet, transactions)
proc creditWallet*(self: WalletDomain, walletId: UUID, transactionId: UUID, amount: int): Wallet =
result = self.processTransaction(walletId, transactionId, CREDIT, amount)
proc debitWallet*(self: WalletDomain, walletId: UUID, transactionId: UUID, amount: int): Wallet =
result = self.processTransaction(walletId, transactionId, DEBIT, amount)
# File: src/models/wallet_exception.nim
type
WalletException* = object of CatchableError
WalletNotFoundException* = object of WalletException
InsufficientBalanceException* = object of WalletException
NegativeCreditException* = object of WalletException
NegativeDebitException* = object of WalletException
DuplicateTransactionException* = object of WalletException
InvalidTransactionException* = object of WalletException
You may have noticed the EventStorePort
being used in the code above. This specific component acts as a port connecting our core system to an external dependency, ensuring that the dependency is driven by the core. As an output port, its primary function is to facilitate the retrieval of data from a specific event store, which could be any type of data storage system designed to manage event-driven data. In our specific implementation, the EventStorePort
not only retrieves data from the event store but also acquires data from the same source. You may have also observed that the EventStorePort
has not been implemented, and this is intentional. It's up to the adapter to implement its own logic for the contract provided.
# File: src/ports/event_store.nim
import uuids
import ../models/[transaction]
type
EventStorePort* = ref object of RootObj
method addTransaction*(self: EventStorePort, transaction: Transaction): void {.base.} = discard
method getTransactionsByWalletId*(self: EventStorePort, walletId: UUID): seq[Transaction] {.base.} = discard
Moving forward to the adapter side of the application, we must now develop the logic that dictates how the core retrieves or stores data based on the contract established by the port. To accomplish this, we will employ Sqlite and the norm library to create the Event Store Adapter, which we will fittingly call EventStoreSqliteAdapter
. This is where the real magic takes place.
Recall how we previously defined the EventStorePort
but left its implementation incomplete? By inheriting this reference object, we can now implement the missing logic, enabling it to seamlessly connect with our core. This is done without the core needing any knowledge of the adapter's implementation details (and vice-versa), thus maintaining a clean separation of concerns.
In addition to implementing the ports methods, we will also define our adapter-related conversion, methods, and models. This will ensure that the data being retrieved or stored is properly formatted and compatible with the core's expectations. By carefully constructing the EventStoreSqliteAdapter
, we can create a robust and efficient system for managing data within our application, all while adhering to the principles of the Hexagonal Architecture.
# File: src/adapters/event_store/event_store_sqlite.nim
import norm/[pool, sqlite]
import std/[strutils]
import uuids
import ./[event_store_transaction]
import ../../models/[transaction]
import ../../ports/[event_store]
type
EventStoreSqliteAdapter = ref object of EventStorePort
dbPool: Pool[DbConn]
proc newEventStoreSqliteAdapter*(dbPool: Pool[DbConn]): EventStoreSqliteAdapter =
EventStoreSqliteAdapter(dbPool: dbPool)
proc createTables*(self: EventStoreSqliteAdapter): void =
withDb self.dbPool:
db.createTables(newEventStoreTransaction())
method addTransaction*(self: EventStoreSqliteAdapter, transaction: Transaction): void =
var eventStoreTransaction = parseTransaction(transaction)
withDb self.dbPool:
db.insert(eventStoreTransaction)
method getTransactionsByWalletId*(self: EventStoreSqliteAdapter, walletId: UUID): seq[Transaction] =
var transactions: seq[Transaction]
var eventStoreTransactions = @[newEventStoreTransaction()]
withDb self.dbPool:
db.select(eventStoreTransactions, "walletId = ? ORDER BY version ASC", $walletId)
for eventStoreTransaction in eventStoreTransactions:
transactions.add(parseEventStoreTransaction(eventStoreTransaction))
result = transactions
# File: src/adapters/event_store/event_store_transaction.nim
import norm/[model, pragmas]
import std/[strutils]
import uuids
import ../../models/[transaction]
type
EventStoreTransaction* = ref object of Model
transactionId*: string
walletId* {.uniqueGroup.} : string
transactionType*: string
transactionNonce* {.uniqueGroup.}: int
amount*: int
proc newEventStoreTransaction*(transactionId: string = "", walletId: string = "", transactionType: string = "", transactionNonce: int = 0, amount: int = 0): EventStoreTransaction =
EventStoreTransaction(
transactionId: transactionId,
walletId: walletId,
transactionType: transactionType,
transactionNonce: transactionNonce,
amount: amount
)
proc parseTransaction*(transaction: Transaction): EventStoreTransaction =
EventStoreTransaction(
transactionId: $transaction.id,
walletId: $transaction.walletId,
transactionType: $transaction.transactionType,
transactionNonce: transaction.transactionNonce,
amount: transaction.amount
)
proc parseEventStoreTransaction*(eventStoreTransaction: EventStoreTransaction): Transaction =
Transaction(
id: parseUUID(eventStoreTransaction.transactionId),
walletId: parseUUID(eventStoreTransaction.walletId),
transactionType: parseEnum[TransactionType](eventStoreTransaction.transactionType),
transactionNonce: eventStoreTransaction.transactionNonce,
amount: eventStoreTransaction.amount
)
Now that we have a core, a port, and an adapter, we are nearly done. However, we still need a few more components. The application, in its current state, isn't very useful since it lacks a way to drive it. To address this issue, we will implement an API adapter for the core using prologue. I apologise for the upcoming series of code blocks, but it's necessary.
# File: src/adapters/api/api.nim
import prologue
import ../../domains/[wallet]
import ./wallet/[wallet_controller, wallet_inject_wallet_domain_middleware]
proc configureAndRunServer*(wallet: WalletDomain, settings: Settings) =
var server = newApp(settings)
server.use(injectWalletDomainMiddleware(wallet))
server.addRoute(@[
pattern("/{walletId}", getWalletHandler, HttpGet),
pattern("/{walletId}/credit", creditWalletHandler, HttpPost),
pattern("/{walletId}/debit", debitWalletHandler, HttpPost)
], "/wallets")
server.run(WalletContext)
# File: src/adapters/api/wallet/wallet_controller.nim
import prologue
import std/[json]
import uuids
import ../../../domains/[wallet]
import ../../../models/[wallet, wallet_exception]
import ./[wallet_dto, wallet_inject_wallet_domain_middleware]
proc getWalletHandler*(ctx: Context) {.async.} =
let ctx = WalletContext(ctx)
let walletId = parseUUID(ctx.getPathParams("walletId"))
try:
let wallet = ctx.walletDomain.getWallet(walletId)
resp jsonResponse(%WalletResponseDto(
transactionId: $wallet.latestTransactionId,
transactionNonce: wallet.latestTransactionNonce,
balance: wallet.balance
), Http200)
except WalletNotFoundException as exception:
resp(jsonResponse(%*{
"message": exception.msg
}, Http404))
except:
resp(jsonResponse(%*{
"message": "Something went wrong."
}, Http500))
proc creditWalletHandler*(ctx: Context) {.async.} =
let ctx = WalletContext(ctx)
let walletId = parseUUID(ctx.getPathParams("walletId"))
let transaction = to(parseJson(ctx.request.body()), WalletRequestDto)
try:
let wallet = ctx.walletDomain.creditWallet(
walletId,
parseUUID(transaction.transactionId),
transaction.amount
)
resp(jsonResponse(%WalletResponseDto(
transactionId: $wallet.latestTransactionId,
transactionNonce: wallet.latestTransactionNonce,
balance: wallet.balance
), Http201))
except DuplicateTransactionException:
let wallet = ctx.walletDomain.getWallet(walletId)
resp(jsonResponse(%WalletResponseDto(
transactionId: $wallet.latestTransactionId,
transactionNonce: wallet.latestTransactionNonce,
balance: wallet.balance
), Http202))
except InsufficientBalanceException as exception:
resp(jsonResponse(%*{
"message": exception.msg
}, Http400))
except NegativeCreditException as exception:
resp(jsonResponse(%*{
"message": exception.msg
}, Http400))
except:
resp(jsonResponse(%*{
"message": "Something went wrong."
}, Http500))
proc debitWalletHandler*(ctx: Context) {.async.} =
let ctx = WalletContext(ctx)
let walletId = parseUUID(ctx.getPathParams("walletId"))
let transaction = to(parseJson(ctx.request.body()), WalletRequestDto)
try:
let wallet = ctx.walletDomain.debitWallet(
walletId,
parseUUID(transaction.transactionId),
transaction.amount
)
resp(jsonResponse(%WalletResponseDto(
transactionId: $wallet.latestTransactionId,
transactionNonce: wallet.latestTransactionNonce,
balance: wallet.balance
), Http201))
except DuplicateTransactionException:
let wallet = ctx.walletDomain.getWallet(walletId)
resp(jsonResponse(%WalletResponseDto(
transactionId: $wallet.latestTransactionId,
transactionNonce: wallet.latestTransactionNonce,
balance: wallet.balance
), Http202))
except InsufficientBalanceException as exception:
resp(jsonResponse(%*{
"message": exception.msg
}, Http400))
except NegativeDebitException as exception:
resp(jsonResponse(%*{
"message": exception.msg
}, Http400))
except:
resp(jsonResponse(%*{
"message": "Something went wrong."
}, Http500))
# File: src/adapters/api/wallet/wallet_dto.nim
type
WalletRequestDto* = object
transactionId*: string
amount*: int
WalletResponseDto* = object
transactionId*: string
transactionNonce*: int
balance*: int
# File: src/adapters/api/wallet/wallet_inject_wallet_domain_middleware.nim
import prologue
import ../../../domains/[wallet]
type
WalletContext* = ref object of Context
walletDomain*: WalletDomain
proc setWallet*(self: WalletContext, walletDomain: WalletDomain) =
self.walletDomain = walletDomain
proc injectWalletDomainMiddleware*(walletDomain: WalletDomain): HandlerAsync =
result = proc(ctx: Context) {.async.} =
let ctx = WalletContext(ctx)
ctx.setWallet(walletDomain)
await switch(ctx)
Impressive! We now have a driving adapter that operates the core. Once again, the core doesn't need to know about the implementation details of the API adapter; it only needs to be aware that it's being driven. It is worth noting, if your domain is more complex, you may want to write a port for the API adapter to implement.
The last thing we need to do is connect everything. In the nim_hexagonal_architecture.nim
file, we'll do just that — some straightforward orchestration. I've also included dotenv for configuring environment variables.
# File: src/nim_hexagonal_architecture.nim
import dotenv
import norm/[pool, sqlite]
import prologue
import std/[os, strutils]
import adapters/api/[api]
import adapters/event_store/[event_store_sqlite]
import domains/[wallet]
when isMainModule:
dotenv.load()
let eventStoreAdapter = newEventStoreSqliteAdapter(newPool[DbConn](parseInt(getEnv("DB_POOL", "16"))))
let walletDomainInstance = WalletDomain(eventStore: eventStoreAdapter)
let serverSettings = newSettings(
appName = getEnv("SERVER_NAME", "Wallet"),
address = getEnv("SERVER_ADDRESS", "127.0.0.1"),
port = Port(parseInt(getEnv("SERVER_PORT", "8080"))),
debug = parseBool(getEnv("SERVER_DEBUG", "true"))
)
eventStoreAdapter.createTables()
configureAndRunServer(walletDomainInstance, serverSettings)
# File: .env
DB_HOST=./db/wallet.db
DB_POOL=8
SERVER_NAME=Wallet
SERVER_ADDRESS=0.0.0.0
SERVER_PORT=1337
SERVER_DEBUG=true
So there you have it — a working example of Hexagonal Architecture in Nim. You can find the full code here. There are a few more things I'd like to do, such as writing both unit tests and integration tests for this, but that will be for another time.
Final Words
In conclusion, Hexagonal Architecture offers an effective way to develop robust, maintainable, and flexible software applications by separating the core business logic from external dependencies. Implementing this design pattern can lead to clean, modular software. We've explored the key components of Hexagonal Architecture, how to structure your project, and how to implement it step by step in Nim by creating a basic wallet for tracking transactions. By adhering to this pattern, you can create software that is adaptable to changes in external components without requiring substantial modifications to the core logic. Thus, Hexagonal Architecture proves to be a powerful design pattern for software development.