Skip to content

Template project to build ktor-based multi-module web service with Kotlin using Hexagonal architecture

Notifications You must be signed in to change notification settings

mobiletoly/ktor-hexagonal-multimodule

Repository files navigation

Hexagonal architecture for AddressBook

Backend API application built using hexagonal architecture and implemented with Kotlin's ktor.

Overview

What tools/frameworks do we use?

and for testing:

and some other misc stuff:

  • ktlint for Kotlin checkstyle
  • jacoco for code coverage metrics

Goal

Simple Address Book web service to add/delete/fetch address book items. It demonstrates database access as well as using external REST service to assist in generating random address book items. You can find Postman collection to access this web service in integration/postman directory.

Hexagonal architecture overview

Project uses Hexagonal (or "ports and adapters") architecture (google it, it is really cool) to separate functionality into 5 modules - domain, adapters, ports, shared, app, with only few modules having dependencies on other modules.

So here is a brief overview of each module:

  • domain - contains business logic
  • adapters - provides platform/framework specific functionality (e.g. your database repository classes go here)
  • ports - set of interfaces and data classes (aka POJO) that is used by domain module to interact with adapters module.
  • app - application launcher

Now let's take a look at each module in more details

Domain module

This is a module containing a business logic of our application. The main idea is that business logic should have no idea about frameworks used to implement an application, it should have zero knowledge about database used, database ORM technology used, HTTP client used or what is Kafka. For example in our application domain module contains a single service "AddressBookService" that has an implementation of our core business logic - add, update and delete Address Book items as well as some auxiliary functionality such as generating random items for Address Book. It does have a single dependency.

Depends on
  • ports module. Domain uses this module to provide AddressBookService interface to adapters module by using ports.provided package ("provided by domain") and expects some functionality from Adapters in ports.required package ("required by domain").
What should be in domain
  • Service code to provide business logic functionality
What should not be in domain
  • Frameworks (less are better)
  • Database, network clients (no SQL Exposed calls, no Kafka calls, no SQS call etc)
  • No transport logic (e.g. no JSON parsing, no JSON annotations etc). It might be tempting to use JSON objects received from web service controllers, but don't do this, use ports for DTO (Data Transfer Objects) to communicate with adapters module. It will require extra work and some extra effort to write data structures that might look very similar, but when you decide to change your JSON payload, and your business logic will remain the same - it will pay off.
  • DAO

Ports module

Ports is a bridge between Adapters and Domain. Domain declare interfaces it requires from Adapters and put them in ports.required package. Then Adapters will provide implementations (injected via Dependency Injection, we use Koin framework in our application). If Domain wants to share some functionality (usually business services) with Adapters - then it will provide interfaces in ports.provided for Adapters to use. Also, Ports can declare data classes (or POJOs) to allow Domain and Adapters communicate to each other.

Depends on

No module dependencies

What should be in ports
  • ports.provided - interfaces for provided Domain services to be called from Adapters (in our case domain services will be called from web service routes from adapters module). DTO objects for Domain<=>Adapters communication can be declared here as well.
  • ports.required - interfaces for Adapters services required by Domain to perform its business logic.
What should not be in ports
  • Anything else

Adapters module

Platform-specific code. Don't put any of your business logic here.

Depends on
  • ports module. Adapters use this module to fulfill Domain need. For example Domain needs an access to persistent storage (and Domain does not care how data is stored) to add/delete/update Address Book item. In this case Domain will add its requirements into ports.required package and Adapters should fulfill these requirements. In our app Domain declares an interface ports.required.addressbook.AddressBookItemRepository and Adapters provides a database-specific implementation of this interface in adapters.db.AddressBookItemRepositoryDbImpl class. Also Domain needs to generate random AddressBook item and it adds this requirement via ports.required.RandomPersonClient interface and lets Adapters model to choose what method to choose (pick from database, generate using its own algorith etc). In our case Adapters decided to call free 3rd party REST service to fetch for random names and contact information and injects adapters.clients.randomperson.RandomPersonHttpClient via dependency injection.
What should be in adapters
  • Web Service controllers (in our app it is ktor handlers)
  • HTTP/REST clients
  • Kafka code to send or receive data
  • Database repositories to provide access to underlying database.
  • DAO objects. Yes, DAO objects should not be in ports or adapters modules if they contain framework-specific code or annotations (e.g. JPA annotations). Exceptions can be made, for example Exposed SQL uses plain data classes and we decided to put them in ports, therefore we use this classes not only as DAO but as DTO objects as well (something that we transfer between Adapters and Domain). In your case you might want to consider using DAO objects in Adapters only and have transformers to convert DAO objects to DTO objects (DTO objects declared in ports.provided).
What should not be in adapters
  • Avoid any business logic code

App module

Application launcher. Should be a very simple code. Can contain application specific resources.

Depends on

All other modules.

Shared module

shared module should not be used too often. It may contain some utility functionality, for example in our app we have some logger utility functions in the shared module, because logging required in both adapter and domain modules.

Depends on

No module dependencies

Example of workflow in modules

  1. User performs HTTP POST request to /addressBookItems to create new AddressBook item. This request handled by REST controller in adapters.routes.AddressBookItemRoute class. JSON payload is deserialized and copied into ports.provided.addressbook.SaveAddressBookItemRequestDto data class required by Domain's service.
  2. REST controller calls method addAddressBookItem(...) declared by interface ports.provided.AddressBoookService (and implemented by domain module in AddressBookServiceImpl class)
  3. Domain code in class domain.addressbook.AddressBookServiceImpl performs business logic related to validating and storing AddressBook item received as DTO object from REST controller. At some point of time domain logic requires storing of new item in persistent storage. Domain code calls method upsert(...) (update or insert) declared by interface AddressBookItemRepository (and implemented by Adapters code in AddressBookItemRepositoryDbImpl class) as well as method upsert(...) declared by interface PostalAddressRepository.
  4. Domain's service code returns result back to Adapters REST controller when it gets serialized into JSON and returned back to user.

Setup

Prerequisites

Docker

Docker is not required to run this code locally, but makes setup a little easier. We recommend installing it.

PostgreSQL

If you have a local PostgreSQL running on your computer - you are all set. If you don't have PostgreSQL installed, you can do it now by installing from: https://www.postgresql.org/download/ and set it up with user name and password that can later be used to configure.

Another option is to create a docker image with PostgreSQL database running:

$ docker run --name localpostgres -d -p 5432:5432 -e POSTGRES_PASSWORD=postgresspass postgres:alpine

This will create and run a local instance of PostgreSQL database with user name "postgres" and password "postgresspass".

Or use

$ docker start localpostgres

if localpostgres container was already created.

Make sure to create addrbook database in your PostgreSQL instance.

Run application locally

Run application with gradle

Application uses HOCON configuration files and some parameters rely on environment variable, so you need to setup them first:

APP_DEPLOYMENT_ENV=local;APP_DB_USERNAME=postgres;APP_DB_PASSWORD=postgresspass;APP_DB_URI=jdbc:postgresql://localhost:5432/addrbook;APP_VERSION=0.0;APP_BUILD_NUMBER=0

  • Deployment target. Application can have multiple configurations (local, dev, sit, prod etc) and therefore multiple resource files, such as adapters/src/main/resources/config-local.conf (config-prod.conf etc) are available. Depending on a target specified in APP_DEPLOYMENT_ENV environment variable - different configuration files will be selected

      $ export APP_DEPLOYMENT_ENV=local
    
  • Application version:

      $ export APP_VERSION=0.1
    
  • Application build number:

      $ export APP_BUILD_NUMBER=1
    
  • PostgreSQL database username, password and connection URI:

      $ export APP_DB_USERNAME=postgres
      $ export APP_DB_PASSWORD=postgresspass
      $ export APP_DB_URI=jdbc:postgresql://localhost:5432/addrbook
    

Next step is to run application via gradle:

    ./gradlew build :app:run --args='-config=app/src/main/resources/application-dev.conf'

If you don't specify --args argument, then by default application.conf will be loaded. Both files are very similar, but application-dev.conf can contain some settings helpful during a development phase (e.g. auto-reload support).

Application exposes HTTP 8080 port for it's API.

Run application with IntelliJ

Import addrbook-hexagonal-ktor project into your IntelliJ IDE. Choose Run / Edit Configuration menu to create new launch configuration. On the left side click [+] and select Application. Name it API Server (or pick other name) and fill up some essential fields:

  • Main class: io.ktor.server.netty.EngineMain
  • VM options: -Dkotlinx.coroutines.debug
  • Program arguments: -config=app/src/main/resources/application-dev.conf
  • Environment variables: APP_DEPLOYMENT_ENV=local;APP_DB_USERNAME=postgres;APP_DB_PASSWORD=postgresspass;APP_DB_URI=jdbc:postgresql://localhost:5432/addrbook;APP_VERSION=0.1;APP_BUILD_NUMBER=1
  • Use classpath or module: addrbook-hexagon-ktor.app.main

You should be able to run/debug your app now.

Run with Docker locally

Build an application first:

    $ ./gradlew build

Then upload and tag it in docker:

    $ docker build -t addrbook-api-server . 

Now you are ready to lunch it:

$ docker run -it \ 
    -e APP_DEPLOYMENT_ENV=local \
    -e APP_VERSION=0.1 \
    -e APP_BUILD_NUMBER=1 \
    -e APP_DB_USERNAME=postgres \
    -e APP_DB_PASSWORD=postgresspass \
    -e APP_DB_URI=jdbc:postgresql://your-local-ip-address:5432/addrbook \
    -p 8080:8080
    --rm addrbook-api-server

Make sure to replace your-local-ip-address in APP_DB_URI in command above to an actual IP address of your machine that you can find with ifconfig or ipconfig shell commands (you cannot use localhost anymore, because localhost inside AddressBook application docker container will be pointing to that container instead of your host machine).

Generate coverage report

To generate a coverage report you must run:

$ ./gradlew clean jacocoFullReport

It will run unit tests and generate coverage report in HTML format into root project's directory: ./build/reports/jacoco/jacocoFullReport/html

About

Template project to build ktor-based multi-module web service with Kotlin using Hexagonal architecture

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published