Skip to content
Walid Karray
Go back

Building a Todo App with TypeScript Using Clean Architecture: A Detailed Look at the Directory Structure

Photo by Donny Jiang on Unsplash

Photo by Donny Jiang on Unsplash

Building a Todo App with TypeScript Using Clean Architecture: A Detailed Look at the Directory Structure

Introduction

Clean Architecture, also known as Hexagonal Architecture, is a design pattern that promotes a modular approach to software development by emphasizing the separation of concerns, maintainability, and testability. This pattern allows developers to build scalable and adaptable applications, with each component being independent and easily replaceable. In this article, we’ll take an in-depth look at the directory structure and its contents when creating a Todo app using TypeScript and Clean Architecture principles. We will explore each directory, explain its purpose, and delve into the specific files inside it, providing a comprehensive understanding of how the various components work together to create a cohesive application.

Directory Structure

Here’s the proposed directory structure for a TypeScript Todo app using Clean Architecture:

todo-app/
│
├── src/                           # Contains the source code of the app
│   ├── core/                      # Contains the core business logic of the app
│   │   ├── domain/                # Contains the domain entities and business rules
│   │   │   ├── entities/          # Contains the Todo entity and related classes
│   │   │   │   ├── todo.ts
│   │   │   │   └── ...
│   │   │   └── use-cases/         # Contains the business use cases (application services)
│   │   │       ├── createTodo.ts
│   │   │       ├── deleteTodo.ts
│   │   │       ├── updateTodo.ts
│   │   │       └── ...
│   │   └── repositories/          # Contains repository interfaces for data access
│   │       ├── todoRepository.ts
│   │       └── ...
│   │
│   ├── infrastructure/            # Contains the implementation of external dependencies
│   │   ├── database/              # Contains the database setup and ORM configuration
│   │   │   ├── connection.ts
│   │   │   └── ...
│   │   └── repositories/          # Contains the implementation of repository interfaces
│   │       ├── todoRepositoryImpl.ts
│   │       └── ...
│   │
│   ├── application/               # Contains the application layer logic
│   │   ├── controllers/           # Contains the API controllers (e.g., for RESTful APIs)
│   │   │   ├── todoController.ts
│   │   │   └── ...
│   │   └── dtos/                  # Contains data transfer objects for input validation and output formatting
│   │       ├── createTodoDTO.ts
│   │       ├── updateTodoDTO.ts
│   │       └── ...
│   │
│   └── config/                    # Contains configuration files and constants
│       ├── app.ts                 # Main application configuration
│       ├── database.ts            # Database configuration
│       └── ...
│
├── tests/                         # Contains unit and integration tests
│   ├── core/
│   │   ├── domain/
│   │   │   └── ...
│   │   └── repositories/
│   │       └── ...
│   ├── infrastructure/
│   │   └── ...
│   └── application/
│       ├── controllers/
│       │   └── ...
│       └── ...
│
├── .gitignore
├── package.json
├── tsconfig.json
└── README.md

Core Layer

The core layer contains the main business logic of the application. It is composed of three main subdirectories:

Here’s a snippet of the todo.ts file:

class Todo {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  createdAt: Date;
  updatedAt: Date;

  constructor(title: string, description: string) {
    this.id = generateId();
    this.title = title;
    this.description = description;
    this.completed = false;
    this.createdAt = new Date();
    this.updatedAt = new Date();
  }

  markAsCompleted(): void {
    this.completed = true;
    this.updatedAt = new Date();
  }

  // Additional methods for updating, etc.
}

function generateId(): string {
  // Generate a unique identifier for each Todo instance
}

In this snippet, the Todo class represents a single Todo item, with properties such as id, title, description, completed, createdAt, and updatedAt. The class also includes methods for managing the Todo item, such as markAsCompleted().

For example, here’s a snippet of the createTodo.ts file:

import { Todo } from '../entities/todo';
import { TodoRepository } from '../repositories/todoRepository';

interface CreateTodoInput {
  title: string;
  description: string;
}

export class CreateTodo {
  private todoRepository: TodoRepository;

  constructor(todoRepository: TodoRepository) {
    this.todoRepository = todoRepository;
  }

  async execute(input: CreateTodoInput): Promise<Todo> {
    const { title, description } = input;
    const todo = new Todo(title, description);
    await this.todoRepository.add(todo);
    return todo;
  }
}

In this snippet, the CreateTodo class represents the use case for creating a new Todo item. It takes an instance of the TodoRepository interface as a dependency and uses it to persist the created Todo item. The execute method accepts an input object with title and description properties, creates a new Todo instance, adds it to the repository, and returns the created Todo item.

Here’s a snippet of the todoRepository.ts file:

import { Todo } from '../domain/entities/todo';

export interface TodoRepository {
  add(todo: Todo): Promise<void>;
  findById(id: string): Promise<Todo | null>;
  findAll(): Promise<Todo[]>;
  update(todo: Todo): Promise<void>;
  delete(id: string): Promise<void>;
}

In this snippet, the TodoRepository interface specifies the required methods for any Todo repository implementation, such as add, findById, findAll, update, and delete. These methods define the expected behavior for data persistence, allowing the core layer to remain decoupled from the specific data storage technology used in the infrastructure layer.

Infrastructure Layer

The infrastructure layer in a Clean Architecture-based application is responsible for providing concrete implementations of interfaces, protocols, or services defined in the core (or domain) layer. It handles interactions with external systems, such as databases, file systems, or third-party services, allowing the core layer to remain agnostic to specific technologies or libraries.

In the context of our Todo app, the infrastructure layer primarily focuses on the following aspects:

  1. Data persistence: The infrastructure layer contains the actual implementation of the repository interfaces defined in the core layer. These implementations use a specific technology or library, such as an ORM (Object-Relational Mapping) like TypeORM, to interact with the data storage system (e.g., a database). By doing this, the infrastructure layer takes care of data storage and retrieval, while the core layer remains decoupled from the specific data storage technology.
  2. External services: When an application relies on third-party services, such as sending emails or interacting with APIs, the infrastructure layer is responsible for managing these interactions. It provides concrete implementations for the required services, which can be injected into the core layer as needed.
  3. Configuration and setup: The infrastructure layer also handles the configuration of various components, such as setting up a database connection, configuring a web server, or initializing external services. By centralizing these configurations in the infrastructure layer, the core layer remains focused on the business logic and rules.

In our TypeScript Todo app, the infrastructure layer contains the following components:

Here’s a snippet of the connection.ts file:

import { createConnection, Connection } from 'typeorm';
import { Todo } from '../core/domain/entities/todo';
import databaseConfig from '../config/database';

const connectDatabase = async (): Promise<Connection> => {
  return createConnection({
    type: databaseConfig.type as any,
    host: databaseConfig.host,
    port: databaseConfig.port,
    username: databaseConfig.username,
    password: databaseConfig.password,
    database: databaseConfig.database,
    entities: [Todo],
    synchronize: true,
    logging: false,
  });
};

export default connectDatabase;

In this snippet, the connectDatabase function sets up the connection to the database using the TypeORM library. It specifies the database type (PostgreSQL in this case), connection details, and the entities to be managed by the ORM. The connectDatabase function returns a Promise that resolves to a Connection object, which can be used throughout the application to interact with the database.

Here’s a snippet of the todoRepositoryImpl.ts file:

import { Repository, EntityRepository } from 'typeorm';
import { Todo } from '../../core/domain/entities/todo';
import { TodoRepository } from '../../core/repositories/todoRepository';

@EntityRepository(Todo)
export class TodoRepositoryImpl extends Repository<Todo> implements TodoRepository {
  async add(todo: Todo): Promise<void> {
    await this.save(todo);
  }

  async findById(id: string): Promise<Todo | null> {
    return this.findOne(id);
  }

  async findAll(): Promise<Todo[]> {
    return this.find();
  }

  async update(todo: Todo): Promise<void> {
    await this.save(todo);
  }

  async delete(id: string): Promise<void> {
    await this.delete(id);
  }
}

In this snippet, the TodoRepositoryImpl class extends the TypeORM Repository class and implements the TodoRepository interface. It provides the actual implementation of the methods defined in the TodoRepository interface, such as add, findById, findAll, update, and delete. These methods interact with the ORM to perform the necessary data persistence operations.

Application Layer

The application layer handles the interaction between the user and the application. It is composed of:

Here’s a snippet of the todoController.ts file:

import { FastifyRequest, FastifyReply } from 'fastify';
import { CreateTodo } from '../../core/use-cases/createTodo';
import { TodoRepositoryImpl } from '../../infrastructure/repositories/todoRepositoryImpl';

const todoRepository = new TodoRepositoryImpl();

export const createTodo = async (req: FastifyRequest, res: FastifyReply): Promise<void> => {
  try {
    const { title, description } = req.body;
    const createTodoUseCase = new CreateTodo(todoRepository);
    const newTodo = await createTodoUseCase.execute({ title, description });

    res.status(201).send({
      success: true,
      data: newTodo,
    });
  } catch (error) {
    res.status(400).send({
      success: false,
      message: error.message,
    });
  }
};

// Additional controller methods for handling other Todo-related API requests

In this snippet, the createTodo function serves as the controller for creating a new Todo item using Fastify. It extracts the required data from the req.body object, creates an instance of the CreateTodo use case, and invokes its execute method. The resulting newTodo object is then sent back to the client using Fastify’s res.send() method with a status code of 201. Error handling is incorporated to ensure that any issues encountered during the process are communicated back to the client with an appropriate status code and error message. This controller can be extended with additional methods for handling other Todo-related API requests.

Here’s a snippet of the createTodoDTO.ts file:

import { IsString, IsNotEmpty, MaxLength } from 'class-validator';

export class CreateTodoDTO {
  @IsString()
  @IsNotEmpty()
  @MaxLength(100)
  title: string;

  @IsString()
  @IsNotEmpty()
  @MaxLength(500)
  description: string;
}

In this snippet, the CreateTodoDTO class defines the structure and validation rules for creating a new Todo item. The title and description properties are decorated with validation decorators from the class-validator library, such as IsString, IsNotEmpty, and MaxLength. These decorators help ensure that the input data received from the client conforms to the expected format and constraints before being processed by the application.

Config Layer

The config layer contains the configuration files and constants for the application, such as:

Here’s a snippet of the app.ts file:

// src/config/app.ts

import fastify, { FastifyInstance } from 'fastify';
import cors from 'fastify-cors';
import helmet from 'fastify-helmet';
import connectDatabase from '../infrastructure/database/connection';
import { todoController } from '../application/controllers/todoController';

const app: FastifyInstance = fastify({ logger: true });

// Middleware
app.register(cors);
app.register(helmet);

// Connect to the database
connectDatabase().then(() => {
  app.log.info('Database connected');
}).catch((error) => {
  app.log.error('Database connection failed:', error);
});

// Routes
app.get('/todos', todoController.getTodos);
app.post('/todos', todoController.createTodo);
app.put('/todos/:id', todoController.updateTodo);
app.delete('/todos/:id', todoController.deleteTodo);

// Error handling
app.setErrorHandler((error, request, reply) => {
  app.log.error(error);
  reply.status(500).send('Internal Server Error');
});

export default app;

In this example, the app.ts file starts by importing the necessary packages and modules, such as Fastify, CORS, Helmet, and the Todo controller. The file then initializes a Fastify instance and sets up the middleware.

Next, the file connects to the database using the connectDatabase function from database/connection.ts.

After setting up the middleware and connecting to the database, the file defines the routes for the Todo app, using the methods from the todoController as handlers for each route.

Finally, the app.ts file sets up basic error handling to log errors and respond with a 500 status code for internal server errors. The Fastify instance is then exported for use in other parts of the application, such as starting the server.

Here’s a snippet of the database.ts file:

interface DatabaseConfig {
  type: string;
  host: string;
  port: number;
  username: string;
  password: string;
  database: string;
}

const databaseConfig: DatabaseConfig = {
  type: process.env.DB_TYPE || 'postgres',
  host: process.env.DB_HOST || 'localhost',
  port: Number(process.env.DB_PORT) || 5432,
  username: process.env.DB_USERNAME || 'your_username',
  password: process.env.DB_PASSWORD || 'your_password',
  database: process.env.DB_NAME || 'todo_app',
};

export default databaseConfig;

In this snippet, the connectDatabase function sets up the connection to the database using the TypeORM library. It specifies the database type (PostgreSQL in this case), connection details, and the entities to be managed by the ORM. The connectDatabase function returns a Promise that resolves to a Connection object, which can be used throughout the application to interact with the database.

Tests

The tests directory contains unit and integration tests for the application, grouped by their respective layers. These tests ensure the correct functioning of the application and can help identify issues early in the development process. For example:

Conclusion

In this article, we have taken a detailed look at the directory structure and its contents when creating a Todo app using TypeScript and Clean Architecture. By following these guidelines, you can create a solid foundation for your TypeScript application and ensure its long-term success.

The Clean/Hexagonal Architecture allows you to keep a separation of concerns and maintain a highly testable and maintainable codebase. This architecture enables developers to quickly adapt to changes and scale the application as needed.


Share this post:

Previous Post
Mastering Static Website Hosting on AWS with Terraform: A Step-by-Step Tutorial
Next Post
Configuring a custom domain for AWS Lambda Function URLs