Code Reviews Example 4 Personal JIRA Clone Design - herougo/SoftwareEngineerKnowledgeRepository GitHub Wiki
This is code and design specs (written by me) for a clone of the JIRA task management system. This is meant for utilizing the clean architecture design and gaining experience with it. The first draft is written in python for learning purposes.
Create Issue Code
For simplicity, the following code focusses on one table called Issues (another name for tasks).
File Structure
- domain
- entities: issues.py, users.py, ...
- enums
- application
- enums: feature_logic_names.py, ...
- features
- issues: command_create_issue.py
- feature_mediator.py
- infrastructure
- persistence
- sqlite: issues.py, users.py, registry.py
- in_memory: issues.py, users.py, registry.py
- utils.py
- persistence
- external_apis
- web
- client_app
- utils.py
- ...
- main: backend_config.py, app.py, env_variables.py
- tests
- unit: domain, application, infrastructure
- integration: application, main
Note:
- for listing contents of a folder, can use a colon or bullets with longer indentation. For example, infrastructure/persistence/sqlite/issue.py is a file path.
- we have the following layers: main > external_apis/web/infrastructure > application > domain
domain/enums/feature_logic_names.py
- this file contains names of queries and commands under the unified name feature logic
from enum import Enum
class Commands(Enum):
CREATE_ISSUE = 'create_issue'
...
# values must not overlap with command values (have unit tests which check this)
class Queries(Enum):
GET_ALL_ISSUES = 'get_all_issues'
...
application/features/issues/command_create_issue.py
- each command/query contains a dataclass for the expected handler input, a validator for that input, and the handler itself
from dataclasses import dataclass
@dataclass
class CreateIssueCommandInput:
...
class CreateIssueCommandValidator:
...
class CreateIssueCommandHandler:
def __init__(self, repository_collection):
self._issues_repository = repository_collection[Entities.ISSUE.value]
def handle(self, command_input: CreateIssueCommandInput):
...
application/feature_logic_registry.py
- The point of this file is build a dictionary of all feature logic, mapping the name of the feature to a config object, which contains the entity, etc. This is useful for dynamically building handlers, etc.
from dataclasses import dataclass
from domain.entities.issues import Issue
from domain.enums.feature_logic_names import Commands, Queries
from domain.enums.other import HandlerCtorArg
from application.features.issues.command_create_issue import (
CreateIssueCommandInput, CreateIssueCommandValidator, CreateIssueCommandHandler
)
@dataclass
class FeatureLogicConfig:
entity_class
input_class
validator_class
handler_class
handler_ctor_args
FEATURE_LOGIC_MAP = {
Commands.CREATE_ISSUE.value: FeatureLogicConfig(
entity_class=Issue,
input_class=CreateIssueCommandInput,
validator_class=CreateIssueCommandValidator,
handler_class=CreateIssueCommandHandler,
handler_ctor_args=(HandlerCtorArg.REPOSITORY_COLLECTION.value
)
}
application/feature_mediator.py
- Making use of the dictionary, we have a mediator object which dynamically builds the handler objects. This mediator performs the feature logic in the send method (i.e. validation, handling). This is mainly done to avoid repeated code.
from application.feature_logic_registry import FEATURE_LOGIC_MAP
class FeatureMediator:
def __init__(self, repository_collection):
self._repository_collection = repository_collection
self._full_kwargs = {
HandlerCtorArg.REPOSITORY_COLLECTION.value: self._repository_collection
}
self._handlers = {name: self._build_handler(feature_logic_name) for name in FEATURE_LOGIC_MAP}
def _build_handler(self, feature_logic_name):
config = FEATURE_LOGIC_MAP[feature_logic_name]
kwargs = {
arg_name: self._full_kwargs[arg_name]
for arg_name in config.handler_ctor_args
}
return config.handler_class(**kwargs)
def send(self, feature_logic_name, payload):
config = FEATURE_LOGIC_MAP[feature_logic_name]
validator = config.validator
if validator:
validator.validate(payload) # validate is a static method
handler = self._handlers[feature_logic_name]
return handler.handle(payload)
infrastructure/persistence/common/base_entity_repository.py
- This is a simple, persistence-type-agnostic repository implementation (repository design pattern).
class BaseEntityRepository:
"""
Assumptions about the data:
- Has a primary key id named "id"
"""
def __init__(self, factory, gateway, column_names):
self._factory = factory
self._gateway = gateway
self._column_names = column_names
def get_by_id(self, id):
rows = self._gateway.get_by_id(id)
return [self._factory.make(row) for row in rows]
def get_all(self):
rows = self._gateway.get_all()
return [self._factory.make(row) for row in rows]
def update(self, id, **kwargs):
...
def insert(self, **kwargs):
self._gateway.insert(**kwargs)
def delete(self, id):
...
infrastructure/persistence/sqlite/base_entity_gateway.py
- We implement gateways and factories for each persistence type (e.g. Sqlite, in-memory, etc)
class BaseSqliteEntityGateway:
def __init__(self, conn):
self._conn = conn
def insert(self, **kwargs):
...
...
infrastructure/persistence/sqlite/registry.py
- Similar to feature logic, we have a registry dictionary of all Sqlite persistence classes.
SQLITE_PERSISTENCE_DICT = {
Entity.ISSUE.value: PersistenceConfig(SqliteIssueFactory, SqliteIssueGateway, SqliteIssueRepository,
[field.name for field in fields(Issue)])
}
infrastructure/persistence/registry.py
- We have a registry dictionary mapping the persistence type to the entity name to the config.
from infrastructure.enums import PersistenceType
from infrastructure.persistence.sqlite.registry import SQLITE_PERSISTENCE_DICT
PERSISTENCE_DICT = {
PersistenceType.SQLITE.value: SQLITE_PERSISTENCE_DICT
}
infrastructure/repository_collection.py
- RepositoryCollection is a container of all of the repositories based on the persistence type argument. It builds all of the repositories using the PERSISTENCE_DICT. It also creates a connection based on the persistence type. Currently, this collection is passed as a constructor argument for all of the handlers.
from ... import create_persistence_connection
class RepositoryCollection:
def __init__(self, persistence_type):
persistence_dict = PERSISTENCE_DICT[persistence_type]
self._conn = create_persistence_connection(persistence_type)
self._repo_name_to_repo = {}
for repo_name, config in persistence_dict.items():
factory = config.factory_class()
gateway = config.gateway_class(self._conn)
column_names = config.column_names
repository = config.repository(factory, gateway, column_names)
self._repo_name_to_repo[repo_name] = repository
def __getitem__(self, repo_name):
return self._repo_name_to_repo[repo_name]
def commit(self):
self._conn.commit()
def close(self):
self._conn.close()
web/utils.py
- build_payload is a function which takes in a Flask request and a data class (not an object, the class itself), and transfers the attributes accordingly in a dynamic way.
- WebFeatureMediator extends FeatureMediator by allowing it to read requests, use the build_payload function to convert it into something FeatureMediator can handle, send it to the FeatureMediator send method, then return a response.
- Notice how FeatureMediator knows nothing about the web. It is only when it is extended by WebFeatureMediator that it becomes usable with the web. This follows clean architecture paradigms.
def build_payload(request, data_class):
...
class WebFeatureMediator(FeatureMediator):
def __init__(self, repository_collection):
super(WebFeatureMediator, self).__init__(repository_collection)
def send(self, feature_logic_name, request):
try:
feature_logic_input_class = FEATURE_LOGIC_MAP[feature_logic_name].input_class
payload = build_payload(request, feature_logic_input_class)
result = super(WebFeatureMediator, self).send(feature_logic_name, payload)
if result:
json_result = result.to_json()
else:
json_result = None
return Response(200, json_result)
except Exception as ex:
return Response(..., str(ex))
main/env_variables.py
- (environment variables)
import os
_ENV_VARIABLE_NAMES = {'CONFIG_FILE_PATH'}
class _EnvironmentVariables:
def __init__(self):
for env_var_name in _ENV_VARIABLE_NAMES:
assert not hasattr(self, env_var_name)
setattr(self, env_var_name.lower(), os.environ.get(env_var_name, None))
ENV = _EnvironmentVariables()
main/app.py
- The "main" file of the repo. It initializes a flask app, gets environment variables, loads a config file, and makes use of the WebFeatureMediator. Note how the post endpoint is one line.
from domain.enums.feature_logic_names import Commands
from main.backend_config import BackendConfig
from main.env_variables import ENV
from infrastructure.repository_collection import RepositoryCollection
from web.web_feature_mediator import WebFeatureMediator
from flask import Flask, request
config = BackendConfig(ENV.config_file_path)
repository_collection = RepositoryCollection(config.persistence_type)
mediator = WebFeatureMediator(repository_collection)
app = Flask(__name__)
@app.route('/create_issue', methods=['POST'])
def create_issue():
return mediator.send(Commands.CREATE_ISSUE.value, request)
Henri Code Analysis
Pros
- follows clean architecture
- makes use of the mediator pattern
- short app.py code (also low coupling)
Cons
- dynamic registration is not recommended?
- repositories are not grouped by DDD aggregates?
Improvements
- dynamic registration
- Idea: Add a create_sqlite_issue_repository(conn) and a create_persistence_dict(conn) function to get rid of the dynamic registration for persistence.
- Idea: Keep FeatureLogicConfig stuff as is because it saves code.