Separating Domain Objects

tacoda

Ian Johnson

Posted on January 16, 2024

Separating Domain Objects

Before we continue, let's do a quick recap of the current state of our code.

todo.py

import sys
from todo.task import Task


if __name__ == '__main__':
    args = sys.argv
    if(args[1] == 'add'):
        if(len(args[2:]) == 0):
            sys.stdout.buffer.write(
                "Error: Missing todo string. Nothing added!".encode('utf8'))
        else:
            with open('todo.txt', 'a') as f:
                    task = Task(f)
                    task.add(''.join(args[2:]))

    elif(args[1] == 'done' and len(args[2:]) == 0):
        if(len(args[2:]) == 0):
            sys.stdout.buffer.write(
                "Error: Missing NUMBER for marking todo as done.".encode('utf8'))
        else:
            Task.done(args[2:])

    elif(args[1] == 'del' and len(args[2:]) == 0):
        if(len(args[2:]) == 0):
            sys.stdout.buffer.write(
                "Error: Missing NUMBER for deleting todo.".encode('utf8'))
        else:
            Task.deL(args[2])

    elif(args[1] == 'ls'):
        Task.ls()
Enter fullscreen mode Exit fullscreen mode

todo/task.py

import sys
import datetime

d = {}
don = {}

class Task:
    def __init__(self, todo_file, done_file):
        self.todo_file = todo_file
        self.done_file = done_file

    def add(self, s):
        self.todo_file.write(s)
        self.todo_file.write("\n")
        s = '"'+s+'"'
        print(f"Added todo: {s}")

    def help():
        sa = """Usage :-
    $ ./todo add "todo item" # Add a new todo
    $ ./todo ls          # Show remaining todos
    $ ./todo del NUMBER  # Delete a todo
    $ ./todo done NUMBER     # Complete a todo
    $ ./todo help            # Show usage
    $ ./todo report      # Statistics"""
        sys.stdout.buffer.write(sa.encode('utf8'))

    def ls(self):
        try:

            Task.nec()
            l = len(d)
            k = l

            for i in d:
                sys.stdout.buffer.write(f"[{l}] {d[l]}".encode('utf8'))
                sys.stdout.buffer.write("\n".encode('utf8'))
                l = l-1

        except Exception as e:
            raise e

    def deL(no):
        try:
            no = int(no)
            Task.nec()
            with open("todo.txt", "r+") as f:
                lines = f.readlines()
                f.seek(0)
                for i in lines:
                    if i.strip('\n') != d[no]:
                        f.write(i)
                f.truncate()
            print(f"Deleted todo #{no}")

        except Exception as e:
            print(f"Error: todo #{no} does not exist. Nothing deleted.")

    def done(self, no):
        try:

            nec()
            no = int(no)
            st = 'x '+str(datetime.datetime.today()).split()[0]+' '+d[no]
            self.done_file.write(st)
            self.done_file.write("\n")
            print(f"Marked todo #{no} as done.")

            with open("todo.txt", "r+") as f:
                lines = f.readlines()
                f.seek(0)
                for i in lines:
                    if i.strip('\n') != d[no]:
                        f.write(i)
                f.truncate()
        except:
            print(f"Error: todo #{no} does not exist.")

    def report():
        nec()
        try:

            nf = open('done.txt', 'r')
            c = 1
            for line in nf:
                line = line.strip('\n')
                don.update({c: line})
                c = c+1
            print(
                f'{str(datetime.datetime.today()).split()[0]} Pending : {len(d)} Completed : {len(don)}')
        except:
            print(
                f'{str(datetime.datetime.today()).split()[0]} Pending : {len(d)} Completed : {len(don)}')

    def nec():
        try:
            f = open('todo.txt', 'r')
            c = 1
            for line in f:
                line = line.strip('\n')
                d.update({c: line})
                c = c+1
        except:
            sys.stdout.buffer.write("There are no pending todos!".encode('utf8'))
Enter fullscreen mode Exit fullscreen mode

todo/task_list.py

class TaskList:
    def __init__(self, todo_file, done_file):
        pass

    def add(self, task_description):
        print('Buy Bread')

    def ls(self):
        print('[1] Buy Milk')
Enter fullscreen mode Exit fullscreen mode

tests/test_task.py

import pytest
from unittest.mock import mock_open, patch
from todo.task import Task

# Skipping this test until we finish re-organizing
@pytest.mark.skip
def test_marking_task_done(task, capsys):
    task.done(1)
    captured = capsys.readouterr()
    assert "Buy Bread" in captured.out

Enter fullscreen mode Exit fullscreen mode

tests/test_task_list.py

import pytest
from unittest.mock import mock_open, patch
from todo.task_list import TaskList

@pytest.fixture
def todo_file():
    mock = mock_open()
    return patch('{}.open'.format(__name__), mock, create=True)

def test_adding_task_to_list(todo_file, capsys):
    with todo_file:
        with open('todo.txt', 'a') as todo:
            with open('done.txt', 'a') as done:
                task_list = TaskList(todo, done)
                task_list.add("Buy Bread")
    captured = capsys.readouterr()
    assert "Buy Bread" in captured.out

def test_listing_tasks(todo_file, capsys):
    with todo_file:
        with open('todo.txt', 'a') as todo:
            with open('done.txt', 'a') as done:
                task_list = TaskList(todo, done)
                task_list.ls()
    captured = capsys.readouterr()
    assert "[1] Buy Milk" in captured.out
Enter fullscreen mode Exit fullscreen mode

And if we run tests:

poetry run pytest
# tests/test_task.py s
# tests/test_task_list.py ..
Enter fullscreen mode Exit fullscreen mode

Separating Domain Objects

So let's continue to separate our domain objects. What do I mean by this? When I refer to your domain, I mean the domain of the product that you are building. We are building a todo app, so that's our domain here. Our domain consists of:

  • A task list
  • Tasks

And any other concepts that may be helpful to us, such as:

  • Projects
  • Tags
  • Priority

What is not in our domain is also vitally important. Our domain does not include:

  • Files
  • Databases
  • Network

Or any other third-party dependencies.

Separating domain objects is simply splitting them up. In our case, Task does way too much. It is also doing the work of a TaskList. So, in accordance with the single responsibility principle, we should separate this.

Currently, we are separating the Task from the TaskList. Let's start with add. Right now, our test for adding to a task list is passing. Although, it is not a good test. We also have some stubbed code in our implementation just to make the test pass. Let's take the next step and promote the add behavior from the Task to the TaskList.

First, just move the implementation:

class TaskList:
    def __init__(self, todo_file, done_file):
        pass

    def add(self, task_description):
        self.todo_file.write(s)
        self.todo_file.write("\n")
        s = '"' + s + '"'
        print(f"Added todo: {s}")
Enter fullscreen mode Exit fullscreen mode

And if we run tests:

poetry run pytest
# E       AttributeError: 'TaskList' object has no attribute 'todo_file'
Enter fullscreen mode Exit fullscreen mode

Here, we get an error. We are referring to a todo_file that we never assigned. Update the constructor:

class TaskList:
    def __init__(self, todo_file, done_file):
        self.todo_file = todo_file

    def add(self, task_description):
        self.todo_file.write(s)
        self.todo_file.write("\n")
        s = '"' + s + '"'
        print(f"Added todo: {s}")
Enter fullscreen mode Exit fullscreen mode

Running tests again:

poetry run pytest
# E       UnboundLocalError: cannot access local variable 's' where it is not associated with a value
Enter fullscreen mode Exit fullscreen mode

Our copied code still references an old variable. Let's update that reference.

class TaskList:
    def __init__(self, todo_file, done_file):
        self.todo_file = todo_file

    def add(self, task_description):
        self.todo_file.write(task_description)
        self.todo_file.write("\n")
        s = '"' + task_description + '"'
        print(f"Added todo: {s}")
Enter fullscreen mode Exit fullscreen mode

Running tests again:

poetry run pytest
# tests/test_task_list.py ..
Enter fullscreen mode Exit fullscreen mode

And they pass!

As a side note, did you notice how many things we had to change from copy-pasting code? This is one of the main reasons why doing that can be problematic. Even worse, if your codebase happens to be untested then it's likely you'll find it in production as a bug.

Now we can delete the Task.add method entirely. Running the tests again verifies that this does not break our implmentation.

Next up is Task.ls. Here's the method that we will move:

def ls(self):
    try:

        Task.nec()
        l = len(d)
        k = l

        for i in d:
            sys.stdout.buffer.write(f"[{l}] {d[l]}".encode('utf8'))
            sys.stdout.buffer.write("\n".encode('utf8'))
            l = l-1

    except Exception as e:
        raise e
Enter fullscreen mode Exit fullscreen mode

Notice the call to Task.nec? We'll have to bring that along. Also notice the references to d. Those are legacy references from the previous implementation. Here's what Task.nec looks like.

def nec():
    try:
        f = open('todo.txt', 'r')
        c = 1
        for line in f:
            line = line.strip('\n')
            d.update({c: line})
            c = c+1
    except:
        sys.stdout.buffer.write("There are no pending todos!".encode('utf8'))
Enter fullscreen mode Exit fullscreen mode

Now based on the amount of code we have to move and the fact that it depends on global variables, it doesn't even seem worth it to copy-paste. So, let's take a more intentional first step.

First, let's just delete Task.ls entirely. Then we run tests again.

poetry run pytest
# tests/test_task_list.py ..
Enter fullscreen mode Exit fullscreen mode

Huh? They are green? Take a look at the tests again. We are no longer testing Task.ls. Instead we are testing TaskList.ls. This gives us more confidence moving forward. Now let's update the implementation. We'll do basically the same thing as add, but instead of appending to a file, we'll read a file.

def ls(self):
    lines = self.todo_file.readlines()
    for i in lines:
        print(i.strip())
Enter fullscreen mode Exit fullscreen mode

Running tests:

poetry run pytest
# FAILED tests/test_task_list.py::test_listing_tasks - AssertionError: assert '[1] Buy Milk' in ''
Enter fullscreen mode Exit fullscreen mode

Now our test is giving us a proper failure. We are no longer just printing something to make it pass. So why is it failing? Well, the assertion is expecting [1] Buy Milk, but it's getting nothing. Why? Because we are mocking the file, so there's no content. Let's make the mock return that.

tests/test_task_list.py

@pytest.fixture
def todo_file():
    mock = mock_open(read_data='[1] Buy Milk')
    return patch('{}.open'.format(__name__), mock, create=True)
Enter fullscreen mode Exit fullscreen mode

We have just added a read_data option to predefine the content of our mocked file. Now let's run tests again.

poetry run pytest
# tests/test_task_list.py .. 
Enter fullscreen mode Exit fullscreen mode

And we are back to Green! Next up, we are going to move more methods into the TaskList and address how to clean up our tests in test_task_list.py

Key Takeaways

  • Separate domain objects so they have a single responsiblity
  • Avoid copy-pasting code; it leads to bugs
  • Use tests and domain-level thinking to define domain concepts in your code
    • This allows you to focus on what is important, rather than what is incidental
💖 💪 🙅 🚩
tacoda
Ian Johnson

Posted on January 16, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related