feat: Implement domain logic separation with clean architecture

- Created complete domain layer with pure business logic
- Implemented Issue domain models with 48 passing tests
- Implemented Project domain models with 31 passing tests
- Added domain services for complex business operations
- Established clean separation between domain, application, and infrastructure
- All 250 tests passing with no breaking changes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-26 22:15:45 +02:00
parent a7a7960ef6
commit 0606115104
20 changed files with 6024 additions and 0 deletions

6
domain/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
Domain layer for MarkiTect project.
This package contains the core business logic and domain models,
implementing clean architecture principles with no infrastructure dependencies.
"""

20
domain/issues/__init__.py Normal file
View File

@@ -0,0 +1,20 @@
"""
Issue domain module.
Contains domain models, services, and interfaces for issue management.
"""
from .models import Issue, Label, IssueState, LabelCategories
from .services import IssueStatusService, IssueValidationService
from .exceptions import IssueDomainError, IssueValidationError
__all__ = [
'Issue',
'Label',
'IssueState',
'LabelCategories',
'IssueStatusService',
'IssueValidationService',
'IssueDomainError',
'IssueValidationError'
]

View File

@@ -0,0 +1,29 @@
"""
Domain-specific exceptions for issue management.
"""
class IssueDomainError(Exception):
"""Base exception for issue domain errors."""
def __init__(self, message: str, issue_number: int = None):
super().__init__(message)
self.issue_number = issue_number
class IssueValidationError(IssueDomainError):
"""Exception raised when issue validation fails."""
def __init__(self, message: str, field: str = None, value=None):
super().__init__(message)
self.field = field
self.value = value
class IssueStateError(IssueDomainError):
"""Exception raised when invalid state transitions are attempted."""
def __init__(self, message: str, current_state: str, attempted_state: str):
super().__init__(message)
self.current_state = current_state
self.attempted_state = attempted_state

116
domain/issues/models.py Normal file
View File

@@ -0,0 +1,116 @@
"""
Issue domain models.
Contains core business entities and value objects for issue management.
"""
from dataclasses import dataclass
from typing import List, Optional
from datetime import datetime
from enum import Enum
from .exceptions import IssueStateError
class IssueState(Enum):
"""Issue state enumeration."""
OPEN = "open"
CLOSED = "closed"
IN_PROGRESS = "in_progress"
@dataclass(frozen=True)
class Label:
"""Value object representing an issue label."""
name: str
color: Optional[str] = None
description: Optional[str] = None
def is_state_label(self) -> bool:
"""Check if this is a state-related label."""
return self.name.startswith('status:')
def is_priority_label(self) -> bool:
"""Check if this is a priority-related label."""
return self.name.startswith('priority:')
def is_type_label(self) -> bool:
"""Check if this is a type-related label."""
return self.name in ['bug', 'enhancement', 'feature', 'documentation']
@dataclass(frozen=True)
class LabelCategories:
"""Value object for categorized labels."""
state_labels: List[str]
priority_labels: List[str]
type_labels: List[str]
other_labels: List[str]
@dataclass
class Issue:
"""Issue aggregate root."""
number: int
title: str
state: IssueState
labels: List[Label]
created_at: datetime
updated_at: datetime
milestone: Optional[str] = None
assignee: Optional[str] = None
closed_at: Optional[datetime] = None
def categorize_labels(self) -> LabelCategories:
"""Categorize labels by type - pure domain logic."""
state_labels = [label.name for label in self.labels if label.is_state_label()]
priority_labels = [label.name for label in self.labels if label.is_priority_label()]
type_labels = [label.name for label in self.labels if label.is_type_label()]
other_labels = [
label.name for label in self.labels
if not (label.is_state_label() or label.is_priority_label() or label.is_type_label())
]
return LabelCategories(
state_labels=state_labels,
priority_labels=priority_labels,
type_labels=type_labels,
other_labels=other_labels
)
def close(self) -> None:
"""Close the issue - domain business rule."""
if self.state == IssueState.CLOSED:
raise IssueStateError(
"Issue is already closed",
current_state=self.state.value,
attempted_state=IssueState.CLOSED.value
)
self.state = IssueState.CLOSED
self.closed_at = datetime.utcnow()
def reopen(self) -> None:
"""Reopen the issue - domain business rule."""
if self.state != IssueState.CLOSED:
raise IssueStateError(
"Issue is not closed",
current_state=self.state.value,
attempted_state=IssueState.OPEN.value
)
self.state = IssueState.OPEN
self.closed_at = None
def add_label(self, label: Label) -> None:
"""Add a label to the issue."""
if label not in self.labels:
self.labels.append(label)
def remove_label(self, label_name: str) -> None:
"""Remove a label from the issue."""
self.labels = [label for label in self.labels if label.name != label_name]
def has_label(self, label_name: str) -> bool:
"""Check if issue has a specific label."""
return any(label.name == label_name for label in self.labels)

View File

@@ -0,0 +1,116 @@
"""
Repository interfaces for issue domain.
Defines contracts for data access without infrastructure dependencies.
"""
from abc import ABC, abstractmethod
from typing import List, Optional, Dict, Any
from .models import Issue
class IssueRepository(ABC):
"""Repository interface for issue persistence."""
@abstractmethod
async def get_issue(self, issue_number: int) -> Issue:
"""Retrieve issue by number.
Args:
issue_number: The issue number to retrieve
Returns:
Issue domain object
Raises:
IssueNotFoundError: If issue doesn't exist
"""
pass
@abstractmethod
async def list_issues(self, state: Optional[str] = None, limit: Optional[int] = None) -> List[Issue]:
"""List issues, optionally filtered by state.
Args:
state: Optional state filter (open, closed)
limit: Optional limit on number of results
Returns:
List of Issue domain objects
"""
pass
@abstractmethod
async def save_issue(self, issue: Issue) -> None:
"""Save issue changes.
Args:
issue: Issue domain object to save
"""
pass
@abstractmethod
async def create_issue(self, title: str, description: str, labels: List[str]) -> Issue:
"""Create a new issue.
Args:
title: Issue title
description: Issue description
labels: List of label names
Returns:
Created Issue domain object
"""
pass
@abstractmethod
async def delete_issue(self, issue_number: int) -> None:
"""Delete an issue.
Args:
issue_number: The issue number to delete
"""
pass
class ProjectRepository(ABC):
"""Repository interface for project information."""
@abstractmethod
async def get_issue_project_info(self, issue_number: int) -> Dict[str, Any]:
"""Get project information for an issue.
Args:
issue_number: The issue number
Returns:
Dictionary containing project context information
"""
pass
@abstractmethod
async def get_kanban_columns(self) -> List[str]:
"""Get available kanban columns for the project.
Returns:
List of kanban column names
"""
pass
@abstractmethod
async def get_project_labels(self) -> List[Dict[str, Any]]:
"""Get available labels for the project.
Returns:
List of label definitions
"""
pass
@abstractmethod
async def get_milestones(self) -> List[Dict[str, Any]]:
"""Get available milestones for the project.
Returns:
List of milestone information
"""
pass

173
domain/issues/services.py Normal file
View File

@@ -0,0 +1,173 @@
"""
Issue domain services.
Contains business logic for issue-related operations.
"""
from typing import Dict, Any, List
from .models import Issue, IssueState, LabelCategories
from .exceptions import IssueValidationError
class IssueStatusService:
"""Domain service for issue status-related business logic."""
def determine_kanban_column(self, issue: Issue, project_info: Dict[str, Any]) -> str:
"""Determine kanban column based on issue state and labels."""
# Pure business logic - no infrastructure dependencies
label_categories = issue.categorize_labels()
# Business rules for kanban column determination
if issue.state == IssueState.CLOSED:
return "Done"
# Check for explicit status labels
for state_label in label_categories.state_labels:
if state_label == "status:in-progress":
return "In Progress"
elif state_label == "status:review":
return "Review"
elif state_label == "status:blocked":
return "Blocked"
elif state_label == "status:ready":
return "Ready"
# Default for open issues without explicit status
return "Todo"
def extract_priority_info(self, issue: Issue) -> Dict[str, Any]:
"""Extract priority information from issue labels."""
label_categories = issue.categorize_labels()
priority_mapping = {
"priority:low": "Low",
"priority:medium": "Medium",
"priority:high": "High",
"priority:critical": "Critical"
}
for priority_label in label_categories.priority_labels:
if priority_label in priority_mapping:
return {
"level": priority_mapping[priority_label],
"label": priority_label
}
# Default priority
return {"level": "Medium", "label": None}
def extract_state_info(self, issue: Issue) -> Dict[str, Any]:
"""Extract state information from issue labels and state."""
label_categories = issue.categorize_labels()
return {
"state": issue.state.value,
"state_labels": label_categories.state_labels,
"is_closed": issue.state == IssueState.CLOSED,
"closed_at": issue.closed_at.isoformat() if issue.closed_at else None
}
def calculate_issue_age_days(self, issue: Issue) -> int:
"""Calculate issue age in days."""
from datetime import datetime
return (datetime.utcnow() - issue.created_at).days
def is_stale_issue(self, issue: Issue, stale_threshold_days: int = 30) -> bool:
"""Determine if issue is considered stale based on business rules."""
if issue.state == IssueState.CLOSED:
return False
age_days = self.calculate_issue_age_days(issue)
return age_days > stale_threshold_days
class IssueValidationService:
"""Domain service for issue validation business rules."""
def validate_issue_creation(self, title: str, labels: List[str]) -> None:
"""Validate issue creation according to business rules."""
if not title or not title.strip():
raise IssueValidationError(
"Issue title cannot be empty",
field="title",
value=title
)
if len(title) > 255:
raise IssueValidationError(
"Issue title cannot exceed 255 characters",
field="title",
value=title
)
# Business rule: Cannot have conflicting priority labels
priority_labels = [label for label in labels if label.startswith("priority:")]
if len(priority_labels) > 1:
raise IssueValidationError(
"Issue cannot have multiple priority labels",
field="labels",
value=priority_labels
)
# Business rule: Cannot have conflicting state labels
state_labels = [label for label in labels if label.startswith("status:")]
if len(state_labels) > 1:
raise IssueValidationError(
"Issue cannot have multiple state labels",
field="labels",
value=state_labels
)
def validate_title_update(self, new_title: str) -> None:
"""Validate issue title update."""
if not new_title or not new_title.strip():
raise IssueValidationError(
"Issue title cannot be empty",
field="title",
value=new_title
)
if len(new_title) > 255:
raise IssueValidationError(
"Issue title cannot exceed 255 characters",
field="title",
value=new_title
)
def validate_label_addition(self, issue: Issue, new_label: str) -> None:
"""Validate adding a label to an issue."""
# Business rule: Cannot add duplicate labels
if issue.has_label(new_label):
raise IssueValidationError(
f"Issue already has label '{new_label}'",
field="labels",
value=new_label
)
# Business rule: Cannot add conflicting priority labels
if new_label.startswith("priority:"):
existing_priority_labels = [
label.name for label in issue.labels
if label.is_priority_label()
]
if existing_priority_labels:
raise IssueValidationError(
f"Issue already has priority label '{existing_priority_labels[0]}'. "
f"Cannot add '{new_label}'",
field="labels",
value=new_label
)
# Business rule: Cannot add conflicting state labels
if new_label.startswith("status:"):
existing_state_labels = [
label.name for label in issue.labels
if label.is_state_label()
]
if existing_state_labels:
raise IssueValidationError(
f"Issue already has state label '{existing_state_labels[0]}'. "
f"Cannot add '{new_label}'",
field="labels",
value=new_label
)

View File

@@ -0,0 +1,18 @@
"""
Project domain module.
Contains domain models, services, and interfaces for project management.
"""
from .models import Project, Milestone, ProjectState
from .services import ProjectManagementService
from .exceptions import ProjectDomainError, ProjectValidationError
__all__ = [
'Project',
'Milestone',
'ProjectState',
'ProjectManagementService',
'ProjectDomainError',
'ProjectValidationError'
]

View File

@@ -0,0 +1,28 @@
"""
Domain-specific exceptions for project management.
"""
class ProjectDomainError(Exception):
"""Base exception for project domain errors."""
def __init__(self, message: str, project_name: str = None):
super().__init__(message)
self.project_name = project_name
class ProjectValidationError(ProjectDomainError):
"""Exception raised when project validation fails."""
def __init__(self, message: str, field: str = None, value=None):
super().__init__(message)
self.field = field
self.value = value
class MilestoneError(ProjectDomainError):
"""Exception raised when milestone operations fail."""
def __init__(self, message: str, milestone_id: int = None):
super().__init__(message)
self.milestone_id = milestone_id

162
domain/projects/models.py Normal file
View File

@@ -0,0 +1,162 @@
"""
Project domain models.
Contains core business entities and value objects for project management.
"""
from dataclasses import dataclass
from typing import List, Optional, Dict, Any
from datetime import datetime
from enum import Enum
from .exceptions import MilestoneError
class ProjectState(Enum):
"""Project state enumeration."""
ACTIVE = "active"
ARCHIVED = "archived"
PLANNING = "planning"
@dataclass
class Milestone:
"""Milestone entity."""
id: int
title: str
description: Optional[str]
due_date: Optional[datetime]
state: str
open_issues: int
closed_issues: int
@property
def completion_percentage(self) -> float:
"""Calculate milestone completion percentage."""
total_issues = self.open_issues + self.closed_issues
if total_issues == 0:
return 0.0
return (self.closed_issues / total_issues) * 100
@property
def total_issues(self) -> int:
"""Get total number of issues in milestone."""
return self.open_issues + self.closed_issues
def is_overdue(self) -> bool:
"""Check if milestone is overdue."""
if not self.due_date or self.state == "closed":
return False
return datetime.utcnow() > self.due_date
def is_completed(self) -> bool:
"""Check if milestone is completed."""
return self.state == "closed" or (self.total_issues > 0 and self.completion_percentage >= 100.0)
def add_issue(self) -> None:
"""Add an open issue to the milestone."""
self.open_issues += 1
def close_issue(self) -> None:
"""Close an issue in the milestone."""
if self.open_issues <= 0:
raise MilestoneError(
f"Cannot close issue in milestone '{self.title}': no open issues",
milestone_id=self.id
)
self.open_issues -= 1
self.closed_issues += 1
def reopen_issue(self) -> None:
"""Reopen an issue in the milestone."""
if self.closed_issues <= 0:
raise MilestoneError(
f"Cannot reopen issue in milestone '{self.title}': no closed issues",
milestone_id=self.id
)
self.closed_issues -= 1
self.open_issues += 1
@dataclass
class Project:
"""Project aggregate root."""
name: str
description: str
state: ProjectState
milestones: List[Milestone]
kanban_columns: List[str]
created_at: datetime
updated_at: datetime
archived_at: Optional[datetime] = None
def get_active_milestones(self) -> List[Milestone]:
"""Get milestones that are currently active."""
return [milestone for milestone in self.milestones if milestone.state == "open"]
def get_completed_milestones(self) -> List[Milestone]:
"""Get milestones that are completed."""
return [milestone for milestone in self.milestones if milestone.is_completed()]
def get_overdue_milestones(self) -> List[Milestone]:
"""Get milestones that are overdue."""
return [milestone for milestone in self.milestones if milestone.is_overdue()]
def calculate_overall_progress(self) -> float:
"""Calculate overall project progress based on milestones."""
if not self.milestones:
return 0.0
total_completion = sum(milestone.completion_percentage for milestone in self.milestones)
return total_completion / len(self.milestones)
def get_total_issues(self) -> int:
"""Get total number of issues across all milestones."""
return sum(milestone.total_issues for milestone in self.milestones)
def get_total_open_issues(self) -> int:
"""Get total number of open issues across all milestones."""
return sum(milestone.open_issues for milestone in self.milestones)
def get_total_closed_issues(self) -> int:
"""Get total number of closed issues across all milestones."""
return sum(milestone.closed_issues for milestone in self.milestones)
def archive(self) -> None:
"""Archive the project."""
if self.state == ProjectState.ARCHIVED:
return # Already archived
self.state = ProjectState.ARCHIVED
self.archived_at = datetime.utcnow()
def activate(self) -> None:
"""Activate the project."""
if self.state == ProjectState.ACTIVE:
return # Already active
self.state = ProjectState.ACTIVE
self.archived_at = None
def add_milestone(self, milestone: Milestone) -> None:
"""Add a milestone to the project."""
# Check for duplicate milestone IDs
if any(m.id == milestone.id for m in self.milestones):
raise ValueError(f"Milestone with ID {milestone.id} already exists")
self.milestones.append(milestone)
def remove_milestone(self, milestone_id: int) -> None:
"""Remove a milestone from the project."""
original_count = len(self.milestones)
self.milestones = [m for m in self.milestones if m.id != milestone_id]
if len(self.milestones) == original_count:
raise ValueError(f"Milestone with ID {milestone_id} not found")
def get_milestone(self, milestone_id: int) -> Optional[Milestone]:
"""Get a milestone by ID."""
for milestone in self.milestones:
if milestone.id == milestone_id:
return milestone
return None

189
domain/projects/services.py Normal file
View File

@@ -0,0 +1,189 @@
"""
Project domain services.
Contains business logic for project-related operations.
"""
from typing import Dict, Any, List
from datetime import datetime, timedelta
from .models import Project, Milestone, ProjectState
from .exceptions import ProjectValidationError
class ProjectManagementService:
"""Domain service for project management business logic."""
def determine_project_health(self, project: Project) -> str:
"""Determine project health based on business rules."""
progress = project.calculate_overall_progress()
overdue_milestones = project.get_overdue_milestones()
active_milestones = project.get_active_milestones()
# Business rules for project health assessment
if project.state != ProjectState.ACTIVE:
return "Inactive"
if progress >= 95:
return "Excellent"
elif progress >= 80:
return "Good"
elif progress >= 60:
return "Fair"
elif len(overdue_milestones) > 0:
return "At Risk"
elif len(active_milestones) == 0:
return "Stalled"
else:
return "Needs Attention"
def calculate_project_velocity(self, project: Project, days_back: int = 30) -> float:
"""Calculate project velocity based on recent milestone completions."""
completed_milestones = project.get_completed_milestones()
cutoff_date = datetime.utcnow() - timedelta(days=days_back)
# Count milestones completed in the specified period
# Note: This would need milestone completion dates in a real implementation
recent_completions = len(completed_milestones) # Simplified for now
return recent_completions / (days_back / 7) # Issues per week
def identify_bottlenecks(self, project: Project) -> List[str]:
"""Identify potential bottlenecks in the project."""
bottlenecks = []
# Check for overdue milestones
overdue_milestones = project.get_overdue_milestones()
if overdue_milestones:
bottlenecks.append(f"Overdue milestones: {len(overdue_milestones)}")
# Check for milestones with too many open issues
for milestone in project.get_active_milestones():
if milestone.open_issues > 20: # Business rule: threshold for too many issues
bottlenecks.append(f"Milestone '{milestone.title}' has {milestone.open_issues} open issues")
# Check for stalled milestones (no progress)
for milestone in project.get_active_milestones():
if milestone.total_issues > 0 and milestone.completion_percentage == 0:
bottlenecks.append(f"Milestone '{milestone.title}' shows no progress")
return bottlenecks
def recommend_next_actions(self, project: Project) -> List[str]:
"""Recommend next actions based on project state."""
recommendations = []
health = self.determine_project_health(project)
if health == "At Risk":
overdue_milestones = project.get_overdue_milestones()
recommendations.append(f"Address {len(overdue_milestones)} overdue milestone(s)")
if health == "Stalled":
recommendations.append("Create new milestones or reactivate existing ones")
# Check for milestones nearing completion
for milestone in project.get_active_milestones():
if milestone.completion_percentage >= 80:
recommendations.append(f"Focus on completing milestone '{milestone.title}' ({milestone.completion_percentage:.0f}% done)")
# Check for unbalanced workload
total_open = project.get_total_open_issues()
if total_open > 50: # Business rule: threshold for too many open issues
recommendations.append(f"Consider breaking down work - {total_open} total open issues")
return recommendations
def validate_project_creation(self, name: str, description: str) -> None:
"""Validate project creation according to business rules."""
if not name or not name.strip():
raise ProjectValidationError(
"Project name cannot be empty",
field="name",
value=name
)
if len(name) > 100:
raise ProjectValidationError(
"Project name cannot exceed 100 characters",
field="name",
value=name
)
if description and len(description) > 1000:
raise ProjectValidationError(
"Project description cannot exceed 1000 characters",
field="description",
value=description
)
def validate_milestone_creation(self, title: str, due_date: datetime = None) -> None:
"""Validate milestone creation according to business rules."""
if not title or not title.strip():
raise ProjectValidationError(
"Milestone title cannot be empty",
field="title",
value=title
)
if len(title) > 100:
raise ProjectValidationError(
"Milestone title cannot exceed 100 characters",
field="title",
value=title
)
# Business rule: Due date cannot be in the past
if due_date and due_date < datetime.utcnow():
raise ProjectValidationError(
"Milestone due date cannot be in the past",
field="due_date",
value=due_date
)
def calculate_milestone_priority(self, milestone: Milestone) -> int:
"""Calculate milestone priority based on business rules."""
priority_score = 0
# Higher priority for milestones with more issues
priority_score += milestone.total_issues * 2
# Higher priority for milestones with due dates
if milestone.due_date:
days_until_due = (milestone.due_date - datetime.utcnow()).days
if days_until_due <= 7:
priority_score += 50 # Very urgent
elif days_until_due <= 30:
priority_score += 25 # Urgent
else:
priority_score += 10 # Normal
# Higher priority for milestones closer to completion
if milestone.completion_percentage >= 75:
priority_score += 30 # Push to completion
# Lower priority for stalled milestones
if milestone.total_issues > 0 and milestone.completion_percentage == 0:
priority_score -= 20
return max(0, priority_score) # Ensure non-negative
def generate_project_summary(self, project: Project) -> Dict[str, Any]:
"""Generate a comprehensive project summary."""
health = self.determine_project_health(project)
bottlenecks = self.identify_bottlenecks(project)
recommendations = self.recommend_next_actions(project)
return {
"name": project.name,
"state": project.state.value,
"health": health,
"overall_progress": project.calculate_overall_progress(),
"total_milestones": len(project.milestones),
"active_milestones": len(project.get_active_milestones()),
"completed_milestones": len(project.get_completed_milestones()),
"overdue_milestones": len(project.get_overdue_milestones()),
"total_issues": project.get_total_issues(),
"open_issues": project.get_total_open_issues(),
"closed_issues": project.get_total_closed_issues(),
"bottlenecks": bottlenecks,
"recommendations": recommendations
}