Basic introduction using Python
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
- adopt “shift left” mentality and guarantee (application) Security
- clear separation of concerns (fundamental in Information hiding)
- hide details behind a boundary
- hide details behind a boundary
- help with technical debt
- decouple (Cohesion vs Coupling) business code from technology code
- business should still grow without any hard dependencies on technological challenges
- improve software delivery performance
- decouple (Cohesion vs Coupling) business code from technology code
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
servershandling requests from outside to your business application - CLI commands doing something with your business use cases
- Pub/Sub message subscribers
- HTTP/gRPC
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
- file readers/writers
- SQL queries
- Some distinguish between
- primary/driving adapters
- secondary/driven adapters
- primary/driving 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
- if you cannot say which database is used for storing entities, that’s a good sign
1.3. Language-agnostic implementation
- I’ll describe use cases for a concrete problem
- There are several actors involved
- uploader
- product manager
- uploader
- I’ll abstractions to define relationships between
- use cases and
- concrete implementations
- use cases and
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?
- via web client?
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?
- Via Web?
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
- apply information hiding
- use SOLID
- apply information hiding
- you can apply this on different levels
- structures
- namespaces
- modules
- packages
- microservices
- just keep related things within a boundary
- in DDD language: boundary context
- in DDD language: boundary context
- structures
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 (
DocumentStorageRepositoryandDocumentMetricsRepository)
- basically interfaces to be implemented by the Secondary Adapters ❸
- basically interfaces to be implemented by the Secondary Adapters ❸
- Entities (there is only
- The Secondary (Driven) Adapters implement
- the repositories/interfaces defined in the Business Domain ❶
- the repositories/interfaces defined in the Business Domain ❶
- The Primary (Driving) Adapters ❷ use the Services
- a CLI could implement the
DocumentUploadServicefor the terminal - a HTTP server could serve the
DocumentUploadServicevia HTTP
- a CLI could implement the
3. Domain
- everything related to the business case
- uploader wants to upload some document
- PM wants to have some metrics
- uploader wants to upload some document
- contains
- Entities
- Services
- Repositories/Interfaces
- Entities
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
savedocuments - define how to
searchfor documents - define how to
deletea document
- define how to
- 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
HTTPControllerexpects aUploadDocumentService
- we use an abstraction rather than a concrete implementation
- we use an abstraction rather than a concrete implementation
- ❷ We use the
upload_serviceto upload a document (of typeentities.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) ❷
- ❶
CLIControllerexpects anUploadDocumentService - uses its
upload_documentmethod to upload ❷ a document (of typeentities.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
...
S3StorageRepositoryimplementsDocumentStorageRepository(interface)savetakes as an argument aDocumentand ❷ defines how and where to save the document
- these are implementation specific details
- these are implementation specific details
- ❸
searchwill return a list ofDocument
- instead of a S3 object
- 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(...) ❷
ELKMetricsRepositoryimplementsDocumentMetricsRepository(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()
mainis where everything is glued together- first we initialize concrete implementations (❶ and ❷)
- the upload service constructor ❸ expects 2 interfaces
DocumentStorageRepositoryandDocumentMetricsRepositorys3_repoandelk_reposatisfy this signature
- the HTTP handler constructor (an adapter) expects an
UploadDocumentService(service)
upload_servicecan 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
- 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
- e.g. Java
- make sure object/struct has implemented methods (structural-type languages)
- e.g. Golang
- e.g. Golang
- make you explicitly use the type (nominal-typed languages) as strict dependency
- you can argue that for e.g. Python you don’t need explicit types for parameters
8. Resources
- Clean Architecture (and variants)
- Hexagonal Architecture
- DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together
- great stuff, awesome graphics
- make sure you also read his other posts
- great stuff, awesome graphics
- DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together
- Hexagonal Architecture: The practical guide for clean architecture
- Domain-Driven Design (DDD) im Hexagon (german)
- Package by component and architecturally-aligned testing (how to design your packages/components)
- Ready for changes with Hexagonal Architecture (netflix tech blog)
- Package by component and architecturally-aligned testing (how to design your packages/components)
- Python
- Images
9. Contact
- About
- dornea.nu
- Blog
- blog.dornea.nu
- Github
- github.com/dorneanu
- @victordorneanu
- linkedin.com/in/victor-dorneanu
- Threema
- HCPNAFRD