Backend API application built using hexagonal architecture and implemented with Kotlin's ktor.
- gradle - our build system of choice (using Kotlin DSL)
- kotlin 1.3 - our language of choice
- ktor for creating web application: https://github.com/ktorio/ktor
- ExposedSQL to access database: https://github.com/JetBrains/Exposed
- HikariCP for high-performance JDBC connection pool: https://github.com/brettwooldridge/HikariCP
- Koin for dependency injection: https://insert-koin.io/
- PostgreSQL for database: https://www.postgresql.org/
- kotlin-logging for logging: https://github.com/MicroUtils/kotlin-logging
- HOCON for application configuration: https://github.com/lightbend/config/
- jackson for JSON serialization/deserialization: https://github.com/FasterXML/jacksons
and for testing:
- Testcontainers for testing with a real database in Docker: https://github.com/testcontainers/testcontainers-java
- junit + spek2 for writing tests: https://github.com/spekframework/spek/
and some other misc stuff:
- ktlint for Kotlin checkstyle
- jacoco for code coverage metrics
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.
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
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.
- 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 inports.required
package ("required by domain").
- Service code to provide business logic functionality
- 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 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.
No module dependencies
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.
- Anything else
Platform-specific code. Don't put any of your business logic here.
- 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 interfaceports.required.addressbook.AddressBookItemRepository
and Adapters provides a database-specific implementation of this interface inadapters.db.AddressBookItemRepositoryDbImpl
class. Also Domain needs to generate random AddressBook item and it adds this requirement viaports.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 injectsadapters.clients.randomperson.RandomPersonHttpClient
via dependency injection.
- 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
).
- Avoid any business logic code
Application launcher. Should be a very simple code. Can contain application specific resources.
All other modules.
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.
No module dependencies
- 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 intoports.provided.addressbook.SaveAddressBookItemRequestDto
data class required by Domain's service. - REST controller calls method
addAddressBookItem(...)
declared by interfaceports.provided.AddressBoookService
(and implemented by domain module inAddressBookServiceImpl
class) - 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 methodupsert(...)
(update or insert) declared by interfaceAddressBookItemRepository
(and implemented by Adapters code inAddressBookItemRepositoryDbImpl
class) as well as methodupsert(...)
declared by interfacePostalAddressRepository
. - Domain's service code returns result back to Adapters REST controller when it gets serialized into JSON and returned back to user.
Docker is not required to run this code locally, but makes setup a little easier. We recommend installing it.
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.
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.
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.
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).
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