Hexagonal Architecture

Basic introduction using Python

2022-10 Victor Dorneanu

1. Introduction

Why should you chose hexagonal architecture ?

  • clean architecture helps with Secure by Design™
    • adopt “shift left” mentality and guarantee (application) Security
    • add Security to the early stages of the SDLC
  • clear separation of concerns (fundamental in Information hiding)
  • help with technical debt

1.1. Hexagonal Architecture in a nutshell

1.1.1. It’s all about business logic

  • explicit separation between what code is internal to the application and what is external

The idea of Hexagonal Architecture is to put inputs and outputs at the edges of our design. Business logic should not depend on whether we expose a REST or a GraphQL API, and it should not depend on where we get data from — a database, a microservice API exposed via gRPC or REST, or just a simple CSV file.
Ready for changes with hexagonal architecture | netflix blog

1.2. Ports & Adapters

1.2.1. Ports

  • A port is an input to your application and the only way the external world can reach it
  • Examples:
    • HTTP/gRPC servers handling requests from outside to your business application
    • CLI commands doing something with your business use cases
    • Pub/Sub message subscribers

1.2.2. Adapters

  • Adapters are something that talk to the outside world
  • you have to adapt your internal data structures to what it’s expected outside
  • Examples
    • SQL queries
    • HTTP/gRPC clients
      • file readers/writers
  • Some distinguish between
    • primary/driving adapters
    • secondary/driven adapters

1.2.3. Application logic/core

  • a layer that glues together other layers
  • also known as the place where “use cases” live at
  • this is what our code is supposed to do (it’s the main application)
  • the application logic depends only on own domain entities
    • if you cannot say which database is used for storing entities, that’s a good sign
    • if you cannot say which URLs it calls for doing authentication, that’s a good sign
    • in general: this layer is “free” of any concrete implementation details

1.3. Language-agnostic implementation

  • I’ll describe use cases for a concrete problem
  • There are several actors involved
    • uploader
    • product manager
  • I’ll abstractions to define relationships between
    • use cases and
    • concrete implementations

1.3.1. Uploader: use case description

As an uploader I’d like to upload documents to some infrastructure. After successful upload I’d like to get a link I can share with my friends.
– Uploader

Easy! Some observations:

  • the uploader doesn’t mention where (storage) the documents should be uploaded to
  • the uploader doesn’t mention how he/she would like to upload documents
    • via web client?
    • via mobile phone?
    • via CLI?

1.3.2. Product Manager: use case description

As a product manager I’d like to see how many documents each uploader uploads and how many times he/she shares the link.
– Product Manager

Also easy! Again some observations:

  • PM doesn’t mention where the metrics should be sent to
  • PM doesn’t mention how she would like to view the metrics
    • Via Web?
    • On her smartphone?
    • Using the CLI?

1.3.3. Use abstractions

  • post-pone decision about concrete implementation details / concrete technologies
  • focus on business cases and use abstractions (aka interfaces) whenever possible
  • separate concerns
  • you can apply this on different levels

2. Software Architecture: High-Level


Figure 3: Architecture of some imaginary application which uploads some documents to a storage system

2.1. Software Architecture: High-Level (explanations)

  • The Business Domain ❶ contains
    • Entities (there is only Document)
    • Services (DocumentUploadService)
    • Repositories (DocumentStorageRepository and DocumentMetricsRepository)
      • basically interfaces to be implemented by the Secondary Adapters
  • The Secondary (Driven) Adapters implement
    • the repositories/interfaces defined in the Business Domain
  • The Primary (Driving) Adapters ❷ use the Services
    • a CLI could implement the DocumentUploadService for the terminal
    • a HTTP server could serve the DocumentUploadService via HTTP

3. Domain

  • everything related to the business case
    • uploader wants to upload some document
    • PM wants to have some metrics
  • contains
    • Entities
    • Services
    • Repositories/Interfaces


Figure 4: The business domain contains the application login and uses abstractions (interfaces) for defining interactions.

3.1. Entities

class Document ():                        ❶
	"""A document is an entity"""
	def __init__(self, path: FilePath):
		self._file_path = path            ❷

	def meta(self):
	"""Display meta information about the file"""
		print("Some information")
  • We only have Document ❶ as an entity
  • The constructor will set an instance variable ❷ for storing the file path

3.2. Services

3.2.1. UploadDocumentService

class UploadDocumentService:           ❶
	"""Upload a document to storage repository"""

	def __init__(
		self,
		storage_repository: DocumentStorageRepository,
		metrics_repository: DocumentMetricsRepository,
	):
		self._storage_repo: DocumentStorageRepository
		self._metrics_repo: DocumentMetricsRepository

	def upload_document(self, document: Document):   ❷
		self._storage_repository(document)
		self._metrics_repo.send_metrics()
  • ❶ We have an UploadDocumentService
  • ❷ this service implements upload_document(document: Document)

4. Repositories

The repositories are basically interfaces for the secondary (driven) adapters. In our case we have:

  • a repository for dealing with document storage
    • define how to save documents
    • define how to search for documents
    • define how to delete a document
  • a repository for dealing with metrics


Figure 5: The interfaces are implemented by adapters which use concrete 3rd-party libraries for “external” calls.

4.1. Storage

from import abc import ABC, abstractmethod

class DocumentStorageRepository(ABC):      ❶
	""" Driven port defining an interface for storing documents """
	@abstractmethod
	def save(self, path: FilePath):
		pass

	@abstractmethod
	def search(self, uuid: uuid):
		pass

	@abstractmethod
	def delete(self, uuid: uuid):
		pass

DocumentStorageRepository is an interface (a port) for describing which methods a document storage implementation should have

4.2. Metrics

from import abc import ABC, abstractmethod

class DocumentMetricsRepository(ABC):    ❶
""" Driven port defining an interface for sending metrics about documents"""
@abstractmethod
	def send_metrics(self):
		pass

DocumentMetricsRepository is an interface (a port) for describing which methods a metrics system implementation should have

5. Adapters

5.1. Primary / driving

5.1.1. HTTP handler

The handler only depends on entities and services.

# HTTP controller
from flask import Flask, request
from domain.services import UploadDocumentService
from domain.entities import Document

app = Flask(__name__)

class HTTPController:
    def __init__(self, upload_service: UploadDocumentService):  ❶
	    self.upload_service = upload_service

	@app.route(self, '/upload', methods=['POST'])
	def upload_document():
		"""Uploads a document using DocumentUploadService """
		# Create a document object
		doc = entities.Document(request.form.get("document_path"))
        self.upload_service.upload_document(doc)                ❷
  • ❶ The HTTPController expects a UploadDocumentService
    • we use an abstraction rather than a concrete implementation
  • ❷ We use the upload_service to upload a document (of type entities.Document)

5.1.2. CLI handler

The handler only depends on entities and services.

# Simpl CLI controller
import sys, getopt
from domain.services import UploadDocumentService
from domain.entities import Document

class CLIController:
    def __init_(self, arguments, upload_service: UploadDocumentService):  ❶
	    self.args = arguments
		self.upload_service = upload_service

	def upload(self):
	   inputfile = ''
	   try:
		  opts, args = getopt.getopt(self.args,"hi:",["ifile="])
	   except getopt.GetoptError:
		  print 'cli.py -i <inputfile>'
		  sys.exit(2)

	   for opt, arg in opts:
		  if opt == '-h':
			 print 'cli.py -i <inputfile>'
			 sys.exit()

		  elif opt in ("-i", "--ifile"):
			 inputfile = arg


	   # Create document object
	   doc = Document(inputfile)
	   service.upload_service.upload_document(doc)                   ❷
  • CLIController expects an UploadDocumentService
  • uses its upload_document method to upload ❷ a document (of type entities.Document)

5.2. Secondary / driven

5.2.1. S3

from domain.entities import Document

class S3StorageRepository:
    """Implements DocumentStorageRepository """
	def __init__(self):
	    # Initiate here connection to AWS S3
		self.s3_conn = ...

	def save(self, doc: Document):                   ❶
	    # Read file contents
		content = get_file_content(doc)
		self.s3_conn.create_new_object(content, ...) ❷

	def search(): List[Document]                     ❸
	    # Search in S3 buckets and return list of documents
		doc_list = []
		for f in results:
		    # Create a document
			doc = Document(f.path())
			doc_list.append(doc)
		return doc_list
	...
  • S3StorageRepository implements DocumentStorageRepository (interface)
  • save takes as an argument a Document and ❷ defines how and where to save the document
    • these are implementation specific details
  • search will return a list of Document
    • instead of a S3 object

5.2.2. ELK

from domain.entities import Document

class ELKMetricsRepository:
    """Implements DocumentMetricsRepository """
	def __init__(self):
	    # Initiate here connection to ELK stack
		self.elk_conn = ...             ❶

    def send_metrics():
	    self.elk_conn.send_data(...)    ❷
  • ELKMetricsRepository implements DocumentMetricsRepository (interface)
  • ❶ and ❷ are ELK (Elasticsearch, Logstash, Kibana) specific implementation details

6. Package MAIN

Here is where everything comes together.

package main

def main():
        # Create new S3 storage repository
        s3_repo = repositories.S3StorageRepository()      ❶

        # Create new ELK metrics repository
        elk_repo = repositories.ELKMetricsRepository()    ❷

        # Create new upload service                       ❸
        upload_service = services.UploadDocumentService(s3_repo, elk_repo)

        # Create new HTTP controller/handler              ❹
        http_server = controller.HTTPController(upload_service)
        http_server.Start()

if __name__ == "__main__":
    main()
  • main is where everything is glued together
  • first we initialize concrete implementations (❶ and ❷)
  • the upload service constructor ❸ expects 2 interfaces
    • DocumentStorageRepository and DocumentMetricsRepository
    • s3_repo and elk_repo satisfy this signature
  • the HTTP handler constructor (an adapter) expects an UploadDocumentService (service)
    • upload_service can be used as an argument to create the HTTP handler

7. Conclusion

  • the goal is to have maintainable code
  • changes on code level should be easy and safe to make
  • abstract away implementations details
  • separate concerns (DDD)
  • also pay attention how you structure your code base
  • some words on duck typing (for dynamic programming languages)
    • you can argue that for e.g. Python you don’t need explicit types for parameters
    • Python (and JS) are duck-typed and give the best flexibility
      • however, runtime errors are sometimes hard to spot
    • static typed languages (C/C++, TS, Golang, Java, Scala etc.) require compile-time checks
      • make you explicitly use the type (nominal-typed languages) as strict dependency
        • e.g. Java
      • make sure object/struct has implemented methods (structural-type languages)

8. Resources

9. Contact

About
dornea.nu
Blog
blog.dornea.nu
Github
github.com/dorneanu
Twitter
@victordorneanu
LinkedIn
linkedin.com/in/victor-dorneanu
Threema
HCPNAFRD