DEV Community

fullsnacker
fullsnacker

Posted on

Proyecto Node.js con TypeScript y Clean Architecture para Gestión de Usuarios

Vamos a implementar un sistema de autenticación desde terminal usando Clean Architecture (Link al repositorio del proyecto).

Aquí te presento la estructura del proyecto:

Estructura del Proyecto

src/
├── core/                    # Capa de dominio
│   ├── entities/           # Entidades de negocio
│   ├── repositories/       # Interfaces de repositorios
│   ├── usecases/           # Casos de uso
│   └── interfaces/         # Interfaces adicionales
├── infrastructure/         # Capa de infraestructura
│   ├── repositories/      # Implementaciones concretas de repositorios
│   ├── cli/               # Interfaz de línea de comandos
│   └── security/          # Utilidades de seguridad
├── application/           # Capa de aplicación (coordinadores)
└── main.ts                # Punto de entrada
Enter fullscreen mode Exit fullscreen mode

Implementación paso a paso

1. Configuración inicial

Primero, instala las dependencias necesarias:

npm init -y
npm install typescript ts-node @types/node bcryptjs inquirer chalk figlet uuid @types/uuid @types/bcryptjs @types/inquirer @types/figlet --save-dev
npx tsc --init
Enter fullscreen mode Exit fullscreen mode

2. Entidad de Usuario (Capa de Dominio)

src/core/entities/user.ts:

export interface User {
  id: string;
  username: string;
  passwordHash: string; // Nunca almacenar contraseñas en texto plano
  createdAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

3. Interfaz del Repositorio (Capa de Dominio)

src/core/repositories/user.repository.ts:

import { User } from "../entities/user";

export interface UserRepository {
  createUser(user: User): Promise<User>;
  findByUsername(username: string): Promise<User | null>;
  findAllUsers(): Promise<User[]>;
  verifyPassword(password: string, hash: string): Promise<boolean>;
}
Enter fullscreen mode Exit fullscreen mode

4. Casos de Uso (Capa de Dominio)

src/core/usecases/auth.usecase.ts:

import { UserRepository } from "../repositories/user.repository";
import { User } from "../entities/user";

export class AuthUseCase {
  constructor(private readonly userRepository: UserRepository) {}

  async register(username: string, password: string): Promise<User> {
    if (!username || !password) {
      throw new Error("Username and password are required");
    }

    const existingUser = await this.userRepository.findByUsername(username);
    if (existingUser) {
      throw new Error("Username already exists");
    }

    const user: User = {
      id: this.generateUserId(),
      username,
      passwordHash: password,
      createdAt: new Date(),
    };

    return this.userRepository.createUser(user);
  }

  async login(username: string, password: string): Promise<User> {
    const user = await this.userRepository.findByUsername(username);
    if (!user) {
      throw new Error("User not found");
    }

    const isValid = await this.userRepository.verifyPassword(password, user.passwordHash);
    if (!isValid) {
      throw new Error("Invalid password");
    }

    return user;
  }

  private generateUserId(): string {
    return "temp-id";
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Implementación del Repositorio (Infraestructura)

src/infrastructure/repositories/user.file.repository.ts:

import { UserRepository } from "../../core/repositories/user.repository";
import { User } from "../../core/entities/user";
import * as fs from "fs";
import * as path from "path";
import * as bcrypt from "bcryptjs";
import { v4 as uuidv4 } from "uuid";

const FILE_PATH = path.join(__dirname, "../../../data/users.json");

export class UserFileRepository implements UserRepository {
  private users: User[] = [];

  constructor() {
    this.ensureFileExists();
    this.loadUsers();
  }

  private ensureFileExists(): void {
    if (!fs.existsSync(FILE_PATH)) {
      fs.writeFileSync(FILE_PATH, "[]", "utf-8");
    }
  }

  private loadUsers(): void {
    const data = fs.readFileSync(FILE_PATH, "utf-8");
    this.users = JSON.parse(data || "[]");
  }

  private saveUsers(): void {
    fs.writeFileSync(FILE_PATH, JSON.stringify(this.users, null, 2), "utf-8");
  }

  async createUser(user: User): Promise<User> {
    const salt = await bcrypt.genSalt(10);
    const passwordHash = await bcrypt.hash(user.passwordHash, salt);

    const newUser: User = {
      ...user,
      id: uuidv4(),
      passwordHash,
      createdAt: new Date(),
    };

    this.users.push(newUser);
    this.saveUsers();
    return newUser;
  }

  async findByUsername(username: string): Promise<User | null> {
    return this.users.find((u) => u.username === username) || null;
  }

  async findAllUsers(): Promise<User[]> {
    return [...this.users];
  }

  async verifyPassword(password: string, hash: string): Promise<boolean> {
    return bcrypt.compare(password, hash);
  }
}
Enter fullscreen mode Exit fullscreen mode

6. CLI Interface (Infraestructura)

src/infrastructure/cli/auth.cli.ts:

import inquirer from "inquirer";
import chalk from "chalk";
import figlet from "figlet";
import { AuthUseCase } from "../../core/usecases/auth.usecase";
import { User } from "../../core/entities/user";

export class AuthCLI {
  private currentUser: User | null = null;

  constructor(private authUseCase: AuthUseCase) {}

  async start() {
    console.log(chalk.green(figlet.textSync("Auth System", { horizontalLayout: "full" })));

    while (true) {
      if (!this.currentUser) {
        await this.showMainMenu();
      } else {
        await this.showUserMenu();
      }
    }
  }

  private async showMainMenu() {
    const { action } = await inquirer.prompt([
      {
        type: "list",
        name: "action",
        message: "What do you want to do?",
        choices: ["Login", "Register", "Exit"],
      },
    ]);

    switch (action) {
      case "Login":
        await this.handleLogin();
        break;
      case "Register":
        await this.handleRegister();
        break;
      case "Exit":
        process.exit(0);
    }
  }

  private async showUserMenu() {
    const { action } = await inquirer.prompt([
      {
        type: "list",
        name: "action",
        message: `Welcome ${this.currentUser!.username}, what do you want to do?`,
        choices: ["Logout", "Exit"],
      },
    ]);

    switch (action) {
      case "Logout":
        this.currentUser = null;
        console.log(chalk.blue("You have been logged out"));
        break;
      case "Exit":
        process.exit(0);
    }
  }

  private async handleLogin() {
    const { username, password } = await inquirer.prompt([
      {
        type: "input",
        name: "username",
        message: "Enter your username:",
      },
      {
        type: "password",
        name: "password",
        message: "Enter your password:",
        mask: "*",
      },
    ]);

    try {
      this.currentUser = await this.authUseCase.login(username, password);
      console.log(chalk.green("Login successful!"));
    } catch (error) {
      console.log(chalk.red(`Error: ${(error as Error).message}`));
    }
  }

  private async handleRegister() {
    const { username, password } = await inquirer.prompt([
      {
        type: "input",
        name: "username",
        message: "Choose a username:",
      },
      {
        type: "password",
        name: "password",
        message: "Choose a password:",
        mask: "*",
      },
    ]);

    try {
      await this.authUseCase.register(username, password);
      console.log(chalk.green("Registration successful! You can now login."));
    } catch (error) {
      console.log(chalk.red(`Error: ${(error as Error).message}`));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

7. Punto de Entrada

src/main.ts:

import { UserFileRepository } from "./infrastructure/repositories/user.file.repository";
import { AuthUseCase } from "./core/usecases/auth.usecase";
import { AuthCLI } from "./infrastructure/cli/auth.cli";

// Configuración e inyección de dependencias
const userRepository = new UserFileRepository();
const authUseCase = new AuthUseCase(userRepository);
const authCLI = new AuthCLI(authUseCase);

// Iniciar la aplicación
authCLI.start().catch((error) => {
  console.error("Application error:", error);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

Características implementadas:

  1. Clean Architecture:

    • Capa de dominio (core) con entidades, repositorios y casos de uso
    • Capa de infraestructura con implementaciones concretas
    • Desacoplamiento mediante interfaces
  2. Seguridad:

    • Encriptación de contraseñas con bcrypt
    • Nunca se almacenan contraseñas en texto plano
  3. Persistencia:

    • Almacenamiento en archivo JSON (data/users.json)
    • Fácil migración a base de datos cambiando el repositorio
  4. Interfaz de usuario:

    • CLI interactiva con inquirer
    • Menús para registro, login y logout

Cómo ejecutar el proyecto:

  1. Instala las dependencias como se indicó al principio
  2. Crea la estructura de directorios y archivos
  3. Ejecuta con: npx ts-node src/main.ts

Posibles mejoras:

  1. Migrar a base de datos (solo implementar nuevo repositorio)
  2. Añadir más validaciones (fortaleza de contraseña, etc.)
  3. Implementar JWT para sesiones más robustas
  4. Añadir tests unitarios e integración

Este proyecto sigue los principios de Clean Architecture manteniendo las capas bien separadas y las dependencias apuntando hacia el centro (dominio).

[🔗 Conéctame para más contenido técnico] [👨‍💻 Visita mi GitHub]

📢 ¡Feedback y contribuciones son bienvenidos! ¿Qué funcionalidades añadirías? ¿Has implementado algo similar? Hablemos en los comentarios 👇

Top comments (0)