Package: lato
LatoOverviewLato is a Python microframework designed for building modular monoliths and loosely coupled applications.FeaturesModularity: Organize your application into smaller, independent modules for better maintainability.Flexibility: Loosely couple your application components, making them easier to refactor and extend.Testability: Easily test your application components in isolation.Minimalistic: Intuitive and lean API for rapid development without the bloat.InstallationInstalllatousing pip:pipinstalllatoQuickstartHere's a simple example to get you started:fromlatoimportApplication,TransactionContextfromuuidimportuuid4classUserService:defcreate_user(self,email,password):...classEmailService:defsend_welcome_email(self,email):...app=Application(name="Hello World",# dependenciesuser_service=UserService(),email_service=EmailService(),)defcreate_user_use_case(email,password,session_id,ctx:TransactionContext,user_service:UserService):# session_id, TransactionContext and UserService are automatically injected by `ctx.call`print("Session ID:",session_id)user_service.create_user(email,password)ctx.publish("user_created",email)@app.handler("user_created")defon_user_created(email,email_service:EmailService):email_service.send_welcome_email(email)withapp.transaction_context(session_id=uuid4())asctx:# session_id is transaction scoped dependencyresult=ctx.call(create_user_use_case,"alice@example.com","password")Example of a modular monolithLato is designed to help you build modular monoliths, with loosely coupled modules. This example shows how to
introduce a structure in your application and how to exchange messages (events) between modules.Let's imagine that we are building an application that allows the company to manage its candidates,
employees and projects. Candidates and employees are managed by theemployeemodule, while projects are managed by
theprojectmodule. When a candidate is hired, theemployeemodule publishes aCandidateHiredevent, which is handled
by theemployeemodule to send a welcome email. When an employee is fired, theemployeemodule publishes anEmployeeFiredevent, which is handled by both theemployeeandprojectmodules to send an exit email and
to remove an employee from any projects, respectively.First, let's start with commands that holds all the required information to execute a use case:# commands.pyfromlatoimportCommandclassAddCandidate(Command):candidate_id:strcandidate_name:strclassHireCandidate(Command):candidate_id:strclassFireEmployee(Command):employee_id:strclassCreateProject(Command):project_id:strproject_name:strclassAssignEmployeeToProject(Command):employee_id:strproject_id:strAnd the events that are published by the application (note that all events are expressed in past tense):# events.pyfromlatoimportEventclassCandidateHired(Event):candidate_id:strclassEmployeeFired(Event):employee_id:strclassEmployeeAssignedToProject(Event):employee_id:strproject_id:strNow let's define the employee module. Each function which is responsible for handling a specific command is decorated
withemployee_module.handler. Similarly, each function which is responsible for handling a specific event is
decorated withemployee_module.on.# employee_module.pyfromlatoimportApplicationModulefromcommandsimportAddCandidate,HireCandidate,FireEmployeefromeventsimportCandidateHired,EmployeeFiredemployee_module=ApplicationModule("employee")@employee_module.handler(AddCandidate)defadd_candidate(command:AddCandidate,logger):logger.info(f"Adding candidate{command.candidate_name}with id{command.candidate_id}")@employee_module.handler(HireCandidate)defhire_candidate(command:HireCandidate,publish,logger):logger.info(f"Hiring candidate{command.candidate_id}")publish(CandidateHired(candidate_id=command.candidate_id))@employee_module.handler(FireEmployee)deffire_employee(command:FireEmployee,publish,logger):logger.info(f"Firing employee{command.employee_id}")publish(EmployeeFired(employee_id=command.employee_id))@employee_module.handler(CandidateHired)defon_candidate_hired(event:CandidateHired,logger):logger.info(f"Sending onboarding email to{event.candidate_id}")@employee_module.handler(EmployeeFired)defon_employee_fired(event:EmployeeFired,logger):logger.info(f"Sending exit email to{event.employee_id}")As you can see, some functions have additional parameters (such asloggerorpublish) which are automatically
injected by the application (to be more specific, by a transaction context) upon command or event execution. This allows
you to test your functions in isolation, without having to worry about dependencies.The structure of the project module is similar to the employee module:# project_module.pyfromlato.application_moduleimportApplicationModulefromcommandsimportCreateProject,AssignEmployeeToProjectfromeventsimportEmployeeFired,EmployeeAssignedToProjectproject_module=ApplicationModule("project")@project_module.handler(EmployeeFired)defon_employee_fired(event:EmployeeFired,logger):logger.info(f"Checking if employee{event.employee_id}is assigned to a project")@project_module.handler(CreateProject)defcreate_project(command:CreateProject,logger):logger.info(f"Creating project{command.project_name}with id{command.project_id}")@project_module.handler(AssignEmployeeToProject)defassign_employee_to_project(command:AssignEmployeeToProject,publish,logger):logger.info(f"Assigning employee{command.employee_id}to project{command.project_id}")publish(EmployeeAssignedToProject(employee_id=command.employee_id,project_id=command.project_id))@project_module.handler(EmployeeAssignedToProject)defon_employee_assigned_to_project(event:EmployeeAssignedToProject,logger):logger.info(f"Sending 'Welcome to project{event.project_id}' email to employee{event.employee_id}")Keep in mind that theemployee_moduleis not aware of theproject_moduleand
vice versa. The only way to communicate between modules is through events.Finally, let's put everything together:# application.pyimportloggingimportuuidfromlatoimportApplication,TransactionContextfromemployee_moduleimportemployee_modulefromproject_moduleimportproject_modulefromcommandsimportAddCandidate,HireCandidate,CreateProject,AssignEmployeeToProject,FireEmployeelogger=logging.getLogger()logger.setLevel(logging.DEBUG)console_handler=logging.StreamHandler()console_handler.setLevel(logging.DEBUG)formatter=logging.Formatter('%(name)s-%(levelname)s-%(message)s')console_handler.setFormatter(formatter)logger.addHandler(console_handler)app=Application("Modular Application",logger=logger)app.include_submodule(project_module)app.include_submodule(employee_module)@app.on_enter_transaction_contextdefon_enter_transaction_context(ctx:TransactionContext):logger=ctx[logging.Logger]transaction_id=uuid.uuid4()logger=logger.getChild(f"transaction-{transaction_id}")ctx.dependency_provider.update(logger=logger,transaction_id=transaction_id,publish=ctx.publish)logger.debug("<<< Begin transaction")@app.on_exit_transaction_contextdefon_exit_transaction_context(ctx:TransactionContext,exception=None):logger=ctx[logging.Logger]logger.debug(">>> End transaction")@app.transaction_middlewaredeflogging_middleware(ctx:TransactionContext,call_next):logger=ctx[logging.Logger]description=f"{ctx.current_action[1]}->{repr(ctx.current_action[0])}"ifctx.current_actionelse""logger.debug(f"Executing{description}...")result=call_next()logger.debug(f"Finished executing{description}")returnresultapp.execute(command=AddCandidate(candidate_id="1",candidate_name="Alice"))app.execute(command=HireCandidate(candidate_id="1"))app.execute(command=CreateProject(project_id="1",project_name="Project 1"))app.execute(command=AssignEmployeeToProject(employee_id="1",project_id="1"))app.execute(command=FireEmployee(employee_id="1"))The first thing to notice is that theApplicationclass is instantiated with alogger. This logger is used as
an application level dependency. TheApplicationclass also provides a way to include submodules using theinclude_submodulemethod. This method will automatically register all the handlers and listeners defined in the
submodule.Next, we have theon_enter_transaction_contextandon_exit_transaction_contexthooks. These hooks are called
whenever a transaction context is created or destroyed. The transaction context is automatically created whenapp.executeis called. The purpose of a transaction context is to hold all the dependencies that are required
to execute a command or handle an event, and also to create any transaction level dependencies. In this example, we
use theon_enter_transaction_contexthook to update the transaction context with a logger and a transaction id,
but in a real application you would probably want to use the hooks to begin a database transaction and commit/rollback
any changes. If you need to get a dependency from the transaction context, you can use thectx[identifier]syntax,
whereidentifieris the name (i.e.logger) or type (i.e.logging.Logger) of the dependency.There is also alogging_middlewarewhich is used to log the execution of any commands and events. This middleware is
automatically called whenever a command or event is executed, and there may be multiple middlewares chained together.Finally, we have theapp.executecalls which are used to execute commands and events. Theapp.executemethod
automatically creates a transaction context and calls thecallmethod of the transaction context. Thecallmethod
is responsible for executing the command or event, and it will automatically inject any dependencies that are required.In addition, you can useapp.publishto publish any external event, i.e. from a webhooks or a message queue.ExamplesCheck out theexamplesandtestsfor more examples.DocsComing soon...TestingRun the tests using pytest:pytesttestsWhat lato actually means?Latois the Polish word for"summer". And we all know that summer is more fun than spring ;)