Nim and Hexagonal Architecture

Photo by Andy Beales on Unsplash

Nim and Hexagonal Architecture

Implementing Ports and Adapters in Nim for Robust and Maintainable Software Development

·

12 min read

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:

  1. 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.

  2. Adapters: Adapters are responsible for bridging the gap between the core and the external world. They include input and output adapters:

    1. Input adapters receive external requests and convert them into a format that the core can understand.

    2. Output adapters take the data produced by the core and present it to the external world in a suitable format.

  3. 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.

  1. Domains: The domains directory defines your application's core business logic, including domain entities, use cases, and other business rules.

  2. 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.

  3. Ports: The ports directory contains interfaces or contracts that define how the core interacts with the adapters (this will become clearer later on).

  4. 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.