Contents

Business logic and controller decoupling

Contents

Problem:

  • We have a problem regarding the separation between the business logic and the controller.

  • The business logic should be responsible for making the decisions and the controller should act on these decisions.

  • Lets highlight the problem by taking two examples an api and a normal crud.a

  • Api Example:

    • I have two doctypes Student and Course.
    • An api endpoint that calculate the fee that should be payed by the Student based on the number of Courses and type and the status of the student.
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    def calculate_fees(student_id):
        
      student = frappe.get_doc("Student", student_id) 
      if (student.status == 'active'): 
         courses =  frappe.get_all("Course", filters = {"student_id": student_id})
         amount = 0
         for course in courses :
            if course.type == 'virtual':
               amount += 50
            elif course.type == 'non-virtual':
               amount += 100
           
         student.amount = amount
         student.save()
    
    • In the above code the controller which is responsible for the flow of the request and the business logic responsible for the calculation of fee and communication with database are all in one function.

    • Drawbacks:

      • As the code grows the function becomes more complex and unreadable.
      • The function is hard to test, because it contains out of process dependency which is the database and thus it will need integration test with a real database connection to test the code.
      • Weak protection against regression , because tests can’t cover all possible scenarios.
      • Testing will be slow due to the presence of many integration tests instead of unit tests.
    • The code regarding the Course fee calculation should reside in the Course doctype and the controller shall ask for the fee of the Course without knowing about the calculation process by providing the data needed for the course.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
        class Course(Document):
             
           def calculate_course_fee(self):
    
              if self.doc.type == 'virtual':
                 amount = 50
              elif self.doc.type == 'non-virtual':
                 amount = 100
     
              return amount
    
    • By doing this it becomes easier to test the calculation function and easier to spot errors like the one raised from the unhandled else.
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
      from course import Course
    
      def calculate_fees(student_id):
        
        student = frappe.get_doc("Student", student_id) 
        if (student.status == 'active'): 
            courses =  frappe.get_all("Course", filters = {"student_id": student_id})
            amount = 0
            for rec in courses:
                course = Course(rec)
                amount += course.calculate_course_fee()
           
         student.amount = amount
         student.save()
    
    • by doing the same for the student status, the controller becomes more focused on managing the flow and the connection between the collaborators, instead of making decisions which should be done by the domain logic.
  • Crud Example:

    • I have Four doctypes Exam, Student, Attendance, Class
    • I need to create an exam event for a student , taking in consideration the student should be enrolled in the class , the student status should be active , and the attendance percentage shouldn’t be less than 75% and the class should be completed.
    • The following code is for the validate function triggered by frappe before saving
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    
    from frappe.utils import get_time, getdate, now_datetime
    
    class Exam(Document):
       def validate(self):
           student_id = self.doc.student_id
           # get student record
           student = frappe.get_doc("Student", student_id) 
           # check student status
           if(student.status == 'active'):
           # get class record
             class = student = frappe.get_doc("Student", student.class)
             date = getdate(now_datetime())
             class_completed =  True if date > class.end_date else False
             
           # Calculate Attendance Percentage
           if class_completed:
             attendance_records = frappe.get_all("Attendance", filters= {"class": student.class, student: student_id})
             no_os_sessions = class.sessions
             attendance = 0
             for rec in attendance_records:
                if rec.present == 1:
                   attendance +=1
               
             precent = ( attendance / no_os_sessions ) * 100
    
             if precent < 75:
                frappe.throw ("Student Should Be Deprived From Exam Access")
    
    • Drawbacks :

      • Same as above.
      • This approach—when a domain class retrieves and persists itself to the database—is called the Active Record pattern. It works fine in simple or short-lived projects but often fails to scale as the code base grows. The reason is precisely this lack of separation between these two responsibilities: business logic and communication with out-of-process dependencies.
      • The Student, class and Attendance doctype should be explicit and injected from outside , to make the exam doctype more testable.
      • Even if we refactor the doctype to contain only the business related as in the following:
      1
      2
      3
      4
      5
      6
      7
      
         class Exam(Document):
           def validate(self):
              # check if exam is online
              exam = self.doc
              if exam.online :
                 return True
                 
      
      • This simple function is hard to test, because we need to have a real database connection to either mock an exam or even get a real one because the exam doctype inherits from Document class which requires a running frappe server , making it difficult to add unit test which we should use to verify the business logic.
       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      
         from exam import Exam
             
         class TestExam():
           # we need database connection and running server to work (integration test)
           def test_validate(self):
               exam = Exam("Final Exam") # which is a name of a record already exist in db.
               # or
               exam = Exam({ "capacity" : 20 , "online": True}) # which is a dict of the record we want to mock 
               assert exam.validate() == True
                  
      
      • if we removed the business logic into a separate file , it will make the testing easier:

        1
        2
        3
        4
        5
        6
        7
        
            class ExamLogic():
                def __init__(capacity, online):
                    self.online = online
                    self.capacity = capacity
        
                def validate_online(self):
                    return self.online == True
        
        1
        2
        3
        4
        5
        6
        
          # compared to a unit test validating only the mock object only to Exam which is a use factory function or just a dict containing the exam info or even a parameterized info
          from exam_logic import ExamLogic
                
          def test_validate(self):
             exam = ExamLogic(capacity = 20, online = True)
             assert exam.validate() == True
        
      • This results into a simple test that is executed fast and focused on the business only with no need for the server to be running.

  • The solution of these problems won’t only benefit testing even if it does its still worth it , but it will result into more simple and clear code , where we refactor overcomplicated code by splitting it into algorithms and controllers. Ideally, you should have no code in the top-right quadrant. /Items.jpg

  • By adhering to the single responsibility principle and open/closed, the code becomes more maintainable, extensible, and testable. It enables easier modification and extension of functionality without impacting other parts of the system, encourages separation of concerns, promotes code reusability, and facilitates easier testing.