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.

Design Doc

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
  • 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.