Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Command Line Interface Challenge

Iteration 3. Layered Architecture

Right now, we have semi-structured code. It is semi-structured because it does not have a well-defined boundaries between the layers. In order to create more structured code, we need to define the parts of our application by providing a definition for each part. Lets try to define the parts of our application:

  • index.ts: This is the entry point of our application. It is responsible for parsing the command line arguments and calling the correct functions or methods.
  • TodoService.ts: This is the service layer of our application. It is responsible for the business logic of the todo list. It is responsible for adding, listing, marking as done, marking as undone, and deleting the todos.

According to these definitions, we can say that TodoService does not depend on index.ts but index.ts depends on TodoService. Therefore, we have two parts and one part depends another while the dependent part is independent.

Let me give an anology for this. We can see our project as a cake and parts of the project as layers of the cake. Dependent part index.ts is located at the top of the independent part TodoService.ts. If underlying part of the cake layer is not ready yet, you cannot put the upper layer on it. Therefore, the existence of upper layer depends on the existence of the underlying layer.

Therefore, our current implementation has two layers right now. The first layer is the index.ts file and the second layer is the TodoService.ts file. A careful reader may notice that our TodoService does not have a single responsibility because it handles;

  • The business logic of the todo list.
  • The file operations (reading and writing the JSON file).
  • Printing the information to the console.

And, this conflicts with the Single Responsibility Principle. Therefore, we need to redifine our layers and arrange our files according to the new layers. Lets try to redefine our layers in a more rigorous way:

  • Presentation Layer: Handles all of the user interactions. (Parsing the command line arguments and printing the results)
  • Service Layer: Validates and runs all the business logic of the application.
  • Data Access Layer: Stores and retries the data that is produced by the service layer.

Cake

According to these definitions, lets arrange our files:

  • Presentation Layer: We should handle all of the operations that is related to user activity in index.ts file. Therefore, we need to handle all command line related operations in the index.ts file.
  • Service Layer: We should handle all of the business logic of the todo list in the TodoService.ts file and we should only handle bussiness logic in this layer. Therefore, we need to move console logging operations to the index.ts file and data access related operations to the Data Access Layer.
  • Data Access Layer: We should handle all of the file operations in the TodoRepository.ts file and again we should only handle file operations in this layer. We call it TodoRepository because by definition (non-techical) a repository is a place where things are stored.

Lets refactor them by starting from TodoRepository.ts:

import fs from 'fs';

export interface Todo {
  id: string;
  title: string;
  description: string;
  completed: boolean;
}

class TodoRepository {
  fileName: string = 'todos.json';

  constructor() {}

  public async readTodos(): Promise<Todo[]> {
    try {
      await fs.promises.stat('todos.json');
    } catch {
      await fs.promises.writeFile('todos.json', '[]');
    }

    const fileConent = await fs.promises.readFile('todos.json', 'utf-8');
    const todoList = JSON.parse(fileConent) as Todo[];

    return todoList;
  }

  public async writeTodos(todoList: Todo[]): Promise<void> {
    const updatedFileContent = JSON.stringify(todoList, null, 2);
    await fs.promises.writeFile(this.fileName, updatedFileContent);
  }
}

export default TodoRepository;

Our repository is so simple. Lets move to the TodoService.ts file:

import TodoRepository, { Todo } from './TodoRepository';
import crypto from 'crypto';

// Define the TodoService class
class TodoService {
  repository: TodoRepository;

  constructor() {
    this.repository = new TodoRepository();
  }

  public async addTodo(title: string, description: string): Promise<void> {
    const todoList = await this.repository.readTodos();

    // Validate the input
    if (!title || typeof title !== 'string' || title.length < 2 || title.length > 255) throw new Error('Title is required and its length must be between 2 and 255 characters');
    if (description && (typeof description !== 'string' || description.length > 4096)) throw new Error('Description length must be less than 4096 characters');

    // Generate a new todo
    const id = crypto.randomBytes(4).toString('hex');
    const newTodo: Todo = {
      id: id,
      title,
      description,
      completed: false,
    };

    // Add the new todo to the list
    todoList.push(newTodo);

    await this.repository.writeTodos(todoList);
  }

  public async listTodos(): Promise<Todo[]> {
    const todoList = await this.repository.readTodos();
    return todoList;
  }

  public async markTodoAsDone(id: string): Promise<void> {
    const todoList = await this.repository.readTodos();

    // Find the todo with the given id
    const todo = todoList.find((todo) => todo.id === id);
    if (!todo) throw new Error('Todo not found');

    // Mark the todo as completed
    todo.completed = true;

    await this.repository.writeTodos(todoList);
  }

  public async markTodoAsUndone(id: string): Promise<void> {
    const todoList = await this.repository.readTodos();

    // Find the todo with the given id
    const todo = todoList.find((todo) => todo.id === id);
    if (!todo) throw new Error('Todo not found');

    // Mark the todo as not completed
    todo.completed = false;

    await this.repository.writeTodos(todoList);
  }

  public async deleteTodo(id: string): Promise<void> {
    const todoList = await this.repository.readTodos();

    // Delete the todo with the given id
    const index = todoList.findIndex((todo) => todo.id === id);
    if (index === -1) throw new Error('Todo not found');
    todoList.splice(index, 1);

    await this.repository.writeTodos(todoList);
  }
}

export default TodoService;

As you can see, we do not have any methods for file operations in the TodoService class. Instead, we are creating an instance of the TodoRepository class and we are using it to read and write the JSON file. This way, we are separating the file operations from the business logic. Also, another improvement that we made is removing the console logging operations from the TodoService class. Instead of printing the information to the console directly in the TodoService class, we are returning the necessary information needed to print to the console to the caller. This way, our TodoService class becomes much more cleaner and maintainable. Lets refactor the index.ts file:

#!/usr/bin/env -S npx tsx
import process from 'process';
import TodoService from './TodoService';

const todoService = new TodoService();

async function main() {
  // Get command line arguments
  const [program, script, subcommand, ...args] = process.argv;

  switch (subcommand) {
    case 'add': {
      const [title, description] = args;
      await todoService.addTodo(title, description);
      console.log('New todo added');
    }
    case 'list': {
      const todoList = await todoService.listTodos();

      // Print the list of todos
      for (const todo of todoList) {
        const status = todo.completed ? 'X' : ' ';
        console.log(`- [${status}] (id: ${todo.id}) ${todo.title}`);
        if (todo.description) console.log(`\t${todo.description}`);
      }

      break;
    }
    case 'done': {
      try {
        const [id] = args;
        await todoService.markTodoAsDone(id);
        console.log('Todo marked as done');
        break;
      } catch (error) {
        if (error instanceof Error) console.log(error.message);
        process.exit(1);
      }
    }
    case 'undone': {
      try {
        const [id] = args;
        await todoService.markTodoAsUndone(id);
        console.log('Todo marked as undone');
        break;
      } catch (error) {
        if (error instanceof Error) console.log(error.message);
        process.exit(1);
      }
    }
    case 'delete': {
      try {
        const [id] = args;
        await todoService.deleteTodo(id);
        console.log('Todo deleted');
        break;
      } catch (error) {
        if (error instanceof Error) console.log(error.message);
        process.exit(1);
      }
    }
    default:
      // Print help messages
      console.log(`Unknown subcommand`);
      console.log(`Usage: tdr <subcommand> [args]`);
      console.log(`Subcommands: add, list, done, undone, delete`);

      // Exit with an error code
      process.exit(1);
  }
}

main();

Summary

In this chapter, you should have learned;

  • What is layered architecture?

There are two important things that you need to get from this chapter:

  1. Layered Architecture: A software architecture that divides the software into different layers. Each layer has a specific responsibility and each layer depends on the underlying layer.