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
- Node.js & npm - Runtime dan package manager
- Express.js - Framework utama
- Postman - Testing API
- VS Code - Code editor
- Git - Version control
b. Perangkat Keras
- 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.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ CLIENT │────▶│ CONTROLLER │────▶│ MODEL │
│ (Browser) │◀────│ (Handler) │◀────│ (Data) │
└─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ VIEW │
│ (Response) │
└─────────────┘Tanggung Jawab Setiap Komponen:
- MODEL:
- Mengelola data dan logika bisnis
- Berinteraksi dengan database
- Validasi data
- Independen dari controller dan view
VIEW:
- Menampilkan data ke user
- Untuk REST API → JSON response
- Untuk web tradisional → HTML templates
CONTROLLER:
- Menerima input dari routes
- Memanggil model
- Mengirim response ke client
- Menjadi "jembatan" antara model dan view
ROUTES (tambahan untuk Express):
- Mapping URL ke controller
- Mendefinisikan HTTP methods
- Middleware composition
3.1 Mengapa MVC Penting?
Tanpa MVC (Spaghetti Code):
// ❌ 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):
// ✅ 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 logic3.3 Praktik Lengkap: Membangun Blog API dengan MVC
Mari kita buat project Blog API dengan struktur MVC yang proper:
# 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:
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.js3.4 Implementasi MVC Step by Step
Step 1: Model Layer
src/models/User.model.js - Definisi data dan logika terkait user
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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):
✅ 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:
✅ Pisahkan business logic dari controller
✅ Controller hanya handle HTTP
✅ Service handle business rules
✅ Repository handle data access
✅ Lebih testable dan maintainableClean Architecture / Hexagonal:
✅ Pemisahan lebih ketat antara domain dan infrastructure
✅ Sangat testable
✅ Independen dari framework
❌ Overkill untuk aplikasi kecil
❌ Learning curve tinggi3.7 Best Practices MVC di Express
1. Keep Controllers Thin
// ❌ 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
// ❌ 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
// ❌ 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
// ❌ 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
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
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
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
// ❌ 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
// ❌ 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
// ❌ 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
// 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/:idExercise 2: Refactor Existing Project
// 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 formatterExercise 3: Add Caching Layer
// TODO: Implementasikan caching dengan Redis
// 1. Buat CacheService
// 2. Cache hasil query database
// 3. Cache hasil perhitungan
// 4. Invalidate cache saat data berubah4. Daftar Pustaka
- EnvantoTuts+ (2022). Membangun Sebuah Website MVC Lengkap Dengan ExpressJS. https://code.tutsplus.com
- Medium (2022). MVC Pattern in NodeJS and express, old but gold. https://medium.com/@jonoyanguren
- Scaler (2024). Creating MVC architecture for restful API. https://www.scaler.com
- WebDong (n.d). Building Express.js project by Utilizing MVC Pattern. https://www.webdong.dev
- Zac Fukuda (2024). Express.js Filke/Folder Structure. https://www.zacfukuda.com

0 Komentar