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 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 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; } 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; findByUsername(username: string): Promise; findAllUsers(): Promise; verifyPassword(password: string, hash: string): Promise; } 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 { 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 { 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"; } } 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 { 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 { return this.users.find((u) => u.username === username) || null; } async findAllUsers(): Promise { return [...this.users]; } async verifyPassword(password: string, hash: string): Promise { return bcrypt.compare(password, hash); } } 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 au

May 14, 2025 - 17:36
 0
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

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

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;
}

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>;
}

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";
  }
}

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);
  }
}

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}`));
    }
  }
}

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);
});

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).

[