I've been transitioning from type-safe programming languages like Dart and Java to Python, and I'm trying to enforce Domain-Driven Design (DDD) principles in a language that naturally leans towards flexibility. The challenge is that Python's typing system is gradual—meaning it allows many potential violations unless explicitly restricted. So, I've been experimenting with workarounds to enforce stricter constraints.
What I'm Trying to Achieve: I want to create a Failure class that can convert exceptions into failure objects, which can then be easily serialized into dictionaries if needed. The goals are:
Base Failure Class Design:
- Adheres to DDD principles, making illegal states non-representable
- An abstract class that forces structure and behaviour onto its subclasses.
- Enforces immutability on all child classes. A factory constructor to simplify instance creation by not having to memorize attributes.
- Leveraging dataclass to define fields in a way that feels natural coming from Dart or Java.
- Setting frozen=True to prevent attribute modification after initialization.
- Adding slots=True to block any foreign attribute assignments, ensuring data integrity.
Tiny Child Class:
- A small, pre-configured child class that’s ready to handle specific failure scenarios.
- Unit tests to validate every bit of logic and guarantee the implementation behaves as expected.
What I Found Out:
In the child class, even though I called the super constructor, I discovered that I needed to manually update the instance’s dict to get things working:
instance = self.factory_child_constructor(message=message, stacktrace=stacktrace, code=34)
self.__dict__.update(instance.__dict__)
This worked fine when using @dataclass(frozen=True)—but here’s the catch: Python still allows adding foreign attributes to the instance! That doesn’t sit well with my old-school, type-safe mindset.
To address this, I tried adding slots=True:
@dataclass(frozen=True, slots=True)
This gave me the data integrity I wanted (no more foreign attributes!), but then self.dict.update(instance.dict) stopped working because slotted classes don’t have a dict. This left the instance effectively empty.
Current Approach and Dilemma: I attempted to override the dict method to handle both cases (with and without slots) while ensuring my unit tests pass. But now, I’m wondering if there’s a more consistent or elegant way to achieve this balance between immutability, data integrity, and flexibility in Python.
TL;DR: How can I create an immutable Python class that:
Prevents foreign attributes from being added. Supports dataclass behavior for familiar field definitions. Allows a factory constructor for easier instance creation. Maintains compatibility with slots=True without breaking initialization? Any insights or best practices would be much appreciated!
FailureObject classes :
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, Optional, Type, TypeVar
from utilities.custom_property_decorators import *
# Define a TypeVar for the subclass
T = TypeVar('T', bound='FailureObject')
@dataclass # Immutable: attributes cannot be modified after initialization even by setters
class FailureObject(ABC):
_allowed_attributes = {'_message', '_stack_trace', '_code', '_date_occurred'}
_message: str = field(default="", init=False)
_stack_trace: Optional[str] = field(default=None, init=False)
_code: Optional[int] = field(default=None, init=False)
_date_occurred: datetime = field(default_factory=datetime.now, init=False)
@property
@abstractmethod
def message(self) -> str:
pass
@property
@abstractmethod
def stack_trace(self) -> Optional[str]:
pass
@property
@abstractmethod
def code(self) -> Optional[int]:
pass
def __post_init__(self):
# Freeze the object after initialization
object.__setattr__(self, '_is_frozen', True)
@property
def date_occurred(self) -> datetime:
"""
Concrete property that returns the default factory value.
Child classes do not need to implement this.
"""
return self._date_occurred
def __setattr__(self, name, value):
if not hasattr(self, '_is_frozen') or name == '_is_frozen':
if name not in self._allowed_attributes:
raise AttributeError(f"'{name}' is not a valid attribute of {self.__class__.__name__}")
super().__setattr__(name, value)
else:
raise AttributeError(f"Cannot modify '{name}'; {self.__class__.__name__} is immutable.")
super().__setattr__(name, value)
def __hash__(self):
return hash((self._message, self._stack_trace, self._code, self._date_occurred))
def __eq__(self, value):
if not isinstance(value, FailureObject):
return False
return (
self._message == value._message
and self._stack_trace == value._stack_trace
and self._code == value._code
and self._date_occurred == value._date_occurred
)
def to_map(self) -> Dict[str, Dict[str, Optional[str | int]]]:
"""
Serializes the FailureObject instance to a dictionary.
"""
return {
self.__class__.__name__: {
"message": self._message,
"code": self._code,
"stack_trace": self._stack_trace,
"date_occurred": self._date_occurred.isoformat() # Convert datetime to string
}
}
@classmethod
def factory_from_json(cls: Type[T], map: Dict[str, Optional[str | int]]) -> T:
"""_summary_
Args:
cls (Type[T]): _description_
map (Dict[str, Optional[str | int]]): _description_
this factory constructor is used to construct Child instances from json!
the output will be of same type as the child class and inheritally same type as the parent as well
Raises:
TypeError: if the map arg supplied is not a Dict
ValueError: if the json arg supplied is none valid
Returns:
T: _description_
"""
if not isinstance(map, dict):
raise TypeError("Input must be a dictionary")
# Get the class name dynamically
class_name = cls.__name__
# Extract the nested dictionary under the class name key
if class_name not in map:
raise ValueError(f"Invalid JSON structure: '{class_name}' key not found.")
data:Dict = map[class_name]
# Extract and validate values
message = cls._validate_message(data.get("message", ""))
stack_trace = cls._validate_stack_trace(data.get("stack_trace", None))
code = cls._validate_code(data.get("code", None))
date_occurred = cls._validate_date_occurred(data.get("date_occurred", None))
# Create an instance of the subclass
instance = cls.__new__(cls) # Create an uninitialized instance
# Forcefully set private fields
object.__setattr__(instance, '_message', message)
object.__setattr__(instance, '_stack_trace', stack_trace)
object.__setattr__(instance, '_code', code)
object.__setattr__(instance, '_date_occurred', date_occurred)
return instance
@classmethod
def factory_child_constructor(cls: Type[T],*args, **kwargs )-> T:
message_arg_val = kwargs.get('message', args[0] if len(args) > 0 else None)
stack_trace_val = kwargs.get('stacktrace', args[1] if len(args) > 1 else None)
code_val = kwargs.get('code', args[2] if len(args) > 2 else None)
# validate args :
valid_message = cls._validate_message(message_arg_val)
valid_stacktrace = cls._validate_stack_trace(stack_trace_val)
valid_code= cls._validate_code(code_val)
# create an instance of subclass
instance = cls.__new__(cls)
object.__setattr__(instance, '_message', valid_message)
object.__setattr__(instance, '_stack_trace', valid_stacktrace)
object.__setattr__(instance, '_code', valid_code)
print(f"valid_code in constructor : {instance._code} ")
return instance
@staticmethod
def _validate_message(value: str) -> str:
if not isinstance(value, str):
raise TypeError("Message must be a string.")
return value
@staticmethod
def _validate_stack_trace(value: Optional[str]) -> Optional[str]:
if value is not None and not isinstance(value, str):
raise TypeError("Stack trace must be a string or None.")
return value
@staticmethod
def _validate_code(value: Optional[int]) -> Optional[int]:
if value is not None and not isinstance(value, int):
raise TypeError("Code must be an integer or None.")
return value
@staticmethod
def _validate_date_occurred(value: Optional[str | datetime]) -> datetime:
if isinstance(value, datetime):
return value
if isinstance(value, str):
try:
res= datetime.fromisoformat(value)
return res
except ValueError:
raise ValueError("Invalid date format. Expected ISO format.")
return datetime.now()
class ValidationFailure(FailureObject):
def __init__(self, message: str, stacktrace: Optional[str]):
# Call the parent class's __init__ to initialize _date_occurred
super().__init__()
instance = self.factory_child_constructor(message= message, stacktrace=stacktrace, code=34)
self.__dict__.update(instance.__dict__)
@property
def message(self) -> str:
return self._message
@property
def stack_trace(self) -> Optional[str]:
return self._stack_trace
@property
def code(self) -> Optional[int]:
return self._code
unit tests :
from dataclasses import dataclass, field
import dataclasses
from datetime import datetime
from typing import Dict
import unittest
from utilities.domain_driven_design_value_objects.failure_objects import FailureObject, ValidationFailure
class TestFailureobjects(unittest.TestCase):
validValidationFailureObject: ValidationFailure = field(init=True,default=None)
nonValidValidationFailureDict: Dict = field(init=True,default=None)
validValidationFailureDict:Dict = field(init=True, default= None)
def setUp(self):
print(f"-------------- called setup for test {self.id().split('.')[-1]} --------------")
self.validValidationFailureObject = ValidationFailure(message="test validation message",stacktrace="TestFailureobjects:")
self.nonValidValidationFailureDict= {'ValidationFailure': {'message': "invalid schema ", 'code': 34, 'stack_trace': 'instance in DateTimeModel', 'date_occurred': '2025-01-20T14SDFSDDFG:40:12.347289'}}
self.validValidationFailureDict={'ValidationFailure': {'message': "valid schema", 'code': 34, 'stack_trace': 'testTestFailureobjects ', 'date_occurred': '2025-01-20T14:40:12.347289'}}
return super().setUp()
def tearDown(self):
print(f"-------------- called tearDown {self.id().split('.')[-1]} --------------")
self.validValidationFailureDict=None
self.validValidationFailureObject=None
self.nonValidValidationFailureDict=None
return super().tearDown()
def test_Validation_failure_instanciation(self):
""" the instance should be a sub instance of FailureObject, and an instance of ValidationFailure the instnce must have a code = 34, and date occurred must be equal to datetime.now()
"""
# arrage
# act
self.assertIsInstance(self.validValidationFailureObject, ValidationFailure)
self.assertIsInstance(self.validValidationFailureObject,FailureObject)
self.assertEqual(self.validValidationFailureObject.code,34)
self.assertLess(self.validValidationFailureObject.date_occurred, datetime.now())
# asset
def test_Validation_failure_from_json_method_valid(self):
""" should create a valid validation failure object from a dict"""
# arrage
# act
validinstance = ValidationFailure.factory_from_json(map=self.validValidationFailureDict)
# assert
self.assertIsInstance(validinstance,FailureObject)
self.assertEqual(validinstance.code,34)
self.assertEqual(validinstance.message,"valid schema")
def test_Validation_failure_from_json_non_valid(self):
""" should throw type error on with message `Invalid date format. Expected ISO format` on invalid date string"""
# arrange
# act and assert
with self.assertRaises(ValueError) as context:
ValidationFailure.factory_from_json(map=self.nonValidValidationFailureDict)
self.assertEqual(str(context.exception), "Invalid date format. Expected ISO format.")
def test_validate_immutability_constraints(self):
""" should throw an exception if we try to assign a value to a known attribute"""
with self.assertRaises((AttributeError,dataclasses.FrozenInstanceError)) as context:
self.validValidationFailureObject._code=454
self.assertIsInstance(context.exception, (AttributeError,dataclasses.FrozenInstanceError))
def test_integrity_encapsulation(self):
""" the instance of sub classes, like ValidationFailure, should not allow adding foreign attributes to its dict or instance menmbers enabbling __slots__"""
# arrange
# act and assert
with self.assertRaises(AttributeError) as context:
self.validValidationFailureObject.anyForeignAttribute="ABC"
self.assertIsInstance(context.exception, AttributeError)
if __name__ == "__main__":
suite = unittest.TestSuite()
suite.addTest(TestFailureobjects("test_Validation_failure_instanciation"))
suite.addTest(TestFailureobjects("test_Validation_failure_from_json_method_valid"))
suite.addTest(TestFailureobjects("test_Validation_failure_from_json_non_valid"))
suite.addTest(TestFailureobjects("test_validate_immutability_constraints"))
suite.addTest(TestFailureobjects("test_integrity_encapsulation"))
runner = unittest.TextTestRunner()
runner.run(suite)
```
ValidationFailureneed a constructor at all if it doesn't have any additional properties? \$\endgroup\$