Package: py-ddd-framework
A Domain-Driven Design (DDD) Framework for Python DevelopersWhat is this library good for?This is a lightweight framework that provides a quick and simple setup forDomain-Drivendesigned apps
that are a pleasure to maintain and easy to unit test.These are the main features that are supported by the framework:Unit of Workwith acommitandrollbackmechanism for application layer handlersDefinition ofDomain Commandsin the domain layer and theirCommand Handlersin the application layerDefinition ofDomain Eventsin the domain layer and theirEvent Handlersin the application layerEvent-Driven Architecturebased onDomain EventsThis library has no external dependencies and hence should be easy to add to any project that can benefit from
DDD.Supportsasyncio.Installationpipinstallpy-ddd-frameworkImportimportasyncioimportddddefmain():bootstrapper=ddd.Bootstrapper()command=SaveUserCommand()# regular usageresult=bootstrapper.handle_command(command)# async usageresult=asyncio.run(bootstrapper.async_handle_command(command))How to implement it?A sample implementation is provided within thedemofolder
in the source code (both for async and regular usage).To run the demo, pleasecdinto the root folder and execute the following command:pythonrun_demo.pyThe below explanation is based on this sample implementation.Sample ImplementationLet's imagine a simplified background job for saving a user's details that consists
of the following steps within a unit of work:Get the new user's data from aPubSub message broker(such as Amazon SQS, RabbitMQ, etc.)
and transform it into acommandobject that can be handled by theapplication layerPerform basic validations on thecommand's dataGet the existinguser entitydata from the database, via arepositoryUpdate theuser entitywith the data stored in thecommandobjectSavethe updated user entityin the repositoryEithercommit(and store the new data in the database)
orrollback(and thus discard the changes recorded in the previous steps)Steps 2 (command validation) and 6 (commit or rollback) are triggered by the framework.How the code looks like?Domain LayerUser Entityfrom__future__importannotationsimportdddfromdemo.domain.command_model.email_set_eventimportEmailSetEventclassUser(ddd.AbstractEntity):def__init__(self,email:str|None=None,id_:str|None=None):super().__init__()self._id=id_self._email=emaildefget_id(self)->str:returnself._iddefset_id(self,value:str)->None:self._id=value@propertydefemail(self)->str:returnself._emaildefset_email(self,value:str)->None:ifvalueandself._email!=value:self.add_event(EmailSetEvent(user_id=self._id,new_email=value,old_email=self._email))self._email=valuedef__repr__(self)->str:returnf'<{type(self).__name__}(id={self._id}, email={self._email})>'SaveUserCommandfrom__future__importannotationsimportdataclassesimportddd@dataclasses.dataclassclassSaveUserCommand(ddd.AbstractCommand):user_id:str|None=Noneemail:str|None=None@propertydefname(self)->str:returntype(self).__name__defvalidate(self)->None:ifnotself.user_id:raiseddd.BoundedContextError(ddd.BAD_REQUEST,'Missing user_id')ifnotself.email:raiseddd.BoundedContextError(ddd.BAD_REQUEST,'Missing email')RepositoryPlease note that we're using an in memory repository for demo purposes
(and also for theunit tests)from__future__importannotationsimportabcimportdddfromdemo.domain.command_model.userimportUserclassAbstractUserRepository(ddd.RollbackCommitter,abc.ABC):defget_by_id(self,id_:str)->User:returnself._get_by_id(id_)defsave(self,user:User)->None:self._save(user)@abc.abstractmethoddef_get_by_id(self,id_:str)->User:raiseNotImplementedError@abc.abstractmethoddef_save(self,user:User)->None:raiseNotImplementedErrorclassInMemoryUserRepository(AbstractUserRepository):def__init__(self):super().__init__()self.users_by_id:dict[str,User]={}self._saved_users:list[User]=[]self.commit_called=Falseself.rollback_called=Falseself.commit_should_fail=Falseself.rollback_should_fail=Falsedef_get_by_id(self,id_:str)->User:result=self.users_by_id.get(id_)ifnotresult:raiseddd.BoundedContextError(ddd.NOT_FOUND,f'User with ID "{id_}" does not exist')returnresultdef_save(self,user:User)->None:self._saved_users.append(user)defcommit(self)->None:self.commit_called=Trueifself.commit_should_fail:raiseException('commit failed')foruserinself._saved_users:self.users_by_id[user.get_id()]=userdefrollback(self)->None:self.rollback_called=Trueifself.rollback_should_fail:raiseException('rollback failed')self._saved_users.clear()SaveUserCommandHandlerThis is the application layer flow that is triggered by the framework's unit of work -
in order to either commit or rollback the changes.This handler is registered to the above definedSaveUserCommand- so that whenever this command is received,
then this handler will be executed. The registration is handled by theBootstrapperwhich will be shown later.from__future__importannotationsimportdddfromdemo.adapters.repositories.user_repositoryimportAbstractUserRepositoryfromdemo.domain.command_model.save_user_commandimportSaveUserCommandclassSaveUserCommandHandler(ddd.AbstractCommandHandler[SaveUserCommand,str]):def__init__(self,user_repository:AbstractUserRepository):super().__init__()self._user_repository=user_repositoryself._events:list[ddd.AbstractEvent]=[]defhandle(self,command:ddd.TCommand)->ddd.THandleCommandResult:user=self._user_repository.get_by_id(command.user_id)user.set_email(command.email)self._events.extend(user.events)returnuser.get_id()@propertydefevents(self)->list[ddd.AbstractEvent]:returnlist(self._events)defcommit(self)->None:self._user_repository.commit()defrollback(self)->None:self._user_repository.rollback()Registration of the SaveUserCommand with its handler: SaveUserCommandHandlerThis happens within the bootstrapper, like so:from__future__importannotationsimportdddfromdemo.adapters.clients.pubsub_clientimportInMemoryPubSubClientfromdemo.adapters.repositories.user_repositoryimportInMemoryUserRepositoryfromdemo.domain.command_model.save_user_commandimportSaveUserCommandfromdemo.service_layer.command_handlers.save_user_command_handlerimportSaveUserCommandHandlerclassDemoBootstrapper(ddd.Bootstrapper):def__init__(self):super().__init__()self.user_repository=InMemoryUserRepository()self.pubsub_client=InMemoryPubSubClient()self.register_command_handler_factory(SaveUserCommand().name,self.create_save_user_command_handler)defcreate_save_user_command_handler(self)->ddd.AbstractCommandHandler:"""Made public for the framework's unit test.In realworld usage, this method should best be private."""returnSaveUserCommandHandler(self.user_repository)Handling the SaveUserCommand by the frameworkBased on the above created bootstrapper instance,
this is how the command should be propagated into the framework:bootstrapper=DemoBootstrapper()command=SaveUserCommand(user_id='1',email='eli.cohen@mossad.gov.il')bootstrapper.handle_command(command)But wait, isn't this code over-engineered?Basically, if this is all the code should do, then this code is arguably too complex.
Yet, what happens when the requirements grow, and you need to handle other tasks, such as:Trigger a verification email to validate the provided email?Notify a KPI Service about the changes - for further analysisHandle other changes, as in a real world scenario the user entity should have much more properties -
where each property change might require triggering other actions (a.k.a.Domain Events)The code might quickly look like this:defhandle(self,command:ddd.TCommand)->ddd.THandleCommandResult:user=self._user_repository.get_by_id(command.user_id)user.set_email(command.email)# Side effect...ifuser.changed_email:self._pubsub_client.notify_email_changed(...)self._pubsub_client.notify_kpi_service(...)# Side effect...ifuser.phone_number_changed:self._pubsub_client.notify_phone_number_changed(...)self._pubsub_client.notify_kpi_service(...)returnuser.get_id()The above code will contain lots side effects, and will defeat the SRP (Single Responsibility Principle)
for which it was created - which is to save the new user details.
Even worse, it will sooner than later become spaghetti code - that will be a nightmare to maintain and unit test.Event-Driven Architecture with EventHandlers to the RescueAll the above side effects should best be extracted out of the above code, and handled within other handlers.
These handlers will be handled in the same way as the command handler,
i.e. within units of work of their own - and may trigger other events which will be handled by the framework.Here are 2 sample event handlers:EmailSetEventHandler that will trigger a KPIEvent that will be handled by the KPIEventHandlerfrom__future__importannotationsimportdddfromdemo.adapters.clients.pubsub_clientimportAbstractPubSubClientfromdemo.domain.command_model.email_set_eventimportEmailSetEventfromdemo.domain.command_model.kpi_eventimportKpiEventclassEmailSetEventHandler(ddd.AbstractEventHandler[EmailSetEvent]):def__init__(self,email_client:AbstractPubSubClient):super().__init__()self._email_client=email_clientself._events:list[ddd.AbstractEvent]=[]defhandle(self,event:ddd.TEvent)->None:self._email_client.notify_email_changed(event.user_id,event.new_email,event.old_email)self._events.append(KpiEvent(action=event.name,data=f'{event!r}'))@propertydefevents(self)->list[ddd.AbstractEvent]:returnlist(self._events)defcommit(self)->None:self._email_client.commit()defrollback(self)->None:self._email_client.rollback()KpiEventHandlerfrom__future__importannotationsimportdddfromdemo.adapters.clients.pubsub_clientimportAbstractPubSubClientfromdemo.domain.command_model.kpi_eventimportKpiEventclassKpiEventHandler(ddd.AbstractEventHandler[KpiEvent]):def__init__(self,pubsub_client:AbstractPubSubClient):super().__init__()self._pubsub_client=pubsub_clientself._events:list[ddd.AbstractEvent]=[]defhandle(self,event:ddd.TEvent)->None:self._pubsub_client.notify_kpi_service(event)@propertydefevents(self)->list[ddd.AbstractEvent]:returnlist(self._events)defcommit(self)->None:self._pubsub_client.commit()defrollback(self)->None:self._pubsub_client.rollback()Advantages of applying the above-mentioned Domain-Driven Design Tactical PatternsA clear separation of concerns between the business rules (which reside solely inside the domain layer),
the application flows (which reside in the service layer) and the IO related operations -
such as communication with databases/web services/file system (which reside in the adapters layer)This separation of concerns make this kind of code very suitable for unit & integration tests -
the service & domain layers can be fully unit tested and the adapter layer can easily
be integration tested (without being concerned with any business logic leaking from the other layers -
so that the integration tests can remain simple)A common code base structure makes it much easier for other developers,
who are aware of this structure, to get into the code.