Arsitektur Project dan MVC di Express.js - Perwira Learning Center


1. Latar Belakang

    Memasuki minggu keempat pembelajaran Express.js di Perwira Learning Center, saya mempelajari Arsitektur Project dan MVC (Model-View-Controller). MVC adalah pola arsitektur yang memisahkan aplikasi menjadi tiga komponen utama yang saling berinteraksi.

Analogi MVC di Restoran:

  • Model = Dapur dan resep masakan (data & aturan bisnis)
  • View = Menu dan tampilan meja (UI/UX) - untuk aplikasi non-API
  • Controller = Pelayan yang menerima pesanan (menghubungkan client ke data)

Untuk REST API, kita pakai varian MVC-R:

  • Model = Struktur data dan logika bisnis
  • Controller = Penangan request/response
  • Routes = Endpoint mapping
  • View = JSON response (bukan template HTML)

2. Alat dan Bahan

a. Perangkat Lunak

  1. Node.js & npm - Runtime dan package manager
  2. Express.js - Framework utama
  3. Postman - Testing API
  4. VS Code - Code editor
  5. Git - Version control

b. Perangkat Keras

  1. Laptop/PC dengan spesifikasi standar

3. Pembahasan

3.1 Apa itu MVC?

MVC (Model-View-Controller) adalah pola arsitektur software yang memisahkan representasi data dari interaksi pengguna.

text
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   CLIENT    │────▶│ CONTROLLER  │────▶│   MODEL     │
│  (Browser)  │◀────│  (Handler)  │◀────│  (Data)     │
└─────────────┘     └─────────────┘     └─────────────┘
                           │
                           ▼
                    ┌─────────────┐
                    │    VIEW     │
                    │  (Response) │
                    └─────────────┘

Tanggung Jawab Setiap Komponen:

  1. MODEL:
    • Mengelola data dan logika bisnis
    • Berinteraksi dengan database
    • Validasi data
    • Independen dari controller dan view
  2. VIEW:

    • Menampilkan data ke user
    • Untuk REST API → JSON response
    • Untuk web tradisional → HTML templates
  3. CONTROLLER:

    • Menerima input dari routes
    • Memanggil model
    • Mengirim response ke client
    • Menjadi "jembatan" antara model dan view
  4. ROUTES (tambahan untuk Express):

    • Mapping URL ke controller
    • Mendefinisikan HTTP methods
    • Middleware composition

3.1 Mengapa MVC Penting?

Tanpa MVC (Spaghetti Code):

javascript
// ❌ Semua dicampur dalam satu file
app.get('/users', (req, res) => {
  // Database query langsung di route
  const users = db.query('SELECT * FROM users');
  
  // Business logic di sini
  const activeUsers = users.filter(u => u.isActive);
  
  // Format response di sini
  res.json({
    status: 'success',
    data: activeUsers
  });
});

Dengan MVC (Clean Architecture):

javascript
// ✅ Setiap komponen punya tanggung jawab sendiri
// routes/userRoutes.js → Mapping URL
// controllers/userController.js → Handle request/response  
// models/User.js → Data structure & business logic
// services/userService.js → Complex business logic

3.3 Praktik Lengkap: Membangun Blog API dengan MVC

Mari kita buat project Blog API dengan struktur MVC yang proper:

bash
# 1. Buat folder project
mkdir blog-api-mvc
cd blog-api-mvc

# 2. Inisialisasi project
npm init -y

# 3. Install dependencies
npm install express dotenv cors helmet morgan
npm install -D nodemon

# 4. Buat struktur folder MVC
mkdir -p src/{models,views,controllers,routes,config,middleware,services,utils}

Struktur Folder MVC untuk REST API:

text
blog-api-mvc/
├── src/
│   ├── models/           # Data models & schemas
│   │   ├── User.model.js
│   │   ├── Post.model.js
│   │   └── Comment.model.js
│   │
│   ├── views/            # JSON response formatters
│   │   ├── user.view.js
│   │   ├── post.view.js
│   │   └── error.view.js
│   │
│   ├── controllers/      # Request handlers
│   │   ├── auth.controller.js
│   │   ├── post.controller.js
│   │   └── user.controller.js
│   │
│   ├── routes/           # URL mappings
│   │   ├── auth.routes.js
│   │   ├── post.routes.js
│   │   ├── user.routes.js
│   │   └── index.js
│   │
│   ├── services/         # Business logic layer
│   │   ├── auth.service.js
│   │   ├── post.service.js
│   │   └── email.service.js
│   │
│   ├── middleware/       # Custom middleware
│   │   ├── auth.middleware.js
│   │   ├── error.middleware.js
│   │   └── validation.middleware.js
│   │
│   ├── config/           # Configuration
│   │   ├── database.js
│   │   └── env.js
│   │
│   └── utils/            # Helpers
│       ├── logger.js
│       └── response.js
│
├── .env.example
├── .gitignore
├── package.json
└── server.js

3.4 Implementasi MVC Step by Step

Step 1: Model Layer

src/models/User.model.js - Definisi data dan logika terkait user

javascript
/**
 * MODEL: Representasi data User
 * - Hanya berisi struktur data dan logika yang berhubungan dengan user
 * - Tidak tahu tentang HTTP, request, response
 * - Bisa digunakan ulang di berbagai konteks
 */
class User {
  constructor(id, email, name, password, role = 'user') {
    this.id = id;
    this.email = email;
    this.name = name;
    this.password = password; // Dalam real app, ini sudah di-hash
    this.role = role;
    this.createdAt = new Date();
    this.updatedAt = new Date();
    this.isActive = true;
    this.lastLogin = null;
  }

  // Method untuk validasi email
  isValidEmail() {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(this.email);
  }

  // Method untuk update last login
  updateLastLogin() {
    this.lastLogin = new Date();
    this.updatedAt = new Date();
  }

  // Method untuk soft delete
  deactivate() {
    this.isActive = false;
    this.updatedAt = new Date();
  }

  // Method untuk aktivasi kembali
  activate() {
    this.isActive = true;
    this.updatedAt = new Date();
  }

  // Method untuk mengubah password
  changePassword(newPassword) {
    this.password = newPassword; // Dalam real app: hash password
    this.updatedAt = new Date();
  }

  // Method untuk keperluan response (hapus sensitive data)
  toJSON() {
    return {
      id: this.id,
      email: this.email,
      name: this.name,
      role: this.role,
      createdAt: this.createdAt,
      updatedAt: this.updatedAt,
      isActive: this.isActive,
      lastLogin: this.lastLogin
    };
  }
}

/**
 * SIMULASI DATABASE (In-Memory)
 * Dalam aplikasi nyata, ini akan diganti dengan MongoDB/PostgreSQL
 */
class UserModel {
  constructor() {
    this.users = [];
    this.nextId = 1;
    
    // Seed data
    this.seed();
  }

  seed() {
    const admin = new User(
      this.nextId++,
      'admin@blog.com',
      'Admin Blog',
      'admin123',
      'admin'
    );
    this.users.push(admin);

    const user1 = new User(
      this.nextId++,
      'john@email.com',
      'John Doe',
      'john123',
      'user'
    );
    this.users.push(user1);
  }

  // ============= CRUD OPERATIONS =============

  async findAll({ page = 1, limit = 10, role = null, isActive = true } = {}) {
    let filteredUsers = this.users.filter(user => user.isActive === isActive);
    
    if (role) {
      filteredUsers = filteredUsers.filter(user => user.role === role);
    }
    
    // Pagination
    const startIndex = (page - 1) * limit;
    const endIndex = page * limit;
    const paginatedUsers = filteredUsers.slice(startIndex, endIndex);
    
    return {
      data: paginatedUsers.map(user => user.toJSON()),
      pagination: {
        page: parseInt(page),
        limit: parseInt(limit),
        total: filteredUsers.length,
        totalPages: Math.ceil(filteredUsers.length / limit),
        hasNext: endIndex < filteredUsers.length,
        hasPrev: startIndex > 0
      }
    };
  }

  async findById(id) {
    const user = this.users.find(u => u.id === parseInt(id) && u.isActive);
    return user ? user.toJSON() : null;
  }

  async findByEmail(email) {
    const user = this.users.find(u => u.email === email && u.isActive);
    return user || null;
  }

  async create(userData) {
    const existingUser = await this.findByEmail(userData.email);
    if (existingUser) {
      throw new Error('Email already exists');
    }
    
    const newUser = new User(
      this.nextId++,
      userData.email,
      userData.name,
      userData.password,
      userData.role || 'user'
    );
    
    this.users.push(newUser);
    return newUser.toJSON();
  }

  async update(id, updateData) {
    const user = this.users.find(u => u.id === parseInt(id));
    if (!user) {
      return null;
    }
    
    // Update fields
    if (updateData.name) user.name = updateData.name;
    if (updateData.email) user.email = updateData.email;
    if (updateData.role) user.role = updateData.role;
    
    user.updatedAt = new Date();
    return user.toJSON();
  }

  async delete(id) {
    const user = this.users.find(u => u.id === parseInt(id));
    if (!user) {
      return false;
    }
    
    user.deactivate();
    return true;
  }

  async authenticate(email, password) {
    const user = this.users.find(u => u.email === email && u.isActive);
    if (!user || user.password !== password) {
      return null;
    }
    
    user.updateLastLogin();
    return user.toJSON();
  }
}

module.exports = new UserModel();

src/models/Post.model.js - Model untuk blog posts

javascript
/**
 * MODEL: Representasi data Blog Post
 */
class Post {
  constructor(id, title, content, authorId, category = 'general') {
    this.id = id;
    this.title = title;
    this.content = content;
    this.authorId = authorId;
    this.category = category;
    this.createdAt = new Date();
    this.updatedAt = new Date();
    this.isPublished = true;
    this.views = 0;
    this.likes = 0;
    this.comments = [];
    this.tags = [];
  }

  // Method untuk increment views
  incrementViews() {
    this.views += 1;
  }

  // Method untuk add like
  addLike() {
    this.likes += 1;
  }

  // Method untuk add comment
  addComment(comment) {
    this.comments.push({
      id: this.comments.length + 1,
      ...comment,
      createdAt: new Date()
    });
  }

  // Method untuk publish/unpublish
  togglePublish() {
    this.isPublished = !this.isPublished;
    this.updatedAt = new Date();
  }

  // Method untuk format response
  toJSON() {
    return {
      id: this.id,
      title: this.title,
      content: this.content,
      authorId: this.authorId,
      category: this.category,
      createdAt: this.createdAt,
      updatedAt: this.updatedAt,
      isPublished: this.isPublished,
      views: this.views,
      likes: this.likes,
      comments: this.comments,
      tags: this.tags
    };
  }
}

/**
 * SIMULASI DATABASE POST
 */
class PostModel {
  constructor() {
    this.posts = [];
    this.nextId = 1;
    this.seed();
  }

  seed() {
    const post1 = new Post(
      this.nextId++,
      'Belajar MVC di Express.js',
      'MVC adalah pola arsitektur yang memisahkan Model, View, dan Controller...',
      1, // authorId
      'programming'
    );
    post1.tags = ['express', 'mvc', 'javascript'];
    this.posts.push(post1);

    const post2 = new Post(
      this.nextId++,
      'Tips Menjadi Full Stack Developer',
      'Full stack developer harus menguasai frontend dan backend...',
      2, // authorId
      'career'
    );
    post2.tags = ['career', 'programming'];
    this.posts.push(post2);
  }

  // ============= CRUD OPERATIONS =============

  async findAll({ 
    page = 1, 
    limit = 10, 
    category = null,
    authorId = null,
    published = true,
    search = null
  } = {}) {
    let filteredPosts = this.posts.filter(p => p.isPublished === published);
    
    if (category) {
      filteredPosts = filteredPosts.filter(p => p.category === category);
    }
    
    if (authorId) {
      filteredPosts = filteredPosts.filter(p => p.authorId === parseInt(authorId));
    }
    
    if (search) {
      const searchLower = search.toLowerCase();
      filteredPosts = filteredPosts.filter(p => 
        p.title.toLowerCase().includes(searchLower) ||
        p.content.toLowerCase().includes(searchLower)
      );
    }
    
    // Sort by newest first
    filteredPosts.sort((a, b) => b.createdAt - a.createdAt);
    
    // Pagination
    const startIndex = (page - 1) * limit;
    const endIndex = page * limit;
    const paginatedPosts = filteredPosts.slice(startIndex, endIndex);
    
    return {
      data: paginatedPosts.map(post => post.toJSON()),
      pagination: {
        page: parseInt(page),
        limit: parseInt(limit),
        total: filteredPosts.length,
        totalPages: Math.ceil(filteredPosts.length / limit)
      },
      filters: { category, authorId, published, search }
    };
  }

  async findById(id) {
    const post = this.posts.find(p => p.id === parseInt(id) && p.isPublished);
    if (post) {
      post.incrementViews();
    }
    return post ? post.toJSON() : null;
  }

  async findByAuthor(authorId) {
    return this.posts.filter(p => p.authorId === parseInt(authorId) && p.isPublished);
  }

  async create(postData) {
    const newPost = new Post(
      this.nextId++,
      postData.title,
      postData.content,
      postData.authorId,
      postData.category
    );
    
    if (postData.tags) {
      newPost.tags = postData.tags;
    }
    
    this.posts.push(newPost);
    return newPost.toJSON();
  }

  async update(id, updateData) {
    const post = this.posts.find(p => p.id === parseInt(id));
    if (!post) {
      return null;
    }
    
    if (updateData.title) post.title = updateData.title;
    if (updateData.content) post.content = updateData.content;
    if (updateData.category) post.category = updateData.category;
    if (updateData.tags) post.tags = updateData.tags;
    
    post.updatedAt = new Date();
    return post.toJSON();
  }

  async delete(id) {
    const index = this.posts.findIndex(p => p.id === parseInt(id));
    if (index === -1) {
      return false;
    }
    
    this.posts.splice(index, 1);
    return true;
  }
}

module.exports = new PostModel();

Step 2: View Layer (Response Formatter)

src/views/user.view.js - Format response untuk user

javascript
/**
 * VIEW: Response formatter untuk resource User
 * - Bertanggung jawab untuk format data sebelum dikirim ke client
 * - Memisahkan logika formatting dari controller
 */
class UserView {
  /**
   * Format single user response
   */
  static render(user) {
    if (!user) return null;
    
    return {
      id: user.id,
      name: user.name,
      email: user.email,
      role: user.role,
      joinedAt: user.createdAt,
      lastActive: user.lastLogin,
      isActive: user.isActive
    };
  }

  /**
   * Format multiple users response dengan pagination
   */
  static renderAll({ data, pagination, filters }) {
    return {
      users: data.map(user => this.render(user)),
      pagination: {
        page: pagination.page,
        limit: pagination.limit,
        totalItems: pagination.total,
        totalPages: pagination.totalPages,
        hasNext: pagination.hasNext,
        hasPrev: pagination.hasPrev
      },
      filters: filters || {}
    };
  }

  /**
   * Format untuk response profile (private)
   */
  static renderProfile(user) {
    return {
      id: user.id,
      name: user.name,
      email: user.email,
      role: user.role,
      joinedAt: user.createdAt,
      lastLogin: user.lastLogin,
      postCount: user.postCount || 0,
      commentCount: user.commentCount || 0
    };
  }

  /**
   * Format untuk response authentication
   */
  static renderAuth(user, token) {
    return {
      user: this.render(user),
      token,
      expiresIn: '24h'
    };
  }
}

module.exports = UserView;

src/views/post.view.js - Format response untuk blog posts

javascript
/**
 * VIEW: Response formatter untuk resource Post
 */
class PostView {
  static render(post, includeAuthor = false) {
    if (!post) return null;
    
    const formatted = {
      id: post.id,
      title: post.title,
      excerpt: post.content.substring(0, 200) + (post.content.length > 200 ? '...' : ''),
      content: post.content,
      category: post.category,
      authorId: post.authorId,
      createdAt: post.createdAt,
      updatedAt: post.updatedAt,
      views: post.views,
      likes: post.likes,
      comments: post.comments?.length || 0,
      tags: post.tags || []
    };
    
    if (includeAuthor && post.author) {
      formatted.author = {
        id: post.author.id,
        name: post.author.name
      };
    }
    
    return formatted;
  }

  static renderAll({ data, pagination, filters }) {
    return {
      posts: data.map(post => this.render(post, true)),
      pagination: {
        page: pagination.page,
        limit: pagination.limit,
        totalItems: pagination.total,
        totalPages: pagination.totalPages
      },
      filters: filters || {}
    };
  }

  static renderDetail(post, author = null, comments = []) {
    const detail = this.render(post, true);
    
    if (author) {
      detail.author = {
        id: author.id,
        name: author.name,
        email: author.email
      };
    }
    
    if (comments.length > 0) {
      detail.comments = comments.map(comment => ({
        id: comment.id,
        content: comment.content,
        author: comment.author,
        createdAt: comment.createdAt
      }));
    }
    
    return detail;
  }
}

module.exports = PostView;

src/views/error.view.js - Format response error

javascript
/**
 * VIEW: Response formatter untuk error
 * - Konsisten untuk semua error response
 */
class ErrorView {
  static render(err, req = null) {
    const errorResponse = {
      success: false,
      error: {
        code: err.code || 'INTERNAL_ERROR',
        message: err.message || 'Terjadi kesalahan pada server',
        timestamp: new Date().toISOString()
      }
    };
    
    // Tambahkan request ID untuk tracking
    if (req?.requestId) {
      errorResponse.error.requestId = req.requestId;
    }
    
    // Tambahkan path jika ada
    if (req?.originalUrl) {
      errorResponse.error.path = req.originalUrl;
    }
    
    // Detail error hanya di development
    if (process.env.NODE_ENV === 'development' && err.stack) {
      errorResponse.error.stack = err.stack;
      errorResponse.error.details = err.details || null;
    }
    
    // Validation errors
    if (err.name === 'ValidationError' && err.errors) {
      errorResponse.error.code = 'VALIDATION_ERROR';
      errorResponse.error.validation = err.errors.map(e => ({
        field: e.field,
        message: e.message
      }));
    }
    
    return errorResponse;
  }
  
  static render404(req) {
    return {
      success: false,
      error: {
        code: 'NOT_FOUND',
        message: `Cannot ${req.method} ${req.originalUrl}`,
        timestamp: new Date().toISOString(),
        path: req.originalUrl
      }
    };
  }
  
  static renderValidation(errors) {
    return {
      success: false,
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Validasi data gagal',
        timestamp: new Date().toISOString(),
        validation: errors
      }
    };
  }
}

module.exports = ErrorView;

Step 3: Service Layer (Business Logic)

src/services/auth.service.js - Logika autentikasi

javascript
/**
 * SERVICE: Business logic untuk autentikasi
 * - Memisahkan logika kompleks dari controller
 * - Reusable dan testable
 */
const UserModel = require('../models/User.model');
const EmailService = require('./email.service');
const jwt = require('jsonwebtoken');

class AuthService {
  constructor() {
    this.jwtSecret = process.env.JWT_SECRET || 'secret-key';
    this.tokenExpiry = '24h';
  }

  /**
   * Register user baru
   */
  async register(userData) {
    // Validasi business rules
    if (!userData.email || !userData.password || !userData.name) {
      throw {
        name: 'ValidationError',
        message: 'Email, password, dan name wajib diisi',
        errors: [
          { field: 'email', message: 'Email wajib diisi' },
          { field: 'password', message: 'Password wajib diisi' },
          { field: 'name', message: 'Nama wajib diisi' }
        ]
      };
    }

    if (userData.password.length < 6) {
      throw {
        name: 'ValidationError',
        message: 'Password minimal 6 karakter',
        errors: [
          { field: 'password', message: 'Password minimal 6 karakter' }
        ]
      };
    }

    // Cek email sudah terdaftar
    try {
      const existingUser = await UserModel.findByEmail(userData.email);
      if (existingUser) {
        throw {
          name: 'DuplicateError',
          message: 'Email sudah terdaftar',
          code: 'EMAIL_EXISTS'
        };
      }
    } catch (error) {
      throw error;
    }

    // Create user
    try {
      const newUser = await UserModel.create(userData);
      
      // Kirim email selamat datang (async, tidak perlu ditunggu)
      EmailService.sendWelcomeEmail(newUser.email, newUser.name).catch(console.error);
      
      // Generate token
      const token = this.generateToken(newUser);
      
      return {
        user: newUser,
        token
      };
    } catch (error) {
      throw {
        name: 'DatabaseError',
        message: 'Gagal menyimpan user',
        original: error.message
      };
    }
  }

  /**
   * Login user
   */
  async login(email, password) {
    if (!email || !password) {
      throw {
        name: 'ValidationError',
        message: 'Email dan password wajib diisi',
        errors: [
          { field: 'email', message: 'Email wajib diisi' },
          { field: 'password', message: 'Password wajib diisi' }
        ]
      };
    }

    try {
      const user = await UserModel.authenticate(email, password);
      
      if (!user) {
        throw {
          name: 'AuthenticationError',
          message: 'Email atau password salah',
          code: 'INVALID_CREDENTIALS'
        };
      }

      const token = this.generateToken(user);
      
      return {
        user,
        token
      };
    } catch (error) {
      throw error;
    }
  }

  /**
   * Generate JWT token
   */
  generateToken(user) {
    const payload = {
      sub: user.id,
      email: user.email,
      role: user.role,
      iat: Math.floor(Date.now() / 1000)
    };
    
    return jwt.sign(payload, this.jwtSecret, {
      expiresIn: this.tokenExpiry
    });
  }

  /**
   * Verify JWT token
   */
  verifyToken(token) {
    try {
      const decoded = jwt.verify(token, this.jwtSecret);
      return decoded;
    } catch (error) {
      throw {
        name: 'TokenError',
        message: error.message === 'jwt expired' ? 'Token expired' : 'Token tidak valid',
        code: error.message === 'jwt expired' ? 'TOKEN_EXPIRED' : 'INVALID_TOKEN'
      };
    }
  }

  /**
   * Forgot password
   */
  async forgotPassword(email) {
    const user = await UserModel.findByEmail(email);
    
    if (!user) {
      // Untuk keamanan, tetap return success meskipun email tidak ditemukan
      return { message: 'Jika email terdaftar, link reset password akan dikirim' };
    }
    
    // Generate reset token
    const resetToken = jwt.sign(
      { sub: user.id, type: 'reset' },
      this.jwtSecret,
      { expiresIn: '1h' }
    );
    
    // Kirim email reset password
    await EmailService.sendResetPasswordEmail(email, resetToken);
    
    return { message: 'Link reset password telah dikirim ke email Anda' };
  }

  /**
   * Reset password
   */
  async resetPassword(token, newPassword) {
    try {
      const decoded = jwt.verify(token, this.jwtSecret);
      
      if (decoded.type !== 'reset') {
        throw new Error('Invalid reset token');
      }
      
      const user = await UserModel.findById(decoded.sub);
      if (!user) {
        throw new Error('User not found');
      }
      
      user.changePassword(newPassword);
      
      return { message: 'Password berhasil direset' };
    } catch (error) {
      throw {
        name: 'TokenError',
        message: 'Token reset tidak valid atau sudah expired',
        code: 'INVALID_RESET_TOKEN'
      };
    }
  }
}

module.exports = new AuthService();

src/services/post.service.js - Logika bisnis untuk blog posts

javascript
/**
 * SERVICE: Business logic untuk Blog Posts
 */
const PostModel = require('../models/Post.model');
const UserModel = require('../models/User.model');

class PostService {
  /**
   * Get all posts dengan filter
   */
  async getAllPosts(filters) {
    try {
      const result = await PostModel.findAll(filters);
      
      // Enhance posts dengan author info
      for (const post of result.data) {
        const author = await UserModel.findById(post.authorId);
        if (author) {
          post.author = {
            id: author.id,
            name: author.name
          };
        }
      }
      
      return result;
    } catch (error) {
      throw {
        name: 'DatabaseError',
        message: 'Gagal mengambil data posts',
        original: error.message
      };
    }
  }

  /**
   * Get single post by ID
   */
  async getPostById(id) {
    try {
      const post = await PostModel.findById(id);
      
      if (!post) {
        throw {
          name: 'NotFoundError',
          message: `Post dengan ID ${id} tidak ditemukan`,
          code: 'POST_NOT_FOUND'
        };
      }
      
      // Get author info
      const author = await UserModel.findById(post.authorId);
      if (author) {
        post.author = {
          id: author.id,
          name: author.name
        };
      }
      
      return post;
    } catch (error) {
      throw error;
    }
  }

  /**
   * Create new post
   */
  async createPost(postData, authorId) {
    // Validasi business rules
    if (!postData.title || postData.title.length < 3) {
      throw {
        name: 'ValidationError',
        message: 'Judul minimal 3 karakter',
        errors: [
          { field: 'title', message: 'Judul minimal 3 karakter' }
        ]
      };
    }
    
    if (!postData.content || postData.content.length < 10) {
      throw {
        name: 'ValidationError',
        message: 'Konten minimal 10 karakter',
        errors: [
          { field: 'content', message: 'Konten minimal 10 karakter' }
        ]
      };
    }

    try {
      const newPost = await PostModel.create({
        ...postData,
        authorId
      });
      
      return newPost;
    } catch (error) {
      throw {
        name: 'DatabaseError',
        message: 'Gagal membuat post',
        original: error.message
      };
    }
  }

  /**
   * Update post
   */
  async updatePost(id, updateData, userId, userRole) {
    try {
      const existingPost = await PostModel.findById(id);
      
      if (!existingPost) {
        throw {
          name: 'NotFoundError',
          message: `Post dengan ID ${id} tidak ditemukan`,
          code: 'POST_NOT_FOUND'
        };
      }
      
      // Authorization: hanya author atau admin yang boleh update
      if (existingPost.authorId !== userId && userRole !== 'admin') {
        throw {
          name: 'AuthorizationError',
          message: 'Anda tidak memiliki izin untuk mengupdate post ini',
          code: 'FORBIDDEN'
        };
      }
      
      const updatedPost = await PostModel.update(id, updateData);
      
      return updatedPost;
    } catch (error) {
      throw error;
    }
  }

  /**
   * Delete post
   */
  async deletePost(id, userId, userRole) {
    try {
      const existingPost = await PostModel.findById(id);
      
      if (!existingPost) {
        throw {
          name: 'NotFoundError',
          message: `Post dengan ID ${id} tidak ditemukan`,
          code: 'POST_NOT_FOUND'
        };
      }
      
      // Authorization: hanya author atau admin yang boleh delete
      if (existingPost.authorId !== userId && userRole !== 'admin') {
        throw {
          name: 'AuthorizationError',
          message: 'Anda tidak memiliki izin untuk menghapus post ini',
          code: 'FORBIDDEN'
        };
      }
      
      const deleted = await PostModel.delete(id);
      
      return deleted;
    } catch (error) {
      throw error;
    }
  }

  /**
   * Get posts by author
   */
  async getPostsByAuthor(authorId) {
    try {
      const author = await UserModel.findById(authorId);
      
      if (!author) {
        throw {
          name: 'NotFoundError',
          message: `Author dengan ID ${authorId} tidak ditemukan`,
          code: 'AUTHOR_NOT_FOUND'
        };
      }
      
      const posts = await PostModel.findByAuthor(authorId);
      
      return {
        author: {
          id: author.id,
          name: author.name
        },
        posts: posts.map(post => ({
          id: post.id,
          title: post.title,
          excerpt: post.content.substring(0, 100) + '...',
          createdAt: post.createdAt,
          views: post.views,
          likes: post.likes
        }))
      };
    } catch (error) {
      throw error;
    }
  }
}

module.exports = new PostService();

Step 4: Controller Layer

src/controllers/auth.controller.js - Controller untuk autentikasi

javascript
/**
 * CONTROLLER: Handle HTTP request/response untuk autentikasi
 * - Menerima input dari routes
 * - Memanggil service layer
 * - Mengirim response menggunakan view
 */
const AuthService = require('../services/auth.service');
const UserView = require('../views/user.view');
const { successResponse, errorResponse } = require('../utils/response');

class AuthController {
  /**
   * POST /api/auth/register
   */
  async register(req, res) {
    try {
      const result = await AuthService.register(req.body);
      
      return successResponse(res, 201, {
        user: UserView.render(result.user),
        token: result.token
      }, 'Registrasi berhasil');
    } catch (error) {
      return errorResponse(res, error);
    }
  }

  /**
   * POST /api/auth/login
   */
  async login(req, res) {
    try {
      const { email, password } = req.body;
      const result = await AuthService.login(email, password);
      
      return successResponse(res, 200, {
        user: UserView.render(result.user),
        token: result.token
      }, 'Login berhasil');
    } catch (error) {
      return errorResponse(res, error);
    }
  }

  /**
   * GET /api/auth/profile
   */
  async getProfile(req, res) {
    try {
      // User sudah di-set oleh auth middleware
      const user = req.user;
      
      return successResponse(res, 200, 
        UserView.renderProfile(user), 
        'Profile berhasil diambil'
      );
    } catch (error) {
      return errorResponse(res, error);
    }
  }

  /**
   * POST /api/auth/forgot-password
   */
  async forgotPassword(req, res) {
    try {
      const { email } = req.body;
      const result = await AuthService.forgotPassword(email);
      
      return successResponse(res, 200, result, 'Link reset password dikirim');
    } catch (error) {
      return errorResponse(res, error);
    }
  }

  /**
   * POST /api/auth/reset-password
   */
  async resetPassword(req, res) {
    try {
      const { token, newPassword } = req.body;
      const result = await AuthService.resetPassword(token, newPassword);
      
      return successResponse(res, 200, result, 'Password berhasil direset');
    } catch (error) {
      return errorResponse(res, error);
    }
  }

  /**
   * POST /api/auth/logout
   */
  async logout(req, res) {
    // Karena JWT stateless, logout cukup di client
    // Tapi kita bisa blacklist token di server
    return successResponse(res, 200, null, 'Logout berhasil');
  }
}

module.exports = new AuthController();

src/controllers/post.controller.js - Controller untuk blog posts

javascript
/**
 * CONTROLLER: Handle HTTP request/response untuk Blog Posts
 */
const PostService = require('../services/post.service');
const PostView = require('../views/post.view');
const { successResponse, errorResponse } = require('../utils/response');

class PostController {
  /**
   * GET /api/posts
   */
  async getAllPosts(req, res) {
    try {
      const result = await PostService.getAllPosts(req.query);
      
      return successResponse(
        res, 
        200, 
        PostView.renderAll(result), 
        'Posts berhasil diambil',
        { filters: result.filters }
      );
    } catch (error) {
      return errorResponse(res, error);
    }
  }

  /**
   * GET /api/posts/:id
   */
  async getPostById(req, res) {
    try {
      const { id } = req.params;
      const post = await PostService.getPostById(id);
      
      return successResponse(
        res, 
        200, 
        PostView.renderDetail(post), 
        'Post berhasil diambil'
      );
    } catch (error) {
      return errorResponse(res, error);
    }
  }

  /**
   * POST /api/posts
   */
  async createPost(req, res) {
    try {
      const post = await PostService.createPost(req.body, req.user.id);
      
      return successResponse(
        res, 
        201, 
        PostView.render(post), 
        'Post berhasil dibuat'
      );
    } catch (error) {
      return errorResponse(res, error);
    }
  }

  /**
   * PUT /api/posts/:id
   */
  async updatePost(req, res) {
    try {
      const { id } = req.params;
      const post = await PostService.updatePost(
        id, 
        req.body, 
        req.user.id, 
        req.user.role
      );
      
      return successResponse(
        res, 
        200, 
        PostView.render(post), 
        'Post berhasil diupdate'
      );
    } catch (error) {
      return errorResponse(res, error);
    }
  }

  /**
   * DELETE /api/posts/:id
   */
  async deletePost(req, res) {
    try {
      const { id } = req.params;
      await PostService.deletePost(id, req.user.id, req.user.role);
      
      return successResponse(
        res, 
        200, 
        null, 
        'Post berhasil dihapus'
      );
    } catch (error) {
      return errorResponse(res, error);
    }
  }

  /**
   * GET /api/posts/author/:authorId
   */
  async getPostsByAuthor(req, res) {
    try {
      const { authorId } = req.params;
      const result = await PostService.getPostsByAuthor(authorId);
      
      return successResponse(
        res, 
        200, 
        result, 
        'Posts author berhasil diambil'
      );
    } catch (error) {
      return errorResponse(res, error);
    }
  }
}

module.exports = new PostController();

Step 5: Routes Layer

src/routes/auth.routes.js - Routes untuk autentikasi

javascript
/**
 * ROUTES: Mapping URL ke controller untuk autentikasi
 */
const express = require('express');
const router = express.Router();
const AuthController = require('../controllers/auth.controller');
const { authenticate } = require('../middleware/auth.middleware');
const { validate } = require('../middleware/validation.middleware');
const { 
  registerSchema, 
  loginSchema, 
  forgotPasswordSchema,
  resetPasswordSchema 
} = require('../validators/auth.validator');

/**
 * @route   POST /api/auth/register
 * @desc    Register user baru
 * @access  Public
 */
router.post(
  '/register',
  validate(registerSchema),
  AuthController.register
);

/**
 * @route   POST /api/auth/login
 * @desc    Login user
 * @access  Public
 */
router.post(
  '/login',
  validate(loginSchema),
  AuthController.login
);

/**
 * @route   GET /api/auth/profile
 * @desc    Get user profile
 * @access  Private
 */
router.get(
  '/profile',
  authenticate,
  AuthController.getProfile
);

/**
 * @route   POST /api/auth/forgot-password
 * @desc    Request password reset
 * @access  Public
 */
router.post(
  '/forgot-password',
  validate(forgotPasswordSchema),
  AuthController.forgotPassword
);

/**
 * @route   POST /api/auth/reset-password
 * @desc    Reset password with token
 * @access  Public
 */
router.post(
  '/reset-password',
  validate(resetPasswordSchema),
  AuthController.resetPassword
);

/**
 * @route   POST /api/auth/logout
 * @desc    Logout user
 * @access  Private
 */
router.post(
  '/logout',
  authenticate,
  AuthController.logout
);

module.exports = router;

src/routes/post.routes.js - Routes untuk blog posts

javascript
/**
 * ROUTES: Mapping URL ke controller untuk blog posts
 */
const express = require('express');
const router = express.Router();
const PostController = require('../controllers/post.controller');
const { authenticate, authorize } = require('../middleware/auth.middleware');
const { validate } = require('../middleware/validation.middleware');
const { 
  createPostSchema, 
  updatePostSchema,
  getPostsQuerySchema 
} = require('../validators/post.validator');

/**
 * @route   GET /api/posts
 * @desc    Get all posts (with filters)
 * @access  Public
 */
router.get(
  '/',
  validate(getPostsQuerySchema, 'query'),
  PostController.getAllPosts
);

/**
 * @route   GET /api/posts/author/:authorId
 * @desc    Get posts by author
 * @access  Public
 */
router.get(
  '/author/:authorId',
  PostController.getPostsByAuthor
);

/**
 * @route   GET /api/posts/:id
 * @desc    Get single post by ID
 * @access  Public
 */
router.get(
  '/:id',
  PostController.getPostById
);

/**
 * @route   POST /api/posts
 * @desc    Create new post
 * @access  Private (User)
 */
router.post(
  '/',
  authenticate,
  authorize('user', 'admin'),
  validate(createPostSchema),
  PostController.createPost
);

/**
 * @route   PUT /api/posts/:id
 * @desc    Update post
 * @access  Private (Author/Admin)
 */
router.put(
  '/:id',
  authenticate,
  validate(updatePostSchema),
  PostController.updatePost
);

/**
 * @route   DELETE /api/posts/:id
 * @desc    Delete post
 * @access  Private (Author/Admin)
 */
router.delete(
  '/:id',
  authenticate,
  PostController.deletePost
);

module.exports = router;

src/routes/index.js - Aggregator semua routes

javascript
/**
 * ROUTES: Aggregator untuk semua routes
 */
const express = require('express');
const router = express.Router();

// Import route modules
const authRoutes = require('./auth.routes');
const postRoutes = require('./post.routes');

// Home route
router.get('/', (req, res) => {
  res.json({
    name: 'Blog API with MVC Architecture',
    version: '1.0.0',
    endpoints: {
      auth: '/api/auth',
      posts: '/api/posts',
      health: '/health'
    },
    documentation: 'https://github.com/yourusername/blog-api-mvc'
  });
});

// Health check
router.get('/health', (req, res) => {
  res.json({
    status: 'OK',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    memory: process.memoryUsage()
  });
});

// Mount routes
router.use('/auth', authRoutes);
router.use('/posts', postRoutes);

module.exports = router;

3.5 Middleware untuk MVC

src/middleware/auth.middleware.js

javascript
/**
 * MIDDLEWARE: Autentikasi dan Authorisasi
 */
const AuthService = require('../services/auth.service');
const UserModel = require('../models/User.model');

const authenticate = async (req, res, next) => {
  try {
    const authHeader = req.headers.authorization;
    
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      throw {
        name: 'AuthenticationError',
        message: 'Token tidak ditemukan',
        code: 'NO_TOKEN',
        statusCode: 401
      };
    }
    
    const token = authHeader.split(' ')[1];
    const decoded = AuthService.verifyToken(token);
    
    // Get user from database
    const user = await UserModel.findById(decoded.sub);
    
    if (!user) {
      throw {
        name: 'AuthenticationError',
        message: 'User tidak ditemukan',
        code: 'USER_NOT_FOUND',
        statusCode: 401
      };
    }
    
    if (!user.isActive) {
      throw {
        name: 'AuthenticationError',
        message: 'Akun tidak aktif',
        code: 'ACCOUNT_INACTIVE',
        statusCode: 403
      };
    }
    
    // Attach user to request object
    req.user = user;
    req.token = token;
    
    next();
  } catch (error) {
    next(error);
  }
};

const authorize = (...roles) => {
  return (req, res, next) => {
    if (!req.user) {
      return next({
        name: 'AuthorizationError',
        message: 'User tidak terautentikasi',
        code: 'NOT_AUTHENTICATED',
        statusCode: 401
      });
    }
    
    if (!roles.includes(req.user.role)) {
      return next({
        name: 'AuthorizationError',
        message: 'Tidak memiliki akses ke resource ini',
        code: 'FORBIDDEN',
        statusCode: 403
      });
    }
    
    next();
  };
};

module.exports = {
  authenticate,
  authorize
};

3.6 MVC vs Arsitektur Lain

MVC (Model-View-Controller):

text
✅ Cocok untuk aplikasi CRUD sederhana-menengah
✅ Pemisahan yang jelas antara data, logic, presentation
✅ Mudah dipahami developer pemula
❌ Bisa menjadi fat controllers
❌ Model sering jadi "god object"

MVC + Service Layer:

text
✅ Pisahkan business logic dari controller
✅ Controller hanya handle HTTP
✅ Service handle business rules
✅ Repository handle data access
✅ Lebih testable dan maintainable

Clean Architecture / Hexagonal:

text
✅ Pemisahan lebih ketat antara domain dan infrastructure
✅ Sangat testable
✅ Independen dari framework
❌ Overkill untuk aplikasi kecil
❌ Learning curve tinggi

3.7 Best Practices MVC di Express

1. Keep Controllers Thin

javascript
// ❌ Bad: Controller melakukan terlalu banyak
async function createUser(req, res) {
  // Validation
  // Business logic
  // Database operation
  // Email sending
  // Logging
  // Response formatting
}

// ✅ Good: Controller hanya delegasi
async function createUser(req, res) {
  const result = await UserService.createUser(req.body);
  return successResponse(res, 201, UserView.render(result));
}

2. Models Should Be Rich

javascript
// ❌ Bad: Anemic model (hanya getter/setter)
class User {
  constructor(data) {
    this.id = data.id;
    this.email = data.email;
  }
}

// ✅ Good: Rich model (ada behavior)
class User {
  constructor(data) {
    this.id = data.id;
    this.email = data.email;
  }
  
  isValidEmail() { /* ... */ }
  changePassword() { /* ... */ }
  deactivate() { /* ... */ }
}

3. Views Format Consistently

javascript
// ❌ Bad: Format berbeda setiap controller
res.json({ user: user });
res.json({ data: user });
res.json({ result: user });

// ✅ Good: Format konsisten
res.json({
  success: true,
  data: UserView.render(user),
  timestamp: new Date()
});

4. Routes Should Be Declarative

javascript
// ❌ Bad: Logic di routes
router.get('/users/:id', (req, res) => {
  if (!req.user) { /* ... */ }
  // Validasi
  // Business logic
});

// ✅ Good: Routes hanya mapping
router.get(
  '/users/:id',
  authenticate,
  validate(getUserSchema),
  UserController.getById
);

3.8 Testing MVC Components

tests/unit/models/User.model.test.js

javascript
const UserModel = require('../../src/models/User.model');

describe('User Model', () => {
  beforeEach(() => {
    // Reset database sebelum test
    UserModel.users = [];
    UserModel.nextId = 1;
    UserModel.seed();
  });

  test('should create new user', async () => {
    const userData = {
      email: 'test@email.com',
      name: 'Test User',
      password: 'test123'
    };
    
    const user = await UserModel.create(userData);
    
    expect(user.id).toBeDefined();
    expect(user.email).toBe('test@email.com');
    expect(user.name).toBe('Test User');
    expect(user.isActive).toBe(true);
  });

  test('should not create duplicate email', async () => {
    const userData = {
      email: 'admin@blog.com',
      name: 'Another Admin',
      password: 'admin123'
    };
    
    await expect(UserModel.create(userData)).rejects.toThrow('Email already exists');
  });

  test('should find user by email', async () => {
    const user = await UserModel.findByEmail('admin@blog.com');
    expect(user).toBeDefined();
    expect(user.email).toBe('admin@blog.com');
  });

  test('should authenticate valid credentials', async () => {
    const user = await UserModel.authenticate('admin@blog.com', 'admin123');
    expect(user).toBeDefined();
    expect(user.email).toBe('admin@blog.com');
  });

  test('should not authenticate invalid credentials', async () => {
    const user = await UserModel.authenticate('admin@blog.com', 'wrongpassword');
    expect(user).toBeNull();
  });
});

tests/unit/services/auth.service.test.js

javascript
const AuthService = require('../../src/services/auth.service');
const UserModel = require('../../src/models/User.model');

// Mock UserModel
jest.mock('../../src/models/User.model');

describe('Auth Service', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  test('should register new user', async () => {
    const mockUser = {
      id: 1,
      email: 'test@email.com',
      name: 'Test User',
      role: 'user'
    };
    
    UserModel.findByEmail.mockResolvedValue(null);
    UserModel.create.mockResolvedValue(mockUser);
    
    const result = await AuthService.register({
      email: 'test@email.com',
      password: 'test123',
      name: 'Test User'
    });
    
    expect(result.user).toBeDefined();
    expect(result.token).toBeDefined();
    expect(UserModel.create).toHaveBeenCalled();
  });

  test('should throw error for duplicate email', async () => {
    UserModel.findByEmail.mockResolvedValue({ id: 1 });
    
    await expect(AuthService.register({
      email: 'existing@email.com',
      password: 'test123',
      name: 'Test User'
    })).rejects.toHaveProperty('name', 'DuplicateError');
  });

  test('should validate password length', async () => {
    await expect(AuthService.register({
      email: 'test@email.com',
      password: '123',
      name: 'Test User'
    })).rejects.toHaveProperty('name', 'ValidationError');
  });
});

tests/integration/auth.flow.test.js

javascript
const request = require('supertest');
const app = require('../../src/app');

describe('Authentication Flow', () => {
  test('should register, login, and access protected route', async () => {
    // 1. Register
    const registerRes = await request(app)
      .post('/api/auth/register')
      .send({
        email: 'integration@test.com',
        password: 'test123',
        name: 'Integration Test'
      });
    
    expect(registerRes.statusCode).toBe(201);
    expect(registerRes.body.success).toBe(true);
    
    // 2. Login
    const loginRes = await request(app)
      .post('/api/auth/login')
      .send({
        email: 'integration@test.com',
        password: 'test123'
      });
    
    expect(loginRes.statusCode).toBe(200);
    expect(loginRes.body.data.token).toBeDefined();
    
    const token = loginRes.body.data.token;
    
    // 3. Access profile
    const profileRes = await request(app)
      .get('/api/auth/profile')
      .set('Authorization', `Bearer ${token}`);
    
    expect(profileRes.statusCode).toBe(200);
    expect(profileRes.body.data.email).toBe('integration@test.com');
  });
});

3.9 Common MVC Anti-Patterns

1. Fat Controller, Skinny Model

javascript
// ❌ Bad: Controller melakukan semua
const UserController = {
  createUser: async (req, res) => {
    // Validasi
    // Hashing password
    // Save to DB
    // Send email
    // Format response
  }
}

// ✅ Good: Delegasi ke service
const UserController = {
  createUser: async (req, res) => {
    const result = await UserService.createUser(req.body);
    return successResponse(res, 201, result);
  }
}

2. Business Logic in Routes

javascript
// ❌ Bad: Logic di route definition
router.get('/posts', async (req, res) => {
  const posts = await Post.find();
  const filtered = posts.filter(p => p.isPublished);
  res.json(filtered);
});

// ✅ Good: Delegasi ke controller
router.get('/posts', PostController.getAll);

3. Database Logic in Views

javascript
// ❌ Bad: View langsung query database
function renderUserProfile(userId) {
  const user = db.query('SELECT * FROM users WHERE id = ?', [userId]);
  return { user };
}

// ✅ Good: View hanya format data
function renderUserProfile(user) {
  return { id: user.id, name: user.name };
}

3.10 Latihan Praktikum

Exercise 1: Implement MVC untuk Comment Feature

javascript
// TODO: Implementasikan fitur komentar dengan MVC pattern
// 1. Model Comment
// 2. Comment Service
// 3. Comment Controller  
// 4. Comment Routes
// 5. Comment View

// Endpoints yang diperlukan:
// POST   /api/posts/:postId/comments
// GET    /api/posts/:postId/comments
// DELETE /api/comments/:id

Exercise 2: Refactor Existing Project

javascript
// TODO: Ambil project lama yang masih monolith
// 1. Pisahkan models ke folder models/
// 2. Pisahkan controllers ke folder controllers/
// 3. Pisahkan routes ke folder routes/
// 4. Tambahkan service layer
// 5. Tambahkan view formatter

Exercise 3: Add Caching Layer

javascript
// TODO: Implementasikan caching dengan Redis
// 1. Buat CacheService
// 2. Cache hasil query database
// 3. Cache hasil perhitungan
// 4. Invalidate cache saat data berubah

4. Daftar Pustaka

  1. EnvantoTuts+ (2022). Membangun Sebuah Website MVC Lengkap Dengan ExpressJS. https://code.tutsplus.com
  2. Medium (2022). MVC Pattern in NodeJS and express, old but gold.   https://medium.com/@jonoyanguren
  3. Scaler (2024). Creating MVC architecture for restful APIhttps://www.scaler.com
  4. WebDong (n.d). Building Express.js project by Utilizing MVC Pattern.   https://www.webdong.dev
  5. Zac Fukuda (2024). Express.js Filke/Folder Structurehttps://www.zacfukuda.com

Posting Komentar

0 Komentar