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.
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 theindex.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 theindex.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 itTodoRepository
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:
- 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.