Skip to content

Architecture

This document describes abstract architecture design which should be able to apply to any use-case in flank scope, starting from user interface and ending on remote API calls. Use it as a reference for explaining common abstract problems around implementation.

Table of contents

  1. Motivation
  2. Goals
  3. Scalability
  4. Layers
  5. Presentation
    1. Responsibilities
    2. Constrains
    3. How to scale
    4. Dependencies
  6. Domain
    1. Execution context
    2. Top-level function
    3. Low-level function
    4. Responsibilities
    5. Constrains
    6. How to scale
    7. Dependencies
    8. Static
    9. Dynamic
    10. Both
  7. Tool
    1. How to scale
    2. Dependencies
  8. API
    1. How to scale
    2. Dependencies
  9. Client
    1. How to scale
    2. Dependencies
  10. Adapter
    1. How to scale
    2. Dependencies
  11. Implementation
    1. Public API
    2. Components composition
    3. Code composition
    4. Vertical
    5. Horizontal
    6. Horizontal Layered

Motivation

Without well-defined architecture, the application can grow in an uncontrolled way. This typically increases the amount of unwanted redundancy, unneeded calls, and unnecessary logical operations which as result makes code harder to understand, more error-prone, and sometimes even impossible to scale.

Goals

The architecture should help achieve the following goals:

  • Organize implementation into restricted logical layers.
  • Divide implementation into small and easy-to-understand parts.
  • Identify scalability vectors for each part of the architecture.
  • Make implementation easy to navigate through.
  • Optimize the amount of code for implementation.
  • Make implementation less error-prone.

Scalability

The design is specifying two types of scaling:

  • Horizontal - by adding atomic components that are not related to each other, but must meet common requirements.
  • Vertical - by expanding one component for new features.

Typically, horizontal scaling is preferred when vertical scaling become to break the single responsibility principle.

Layers

The example diagram that is exposing relations between layers:

architecture_template

Presentation

The front-end layer of the flank application.

Responsibilities

Constrains

How to scale

Dependencies

Domain

Is the implementation of the application business logic.

Exposes its own API as a one, or many public extension functions, called top-level functions. Each top-level function have its own execution context, can produce a structured output during the execution and can be composed of one or more low-level functions.

This layer can be considered as a standalone library, that is providing access to business logic through pure kotlin functions.

Execution context

The context can provide arguments and dynamic functions required by the execution. Its name should reflect the related use case.

Top-level function

Is a public function placed in root of the domain package. Is responsible to implement domain logic directly or compose it using low-level functions, tools or API. For simplification, consider a simple common type for all top-level functions.

typealias UseCase<A> = A.() -> Unit

Where A is a generic type that is representing the execution context.

Low-level function

The low-level function is useful when it comes to dividing complex top-level function into the composition of smaller chunks or the reuse of some logic in many top-level functions. It is crucial in keeping the composition of low-level functions flat. More nesting in-depth can make a code much harder to understand and maintain.

Responsibilities

  • Contains the business logic of the application
  • Provide access to the use cases through formalized API

Constrains

How to scale

Dependencies

The domain layer shouldn't implement complicated or specialized operations by itself or use third-party libraries directly. Instead of this, it can depend on dedicated internal tools and APIs, that are designed to exactly meet domain requirements. There are 2 types of domain dependencies:

Static

The dependencies that are providing its API through static imports.

It's dedicated to the tools that don't need to be mocked for unit testing.

In details the static dependency:

  • CAN:
    • Implement algorithms.
    • Generate files.
    • Parse files (only if can generate it also)
    • Format data.
    • Parse data
  • CANNOT:
    • Make network calls.
    • Use external applications (shell, SQL server, etc..).
    • Operate on binary files that need to be provided.

Dynamic

The dependencies that are provided to the domain through the execution context, as a reference.

It's dedicated to the tools that need to be mocked for unit testing.

  • CAN:
    • Make network calls.
    • Use external applications (shell, SQL server, etc..).
    • Operate on binary files that need to be provided.
    • Generate data structures.
  • CANNOT:
    • Generate files.
    • Implement algorithms.
  • SHOULD NOT:
    • Parse formatted data (except simple strings like date).
    • Format data (except simple string formatting).

Both

Additionally, both static and dynamic:

  • CAN:
    • Specify data structures.
    • Map data structures.

Tool

The layer that groups various atomic tools required by the domain. Mainly static dependencies or not client specific dynamic dependencies. Typically, tools are specialized to solve one, or a group of related problems like:

  • parsing and formatting
  • calculating
  • mapping

Notice that, tools are solving specialized problems that are meeting domain requirements, but should be designed as standalone libraries that do know nothing about the whole domain problem. Instead, just solving well the small highly isolated part. Designing tools as standalone libraries makes the code more decoupled and easier to reuse if needed.

How to scale

  • Horizontal - as a group of libs just by adding more standalone tools if needed.

Dependencies

  • third-party library

API

The light-weight layer that is specifying structures and functional interfaces for client operations. Like tool this layer must exactly meet the domain requirements and specify public API designed for it. Unlike the tool, it cannot define any implementation, so it can be scaled horizontally by adding new not unrelated scopes.

How to scale

  • Horizontal - by adding more namespaces for structures and functional interfaces.

Dependencies

  • Only standard libraries

Client

The client-side specific operations not related directly to the domain. Typically, there are two purposes for this layer implementation:

  • Is necessary to create a library wrapper for remote protocol, driven on WS, REST, TCP, etc...
  • The third-party library is not convenient and requires some adjustments.

How to scale

  • Along with third-party API changes.

Dependencies

  • Network libraries
  • third-party client library

Adapter

This layer is adapting client or third-party libraries to structures and interfaces, specified in the API layer.

How to scale

Dependencies

one of or many:

Implementation

For convenience and clarity, the code should be written in a functional programming style. It's mandatory to avoid the OOP style which almost always makes things much more complicated than should be.

Public API

Any application or library must always have a public API and an internal/private part. For convenience keep public functions and structures in the root package, so the API will be easy to find. Additionally, if the component:

  • is providing accessibility to public API's with additional structures. -It is mandatory to keep the public structures and functions distinct from internal implementation which should be kept in nested package(s).
  • is just a simple tool with a compact implementation that is not specifying many structures. - Private implementations can be kept in the same file, just behind the public API or even the whole tool can be delivered as one public function if the implementation is simple enough.

DO NOT keep multiple public functions along with internal implementations in the same file or package, because it is messes up the public API, which makes code harder to analyze and navigate.

Components composition

Business logic shouldn't implement complicated tools on its own because it is can mess up crucial high-level implementations making it harder to understand. Instead, it should be decomposed into high-level use-case implementations that operate on tools provided by specialized components.

Code composition

Typically, when huge features are divided into smaller functions and one of those functions is a (public) root, the functions can be composed in two different ways.

Vertical

The preceding function is calling the following, so the composition of functions is similar to a linked list.

vertical-composition

Try to AVOID this pattern where possible, especially in business logic. In some situations it can be even worse than one huge monolithic function with comments, for example when internal functions are not ordered correctly. Understanding the feature composed in vertical style, almost always require analyzing the whole chain of functions which typically is not efficient.

Horizontal

Root function is controlling independent internal and specialized functions.

horizontal-composition

This approach gives a fast overview of high-level implementation but is hiding the details not important from the high-level perspective. Comparing to vertical composition where the cost of manual access to internal functions (jumping on references in IDE) in the worst-case scenario is n, the horizontal composition almost always gives 1 on the same layer (or 2 taking private functions into account if exist).

Horizontal-Layered

An example of horizontal composition in layered architecture can look as following:

horizontal-composition-layered