1. Latar Belakang
Hari ini kita masih belajar Express.js di Perwira Learning Center dan akan membahas Struktur Project Express yang Rapi. Ini ibarat merapikan toko serba ada:
- Toko berantakan = Semua barang dicampur, susah cari, mudah error
- Toko rapi = Setiap barang ada tempatnya, mudah dicari, efisien
Struktur project yang baik adalah fondasi untuk aplikasi yang scalable, maintainable, dan kolaboratif. Di dunia industri, struktur yang rapi bukan pilihan tapi keharusan!
2. Alat dan Bahan
a. Perangkat Lunak
- Express.js - Framework utama
- Node.js & npm - Runtime dan package manager
- VS Code - Code editor
- Postman - API testing
- Git - Version control
b. Perangkat Keras
- Laptop/PC standar
3. Pembahasan
3.1 Masalah Struktur Project Berantakan
Contoh struktur berantakan (BAD):
project/ ├── server.js ← 500 baris kode! ├── database.js ← Semua model dicampur ├── helpers.js ← 20 fungsi random ├── auth.js ← Auth logic └── ... ← Chaos total!
Masalah:
- God Object - Satu file melakukan segalanya
- Tight Coupling - Semua kode saling tergantung
- Poor Maintainability - Susah cari bug, susah tambah fitur
- Hard to Test - Tidak bisa test bagian tertentu
- Team Collaboration - Conflict di git, kerja tidak parallel
3.2 Prinsip Struktur Project yang Baik
SOLID Principles untuk Project Structure:
- Single Responsibility - Satu file, satu tanggung jawab
- Open/Closed - Bisa extend, tidak perlu modify file lama
- Liskov Substitution - Komponen bisa diganti
- Interface Segregation - Interface kecil-kecil
- Dependency Inversion - Bergantung pada abstraksi, bukan konkrit
3.3 Praktik Lengkap: E-commerce API dengan Struktur Rapi
Mari buat project dari nol dengan struktur yang rapi:
# 1. Buat folder project mkdir ecommerce-api cd ecommerce-api # 2. Inisialisasi project npm init -y # 3. Install dependencies npm install express dotenv cors helmet morgan npm install -D nodemon # 4. Buat struktur folder mkdir -p src/{config,controllers,middleware,models,repositories,routes,services,utils,validators}
Struktur akhir project:
ecommerce-api/ ├── src/ │ ├── app.js # Express app configuration │ ├── server.js # Server entry point │ ├── config/ # Configuration files │ │ ├── database.js # DB connection │ │ ├── constants.js # App constants │ │ └── env.js # Environment config │ ├── controllers/ # Request handlers │ │ ├── auth.controller.js │ │ ├── product.controller.js │ │ └── order.controller.js │ ├── middleware/ # Custom middleware │ │ ├── auth.middleware.js │ │ ├── error.middleware.js │ │ └── validation.middleware.js │ ├── models/ # Data models/Schemas │ │ ├── User.model.js │ │ ├── Product.model.js │ │ └── Order.model.js │ ├── repositories/ # Data access layer │ │ ├── user.repository.js │ │ ├── product.repository.js │ │ └── order.repository.js │ ├── routes/ # Route definitions │ │ ├── auth.routes.js │ │ ├── product.routes.js │ │ ├── order.routes.js │ │ └── index.js # Route aggregator │ ├── services/ # Business logic │ │ ├── auth.service.js │ │ ├── product.service.js │ │ └── order.service.js │ ├── utils/ # Utility functions │ │ ├── apiResponse.js │ │ ├── asyncHandler.js │ │ └── logger.js │ └── validators/ # Validation schemas │ ├── auth.validator.js │ ├── product.validator.js │ └── order.validator.js ├── tests/ # Test files │ ├── unit/ │ └── integration/ ├── public/ # Static files ├── .env # Environment variables ├── .env.example # Environment template ├── .gitignore # Git ignore rules ├── package.json # Dependencies └── README.md # Documentation
3.4 File-by-File Implementation
1. package.json
{ "name": "ecommerce-api", "version": "1.0.0", "description": "E-commerce API dengan struktur rapi", "main": "src/server.js", "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", "test": "jest", "test:watch": "jest --watch", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", "format": "prettier --write src/" }, "keywords": ["express", "ecommerce", "api", "structure"], "author": "Perwira Learning Center", "license": "MIT", "dependencies": { "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "helmet": "^7.0.0", "morgan": "^1.10.0" }, "devDependencies": { "nodemon": "^3.0.1" }, "engines": { "node": ">=18.0.0" } }
2. .env
# Application NODE_ENV=development PORT=3007 APP_NAME=Ecommerce API APP_VERSION=1.0.0 # Security JWT_SECRET=your_super_secret_jwt_key_change_in_production JWT_EXPIRE=7d # Database (simulated for now) DB_HOST=localhost DB_PORT=5432 DB_NAME=ecommerce_db DB_USER=postgres DB_PASSWORD=secret # Rate Limiting RATE_LIMIT_WINDOW_MS=900000 # 15 minutes RATE_LIMIT_MAX_REQUESTS=100 # Logging LOG_LEVEL=info
3. .gitignore
# Dependencies node_modules/ # Environment .env .env.local # Logs logs *.log npm-debug.log* # Runtime data .DS_Store Thumbs.db # IDE .vscode/ .idea/ *.swp *.swo # Test coverage coverage/ .nyc_output # OS tmp/ temp/
4. src/config/env.js
require('dotenv').config(); module.exports = { // App NODE_ENV: process.env.NODE_ENV || 'development', PORT: parseInt(process.env.PORT) || 3000, APP_NAME: process.env.APP_NAME || 'Express API', APP_VERSION: process.env.APP_VERSION || '1.0.0', // Security JWT_SECRET: process.env.JWT_SECRET || 'default_secret_key', JWT_EXPIRE: process.env.JWT_EXPIRE || '7d', // Database DB_HOST: process.env.DB_HOST || 'localhost', DB_PORT: parseInt(process.env.DB_PORT) || 5432, DB_NAME: process.env.DB_NAME || 'app_db', DB_USER: process.env.DB_USER || 'postgres', DB_PASSWORD: process.env.DB_PASSWORD || '', // Rate Limiting RATE_LIMIT_WINDOW_MS: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 900000, RATE_LIMIT_MAX_REQUESTS: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100, // Logging LOG_LEVEL: process.env.LOG_LEVEL || 'info', // CORS CORS_ORIGIN: process.env.CORS_ORIGIN || '*', // API API_PREFIX: process.env.API_PREFIX || '/api/v1' };
5. src/config/constants.js
module.exports = { // HTTP Status Codes HTTP_STATUS: { OK: 200, CREATED: 201, BAD_REQUEST: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, CONFLICT: 409, INTERNAL_SERVER_ERROR: 500, SERVICE_UNAVAILABLE: 503 }, // User Roles USER_ROLES: { ADMIN: 'admin', CUSTOMER: 'customer', SELLER: 'seller' }, // Product Categories PRODUCT_CATEGORIES: { ELECTRONICS: 'electronics', FASHION: 'fashion', HOME: 'home', SPORTS: 'sports', BOOKS: 'books', OTHER: 'other' }, // Order Status ORDER_STATUS: { PENDING: 'pending', PROCESSING: 'processing', SHIPPED: 'shipped', DELIVERED: 'delivered', CANCELLED: 'cancelled' }, // Pagination PAGINATION: { DEFAULT_PAGE: 1, DEFAULT_LIMIT: 10, MAX_LIMIT: 100 }, // Validation VALIDATION: { PASSWORD_MIN_LENGTH: 8, NAME_MAX_LENGTH: 100, EMAIL_MAX_LENGTH: 255, PRODUCT_TITLE_MAX_LENGTH: 200, PRODUCT_DESC_MAX_LENGTH: 1000 } };
6. src/utils/logger.js
const winston = require('winston'); const config = require('../config/env'); // Define log levels const levels = { error: 0, warn: 1, info: 2, http: 3, debug: 4 }; // Define colors for each level const colors = { error: 'red', warn: 'yellow', info: 'green', http: 'magenta', debug: 'blue' }; winston.addColors(colors); // Define format const format = winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }), winston.format.colorize({ all: true }), winston.format.printf( (info) => `${info.timestamp} ${info.level}: ${info.message}` ) ); // Define transports (where logs go) const transports = [ // Console transport new winston.transports.Console(), // File transport for errors new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), // File transport for all logs new winston.transports.File({ filename: 'logs/combined.log' }) ]; // Create logger instance const logger = winston.createLogger({ level: config.LOG_LEVEL, levels, format, transports }); module.exports = logger;
7. src/utils/apiResponse.js
const constants = require('../config/constants'); /** * Standard API success response */ const successResponse = (data = null, message = 'Success', metadata = {}) => { return { success: true, message, data, metadata: { timestamp: new Date().toISOString(), ...metadata } }; }; /** * Standard API error response */ const errorResponse = (message = 'Error', errorCode = 'INTERNAL_ERROR', details = null) => { return { success: false, error: { code: errorCode, message, details, timestamp: new Date().toISOString() } }; }; /** * Standard API response with status code */ const apiResponse = (res, statusCode, data = null, message = '', metadata = {}) => { const response = successResponse(data, message, metadata); return res.status(statusCode).json(response); }; /** * Standard API error response with status code */ const apiError = (res, statusCode, message = '', errorCode = '', details = null) => { const response = errorResponse(message, errorCode, details); return res.status(statusCode).json(response); }; module.exports = { successResponse, errorResponse, apiResponse, apiError };
8. src/utils/asyncHandler.js
/** * Async handler to wrap async route handlers and catch errors */ const asyncHandler = (fn) => { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; }; /** * Async handler for Express router */ const asyncRouteHandler = (routeHandler) => { return async (req, res, next) => { try { await routeHandler(req, res, next); } catch (error) { next(error); } }; }; module.exports = { asyncHandler, asyncRouteHandler };
9. src/models/User.model.js
// In real app, this would be a database model // For now, we'll use an in-memory representation class User { constructor(id, email, name, password, role = 'customer') { this.id = id; this.email = email; this.name = name; this.password = password; // In real app, this would be hashed this.role = role; this.createdAt = new Date(); this.updatedAt = new Date(); this.isActive = true; } toJSON() { return { id: this.id, email: this.email, name: this.name, role: this.role, createdAt: this.createdAt, updatedAt: this.updatedAt, isActive: this.isActive }; } } // Sample users const users = [ new User(1, 'admin@example.com', 'Admin User', 'admin123', 'admin'), new User(2, 'customer@example.com', 'John Doe', 'customer123', 'customer'), new User(3, 'seller@example.com', 'Jane Smith', 'seller123', 'seller') ]; module.exports = { User, users };
10. src/models/Product.model.js
class Product { constructor(id, title, description, price, stock, category, sellerId) { this.id = id; this.title = title; this.description = description; this.price = price; this.stock = stock; this.category = category; this.sellerId = sellerId; this.createdAt = new Date(); this.updatedAt = new Date(); this.isActive = true; this.images = []; this.rating = 0; this.reviewCount = 0; } toJSON() { return { id: this.id, title: this.title, description: this.description, price: this.price, stock: this.stock, category: this.category, sellerId: this.sellerId, createdAt: this.createdAt, updatedAt: this.updatedAt, isActive: this.isActive, images: this.images, rating: this.rating, reviewCount: this.reviewCount }; } } // Sample products const products = [ new Product(1, 'Laptop Gaming', 'Laptop untuk gaming', 15000000, 10, 'electronics', 3), new Product(2, 'T-shirt Casual', 'Kaos casual cotton', 150000, 50, 'fashion', 3), new Product(3, 'Smartphone', 'Smartphone terbaru', 8000000, 25, 'electronics', 3), new Product(4, 'Buku Programming', 'Buku belajar JavaScript', 250000, 100, 'books', 3) ]; module.exports = { Product, products };
11. src/repositories/user.repository.js
const { users } = require('../models/User.model'); const logger = require('../utils/logger'); class UserRepository { constructor() { this.users = users; this.nextId = users.length + 1; } /** * Find all users with pagination */ async findAll({ page = 1, limit = 10, role = null }) { try { let filteredUsers = [...this.users]; // Filter by role if provided 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), totalItems: filteredUsers.length, totalPages: Math.ceil(filteredUsers.length / limit), hasNextPage: endIndex < filteredUsers.length, hasPrevPage: startIndex > 0 } }; } catch (error) { logger.error('Error in UserRepository.findAll:', error); throw error; } } /** * Find user by ID */ async findById(id) { try { const user = this.users.find(u => u.id === parseInt(id)); return user ? user.toJSON() : null; } catch (error) { logger.error('Error in UserRepository.findById:', error); throw error; } } /** * Find user by email */ async findByEmail(email) { try { const user = this.users.find(u => u.email === email); return user ? user.toJSON() : null; } catch (error) { logger.error('Error in UserRepository.findByEmail:', error); throw error; } } /** * Create new user */ async create(userData) { try { const newUser = { id: this.nextId++, ...userData, createdAt: new Date(), updatedAt: new Date(), isActive: true }; this.users.push(newUser); return newUser; } catch (error) { logger.error('Error in UserRepository.create:', error); throw error; } } /** * Update user */ async update(id, updateData) { try { const index = this.users.findIndex(u => u.id === parseInt(id)); if (index === -1) { return null; } this.users[index] = { ...this.users[index], ...updateData, updatedAt: new Date() }; return this.users[index].toJSON(); } catch (error) { logger.error('Error in UserRepository.update:', error); throw error; } } /** * Delete user (soft delete) */ async delete(id) { try { const index = this.users.findIndex(u => u.id === parseInt(id)); if (index === -1) { return false; } this.users[index].isActive = false; this.users[index].updatedAt = new Date(); return true; } catch (error) { logger.error('Error in UserRepository.delete:', error); throw error; } } } module.exports = new UserRepository();
12. src/repositories/product.repository.js
const { products } = require('../models/Product.model'); const logger = require('../utils/logger'); class ProductRepository { constructor() { this.products = products; this.nextId = products.length + 1; } async findAll({ page = 1, limit = 10, category = null, minPrice = null, maxPrice = null, search = null, sellerId = null }) { try { let filteredProducts = [...this.products].filter(p => p.isActive); // Apply filters if (category) { filteredProducts = filteredProducts.filter(p => p.category === category); } if (minPrice !== null) { filteredProducts = filteredProducts.filter(p => p.price >= minPrice); } if (maxPrice !== null) { filteredProducts = filteredProducts.filter(p => p.price <= maxPrice); } if (search) { const searchLower = search.toLowerCase(); filteredProducts = filteredProducts.filter(p => p.title.toLowerCase().includes(searchLower) || p.description.toLowerCase().includes(searchLower) ); } if (sellerId) { filteredProducts = filteredProducts.filter(p => p.sellerId === parseInt(sellerId)); } // Pagination const startIndex = (page - 1) * limit; const endIndex = page * limit; const paginatedProducts = filteredProducts.slice(startIndex, endIndex); return { data: paginatedProducts.map(product => product.toJSON()), pagination: { page: parseInt(page), limit: parseInt(limit), totalItems: filteredProducts.length, totalPages: Math.ceil(filteredProducts.length / limit), hasNextPage: endIndex < filteredProducts.length, hasPrevPage: startIndex > 0 }, filters: { category, priceRange: { min: minPrice, max: maxPrice }, search, sellerId } }; } catch (error) { logger.error('Error in ProductRepository.findAll:', error); throw error; } } async findById(id) { try { const product = this.products.find(p => p.id === parseInt(id) && p.isActive); return product ? product.toJSON() : null; } catch (error) { logger.error('Error in ProductRepository.findById:', error); throw error; } } async create(productData) { try { const newProduct = { id: this.nextId++, ...productData, createdAt: new Date(), updatedAt: new Date(), isActive: true, images: productData.images || [], rating: 0, reviewCount: 0 }; this.products.push(newProduct); return newProduct.toJSON(); } catch (error) { logger.error('Error in ProductRepository.create:', error); throw error; } } async update(id, updateData) { try { const index = this.products.findIndex(p => p.id === parseInt(id)); if (index === -1) { return null; } this.products[index] = { ...this.products[index], ...updateData, updatedAt: new Date() }; return this.products[index].toJSON(); } catch (error) { logger.error('Error in ProductRepository.update:', error); throw error; } } async delete(id) { try { const index = this.products.findIndex(p => p.id === parseInt(id)); if (index === -1) { return false; } this.products[index].isActive = false; this.products[index].updatedAt = new Date(); return true; } catch (error) { logger.error('Error in ProductRepository.delete:', error); throw error; } } } module.exports = new ProductRepository();
13. src/services/auth.service.js
const userRepository = require('../repositories/user.repository'); const { apiError } = require('../utils/apiResponse'); const logger = require('../utils/logger'); const constants = require('../config/constants'); class AuthService { /** * Register new user */ async register(userData) { try { logger.info('AuthService.register: Attempting to register user'); // Check if user already exists const existingUser = await userRepository.findByEmail(userData.email); if (existingUser) { logger.warn(`AuthService.register: Email ${userData.email} already exists`); throw { statusCode: constants.HTTP_STATUS.CONFLICT, errorCode: 'EMAIL_ALREADY_EXISTS', message: 'Email already registered' }; } // Validate password strength if (userData.password.length < constants.VALIDATION.PASSWORD_MIN_LENGTH) { throw { statusCode: constants.HTTP_STATUS.BAD_REQUEST, errorCode: 'WEAK_PASSWORD', message: `Password must be at least ${constants.VALIDATION.PASSWORD_MIN_LENGTH} characters` }; } // Create user const newUser = await userRepository.create(userData); logger.info(`AuthService.register: User created with ID ${newUser.id}`); // Remove password from response const { password, ...userWithoutPassword } = newUser; return { user: userWithoutPassword, token: this.generateToken(newUser.id, newUser.role) }; } catch (error) { logger.error('AuthService.register error:', error); throw error; } } /** * Login user */ async login(email, password) { try { logger.info(`AuthService.login: Attempting login for ${email}`); // Find user by email const user = await userRepository.findByEmail(email); if (!user) { logger.warn(`AuthService.login: User not found for email ${email}`); throw { statusCode: constants.HTTP_STATUS.UNAUTHORIZED, errorCode: 'INVALID_CREDENTIALS', message: 'Invalid email or password' }; } // Check if user is active if (!user.isActive) { logger.warn(`AuthService.login: User ${user.id} is inactive`); throw { statusCode: constants.HTTP_STATUS.FORBIDDEN, errorCode: 'ACCOUNT_INACTIVE', message: 'Account is inactive' }; } // Check password (in real app, compare hashed passwords) if (user.password !== password) { logger.warn(`AuthService.login: Invalid password for user ${user.id}`); throw { statusCode: constants.HTTP_STATUS.UNAUTHORIZED, errorCode: 'INVALID_CREDENTIALS', message: 'Invalid email or password' }; } // Remove password from response const { password: _, ...userWithoutPassword } = user; logger.info(`AuthService.login: User ${user.id} logged in successfully`); return { user: userWithoutPassword, token: this.generateToken(user.id, user.role) }; } catch (error) { logger.error('AuthService.login error:', error); throw error; } } /** * Get user profile */ async getProfile(userId) { try { logger.info(`AuthService.getProfile: Getting profile for user ${userId}`); const user = await userRepository.findById(userId); if (!user) { logger.warn(`AuthService.getProfile: User ${userId} not found`); throw { statusCode: constants.HTTP_STATUS.NOT_FOUND, errorCode: 'USER_NOT_FOUND', message: 'User not found' }; } // Remove password const { password, ...userWithoutPassword } = user; return userWithoutPassword; } catch (error) { logger.error('AuthService.getProfile error:', error); throw error; } } /** * Update user profile */ async updateProfile(userId, updateData) { try { logger.info(`AuthService.updateProfile: Updating profile for user ${userId}`); // Don't allow updating email via this endpoint if (updateData.email) { delete updateData.email; } const updatedUser = await userRepository.update(userId, updateData); if (!updatedUser) { logger.warn(`AuthService.updateProfile: User ${userId} not found`); throw { statusCode: constants.HTTP_STATUS.NOT_FOUND, errorCode: 'USER_NOT_FOUND', message: 'User not found' }; } // Remove password const { password, ...userWithoutPassword } = updatedUser; logger.info(`AuthService.updateProfile: User ${userId} updated successfully`); return userWithoutPassword; } catch (error) { logger.error('AuthService.updateProfile error:', error); throw error; } } /** * Generate JWT token (simplified version) */ generateToken(userId, role) { // In real app, use jsonwebtoken library const payload = { sub: userId, role: role, iat: Date.now() }; // Simulate JWT token generation return `jwt_${Buffer.from(JSON.stringify(payload)).toString('base64')}`; } } module.exports = new AuthService();
14. src/services/product.service.js
const productRepository = require('../repositories/product.repository'); const logger = require('../utils/logger'); const constants = require('../config/constants'); class ProductService { /** * Get all products with filters */ async getProducts(filters) { try { logger.info('ProductService.getProducts: Fetching products with filters', { filters }); const result = await productRepository.findAll(filters); logger.info(`ProductService.getProducts: Found ${result.data.length} products`); return result; } catch (error) { logger.error('ProductService.getProducts error:', error); throw error; } } /** * Get product by ID */ async getProductById(id) { try { logger.info(`ProductService.getProductById: Fetching product ${id}`); const product = await productRepository.findById(id); if (!product) { logger.warn(`ProductService.getProductById: Product ${id} not found`); throw { statusCode: constants.HTTP_STATUS.NOT_FOUND, errorCode: 'PRODUCT_NOT_FOUND', message: 'Product not found' }; } logger.info(`ProductService.getProductById: Product ${id} found`); return product; } catch (error) { logger.error('ProductService.getProductById error:', error); throw error; } } /** * Create new product */ async createProduct(productData, sellerId) { try { logger.info(`ProductService.createProduct: Creating product for seller ${sellerId}`); // Validate required fields const requiredFields = ['title', 'description', 'price', 'category']; const missingFields = requiredFields.filter(field => !productData[field]); if (missingFields.length > 0) { logger.warn(`ProductService.createProduct: Missing required fields: ${missingFields.join(', ')}`); throw { statusCode: constants.HTTP_STATUS.BAD_REQUEST, errorCode: 'MISSING_REQUIRED_FIELDS', message: `Missing required fields: ${missingFields.join(', ')}`, missingFields }; } // Validate price if (productData.price <= 0) { logger.warn(`ProductService.createProduct: Invalid price ${productData.price}`); throw { statusCode: constants.HTTP_STATUS.BAD_REQUEST, errorCode: 'INVALID_PRICE', message: 'Price must be greater than 0' }; } // Validate stock if (productData.stock !== undefined && productData.stock < 0) { logger.warn(`ProductService.createProduct: Invalid stock ${productData.stock}`); throw { statusCode: constants.HTTP_STATUS.BAD_REQUEST, errorCode: 'INVALID_STOCK', message: 'Stock cannot be negative' }; } // Add seller ID const productWithSeller = { ...productData, sellerId: parseInt(sellerId), stock: productData.stock || 0 }; const newProduct = await productRepository.create(productWithSeller); logger.info(`ProductService.createProduct: Product created with ID ${newProduct.id}`); return newProduct; } catch (error) { logger.error('ProductService.createProduct error:', error); throw error; } } /** * Update product */ async updateProduct(id, updateData, sellerId) { try { logger.info(`ProductService.updateProduct: Updating product ${id}`); // Check if product exists and belongs to seller const existingProduct = await productRepository.findById(id); if (!existingProduct) { logger.warn(`ProductService.updateProduct: Product ${id} not found`); throw { statusCode: constants.HTTP_STATUS.NOT_FOUND, errorCode: 'PRODUCT_NOT_FOUND', message: 'Product not found' }; } // Check authorization (only seller can update their own product) if (existingProduct.sellerId !== parseInt(sellerId)) { logger.warn(`ProductService.updateProduct: Seller ${sellerId} not authorized to update product ${id}`); throw { statusCode: constants.HTTP_STATUS.FORBIDDEN, errorCode: 'UNAUTHORIZED', message: 'You can only update your own products' }; } // Don't allow updating sellerId if (updateData.sellerId) { delete updateData.sellerId; } const updatedProduct = await productRepository.update(id, updateData); logger.info(`ProductService.updateProduct: Product ${id} updated successfully`); return updatedProduct; } catch (error) { logger.error('ProductService.updateProduct error:', error); throw error; } } /** * Delete product (soft delete) */ async deleteProduct(id, sellerId) { try { logger.info(`ProductService.deleteProduct: Deleting product ${id}`); // Check if product exists and belongs to seller const existingProduct = await productRepository.findById(id); if (!existingProduct) { logger.warn(`ProductService.deleteProduct: Product ${id} not found`); throw { statusCode: constants.HTTP_STATUS.NOT_FOUND, errorCode: 'PRODUCT_NOT_FOUND', message: 'Product not found' }; } // Check authorization if (existingProduct.sellerId !== parseInt(sellerId)) { logger.warn(`ProductService.deleteProduct: Seller ${sellerId} not authorized to delete product ${id}`); throw { statusCode: constants.HTTP_STATUS.FORBIDDEN, errorCode: 'UNAUTHORIZED', message: 'You can only delete your own products' }; } const deleted = await productRepository.delete(id); if (!deleted) { throw { statusCode: constants.HTTP_STATUS.INTERNAL_SERVER_ERROR, errorCode: 'DELETE_FAILED', message: 'Failed to delete product' }; } logger.info(`ProductService.deleteProduct: Product ${id} deleted successfully`); return { success: true, message: 'Product deleted successfully' }; } catch (error) { logger.error('ProductService.deleteProduct error:', error); throw error; } } } module.exports = new ProductService();
15. src/validators/auth.validator.js
const Joi = require('joi'); const constants = require('../config/constants'); const authValidator = { // Register validation schema register: Joi.object({ name: Joi.string() .min(2) .max(constants.VALIDATION.NAME_MAX_LENGTH) .required() .messages({ 'string.empty': 'Name is required', 'string.min': 'Name must be at least 2 characters', 'string.max': `Name must not exceed ${constants.VALIDATION.NAME_MAX_LENGTH} characters` }), email: Joi.string() .email() .max(constants.VALIDATION.EMAIL_MAX_LENGTH) .required() .messages({ 'string.empty': 'Email is required', 'string.email': 'Please provide a valid email address', 'string.max': `Email must not exceed ${constants.VALIDATION.EMAIL_MAX_LENGTH} characters` }), password: Joi.string() .min(constants.VALIDATION.PASSWORD_MIN_LENGTH) .required() .messages({ 'string.empty': 'Password is required', 'string.min': `Password must be at least ${constants.VALIDATION.PASSWORD_MIN_LENGTH} characters` }), role: Joi.string() .valid(...Object.values(constants.USER_ROLES)) .default(constants.USER_ROLES.CUSTOMER) .messages({ 'any.only': 'Invalid user role' }) }), // Login validation schema login: Joi.object({ email: Joi.string() .email() .required() .messages({ 'string.empty': 'Email is required', 'string.email': 'Please provide a valid email address' }), password: Joi.string() .required() .messages({ 'string.empty': 'Password is required' }) }), // Update profile validation schema updateProfile: Joi.object({ name: Joi.string() .min(2) .max(constants.VALIDATION.NAME_MAX_LENGTH) .messages({ 'string.min': 'Name must be at least 2 characters', 'string.max': `Name must not exceed ${constants.VALIDATION.NAME_MAX_LENGTH} characters` }), // Add other fields that can be updated }) }; module.exports = authValidator;
16. src/validators/product.validator.js
const Joi = require('joi'); const constants = require('../config/constants'); const productValidator = { // Create product validation schema create: Joi.object({ title: Joi.string() .min(3) .max(constants.VALIDATION.PRODUCT_TITLE_MAX_LENGTH) .required() .messages({ 'string.empty': 'Product title is required', 'string.min': 'Product title must be at least 3 characters', 'string.max': `Product title must not exceed ${constants.VALIDATION.PRODUCT_TITLE_MAX_LENGTH} characters` }), description: Joi.string() .min(10) .max(constants.VALIDATION.PRODUCT_DESC_MAX_LENGTH) .required() .messages({ 'string.empty': 'Product description is required', 'string.min': 'Product description must be at least 10 characters', 'string.max': `Product description must not exceed ${constants.VALIDATION.PRODUCT_DESC_MAX_LENGTH} characters` }), price: Joi.number() .positive() .required() .messages({ 'number.base': 'Price must be a number', 'number.positive': 'Price must be greater than 0' }), stock: Joi.number() .integer() .min(0) .default(0) .messages({ 'number.base': 'Stock must be a number', 'number.integer': 'Stock must be an integer', 'number.min': 'Stock cannot be negative' }), category: Joi.string() .valid(...Object.values(constants.PRODUCT_CATEGORIES)) .required() .messages({ 'any.only': 'Invalid product category', 'string.empty': 'Product category is required' }), images: Joi.array() .items(Joi.string().uri()) .default([]) .messages({ 'array.base': 'Images must be an array', 'string.uri': 'Each image must be a valid URL' }) }), // Update product validation schema update: Joi.object({ title: Joi.string() .min(3) .max(constants.VALIDATION.PRODUCT_TITLE_MAX_LENGTH) .messages({ 'string.min': 'Product title must be at least 3 characters', 'string.max': `Product title must not exceed ${constants.VALIDATION.PRODUCT_TITLE_MAX_LENGTH} characters` }), description: Joi.string() .min(10) .max(constants.VALIDATION.PRODUCT_DESC_MAX_LENGTH) .messages({ 'string.min': 'Product description must be at least 10 characters', 'string.max': `Product description must not exceed ${constants.VALIDATION.PRODUCT_DESC_MAX_LENGTH} characters` }), price: Joi.number() .positive() .messages({ 'number.base': 'Price must be a number', 'number.positive': 'Price must be greater than 0' }), stock: Joi.number() .integer() .min(0) .messages({ 'number.base': 'Stock must be a number', 'number.integer': 'Stock must be an integer', 'number.min': 'Stock cannot be negative' }), category: Joi.string() .valid(...Object.values(constants.PRODUCT_CATEGORIES)) .messages({ 'any.only': 'Invalid product category' }), images: Joi.array() .items(Joi.string().uri()) .messages({ 'array.base': 'Images must be an array', 'string.uri': 'Each image must be a valid URL' }), isActive: Joi.boolean() .messages({ 'boolean.base': 'isActive must be a boolean' }) }).min(1) // At least one field must be provided for update }; module.exports = productValidator;
17. src/middleware/validation.middleware.js
const { apiError } = require('../utils/apiResponse'); const constants = require('../config/constants'); const logger = require('../utils/logger'); /** * Middleware untuk validasi request body menggunakan Joi schema */ const validate = (schema, property = 'body') => { return (req, res, next) => { try { logger.info('Validation middleware: Validating request', { path: req.path, property: property }); const { error, value } = schema.validate(req[property], { abortEarly: false, // Return all errors, not just the first one stripUnknown: true // Remove unknown fields }); if (error) { logger.warn('Validation middleware: Validation failed', { errors: error.details, path: req.path }); // Format error response const errors = error.details.map(detail => ({ field: detail.path.join('.'), message: detail.message.replace(/"/g, ''), type: detail.type })); return apiError( res, constants.HTTP_STATUS.BAD_REQUEST, 'Validation Error', 'VALIDATION_FAILED', { errors } ); } // Replace request data with validated data req[property] = value; logger.info('Validation middleware: Validation passed'); next(); } catch (error) { logger.error('Validation middleware error:', error); next(error); } }; }; /** * Middleware untuk validasi query parameters */ const validateQuery = (schema) => validate(schema, 'query'); /** * Middleware untuk validasi route parameters */ const validateParams = (schema) => validate(schema, 'params'); module.exports = { validate, validateQuery, validateParams };
18. src/middleware/auth.middleware.js
const { apiError } = require('../utils/apiResponse'); const constants = require('../config/constants'); const logger = require('../utils/logger'); /** * Middleware untuk verifikasi JWT token */ const authenticate = (req, res, next) => { try { logger.info('Auth middleware: Authenticating request'); const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { logger.warn('Auth middleware: No token provided'); return apiError( res, constants.HTTP_STATUS.UNAUTHORIZED, 'Authentication required', 'NO_TOKEN_PROVIDED' ); } const token = authHeader.split(' ')[1]; // Simulate token verification // In real app, use jsonwebtoken.verify() try { const tokenPayload = JSON.parse( Buffer.from(token.split('_')[1], 'base64').toString() ); // Check token expiration (simplified) const tokenAge = Date.now() - tokenPayload.iat; const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds if (tokenAge > maxAge) { logger.warn('Auth middleware: Token expired'); return apiError( res, constants.HTTP_STATUS.UNAUTHORIZED, 'Token expired', 'TOKEN_EXPIRED' ); } // Attach user info to request req.user = { id: tokenPayload.sub, role: tokenPayload.role }; logger.info(`Auth middleware: User authenticated - ID: ${req.user.id}, Role: ${req.user.role}`); next(); } catch (tokenError) { logger.warn('Auth middleware: Invalid token'); return apiError( res, constants.HTTP_STATUS.UNAUTHORIZED, 'Invalid token', 'INVALID_TOKEN' ); } } catch (error) { logger.error('Auth middleware error:', error); next(error); } }; /** * Middleware untuk authorization berdasarkan role */ const authorize = (...roles) => { return (req, res, next) => { try { logger.info('Authorization middleware: Checking permissions', { requiredRoles: roles, userRole: req.user?.role }); if (!req.user) { logger.warn('Authorization middleware: No user found in request'); return apiError( res, constants.HTTP_STATUS.UNAUTHORIZED, 'Authentication required', 'NOT_AUTHENTICATED' ); } if (!roles.includes(req.user.role)) { logger.warn(`Authorization middleware: User ${req.user.id} with role ${req.user.role} not authorized`); return apiError( res, constants.HTTP_STATUS.FORBIDDEN, 'Insufficient permissions', 'INSUFFICIENT_PERMISSIONS', { requiredRoles: roles, userRole: req.user.role } ); } logger.info(`Authorization middleware: User ${req.user.id} authorized`); next(); } catch (error) { logger.error('Authorization middleware error:', error); next(error); } }; }; module.exports = { authenticate, authorize };
19. src/middleware/error.middleware.js
const { apiError } = require('../utils/apiResponse'); const constants = require('../config/constants'); const logger = require('../utils/logger'); /** * Global error handling middleware */ const errorHandler = (err, req, res, next) => { logger.error('Global error handler:', { error: err.message, stack: err.stack, path: req.path, method: req.method, user: req.user?.id }); // Handle known error types if (err.statusCode && err.errorCode) { return apiError( res, err.statusCode, err.message, err.errorCode, err.details ); } // Handle Joi validation errors if (err.name === 'ValidationError') { return apiError( res, constants.HTTP_STATUS.BAD_REQUEST, 'Validation Error', 'VALIDATION_ERROR', { errors: err.details } ); } // Handle duplicate key error (simulated) if (err.code === 'EMAIL_ALREADY_EXISTS') { return apiError( res, constants.HTTP_STATUS.CONFLICT, err.message, 'DUPLICATE_ENTRY', err.details ); } // Default error return apiError( res, constants.HTTP_STATUS.INTERNAL_SERVER_ERROR, process.env.NODE_ENV === 'development' ? err.message : 'Internal server error', 'INTERNAL_ERROR', process.env.NODE_ENV === 'development' ? { stack: err.stack } : null ); }; /** * 404 Not Found middleware */ const notFound = (req, res, next) => { logger.warn('404 Not Found:', { path: req.originalUrl, method: req.method, ip: req.ip }); return apiError( res, constants.HTTP_STATUS.NOT_FOUND, `Cannot ${req.method} ${req.originalUrl}`, 'ROUTE_NOT_FOUND' ); }; module.exports = { errorHandler, notFound };
20. src/controllers/auth.controller.js
const authService = require('../services/auth.service'); const { apiResponse } = require('../utils/apiResponse'); const { asyncHandler } = require('../utils/asyncHandler'); const logger = require('../utils/logger'); const authController = { /** * Register new user */ register: asyncHandler(async (req, res) => { logger.info('AuthController.register: Processing registration request'); const result = await authService.register(req.body); logger.info(`AuthController.register: User registered successfully - ID: ${result.user.id}`); return apiResponse( res, 201, result, 'User registered successfully' ); }), /** * Login user */ login: asyncHandler(async (req, res) => { logger.info('AuthController.login: Processing login request'); const { email, password } = req.body; const result = await authService.login(email, password); logger.info(`AuthController.login: User logged in successfully - ID: ${result.user.id}`); return apiResponse( res, 200, result, 'Login successful' ); }), /** * Get user profile */ getProfile: asyncHandler(async (req, res) => { logger.info(`AuthController.getProfile: Getting profile for user ${req.user.id}`); const profile = await authService.getProfile(req.user.id); logger.info(`AuthController.getProfile: Profile retrieved for user ${req.user.id}`); return apiResponse( res, 200, profile, 'Profile retrieved successfully' ); }), /** * Update user profile */ updateProfile: asyncHandler(async (req, res) => { logger.info(`AuthController.updateProfile: Updating profile for user ${req.user.id}`); const updatedProfile = await authService.updateProfile(req.user.id, req.body); logger.info(`AuthController.updateProfile: Profile updated for user ${req.user.id}`); return apiResponse( res, 200, updatedProfile, 'Profile updated successfully' ); }) }; module.exports = authController;
21. src/controllers/product.controller.js
const productService = require('../services/product.service'); const { apiResponse } = require('../utils/apiResponse'); const { asyncHandler } = require('../utils/asyncHandler'); const logger = require('../utils/logger'); const productController = { /** * Get all products */ getProducts: asyncHandler(async (req, res) => { logger.info('ProductController.getProducts: Fetching products'); const result = await productService.getProducts(req.query); logger.info(`ProductController.getProducts: Retrieved ${result.data.length} products`); return apiResponse( res, 200, result.data, 'Products retrieved successfully', { pagination: result.pagination, filters: result.filters } ); }), /** * Get product by ID */ getProductById: asyncHandler(async (req, res) => { const { id } = req.params; logger.info(`ProductController.getProductById: Fetching product ${id}`); const product = await productService.getProductById(id); logger.info(`ProductController.getProductById: Product ${id} retrieved`); return apiResponse( res, 200, product, 'Product retrieved successfully' ); }), /** * Create new product */ createProduct: asyncHandler(async (req, res) => { logger.info(`ProductController.createProduct: Creating product for seller ${req.user.id}`); const product = await productService.createProduct(req.body, req.user.id); logger.info(`ProductController.createProduct: Product created with ID ${product.id}`); return apiResponse( res, 201, product, 'Product created successfully' ); }), /** * Update product */ updateProduct: asyncHandler(async (req, res) => { const { id } = req.params; logger.info(`ProductController.updateProduct: Updating product ${id}`); const product = await productService.updateProduct(id, req.body, req.user.id); logger.info(`ProductController.updateProduct: Product ${id} updated`); return apiResponse( res, 200, product, 'Product updated successfully' ); }), /** * Delete product */ deleteProduct: asyncHandler(async (req, res) => { const { id } = req.params; logger.info(`ProductController.deleteProduct: Deleting product ${id}`); const result = await productService.deleteProduct(id, req.user.id); logger.info(`ProductController.deleteProduct: Product ${id} deleted`); return apiResponse( res, 200, result, 'Product deleted successfully' ); }) }; module.exports = productController;
22. src/routes/auth.routes.js
const express = require('express'); const router = express.Router(); const authController = require('../controllers/auth.controller'); const authValidator = require('../validators/auth.validator'); const { validate } = require('../middleware/validation.middleware'); const { authenticate } = require('../middleware/auth.middleware'); // Public routes router.post( '/register', validate(authValidator.register), authController.register ); router.post( '/login', validate(authValidator.login), authController.login ); // Protected routes router.get( '/profile', authenticate, authController.getProfile ); router.patch( '/profile', authenticate, validate(authValidator.updateProfile), authController.updateProfile ); module.exports = router;
23. src/routes/product.routes.js
const express = require('express'); const router = express.Router(); const productController = require('../controllers/product.controller'); const productValidator = require('../validators/product.validator'); const { validate, validateQuery } = require('../middleware/validation.middleware'); const { authenticate, authorize } = require('../middleware/auth.middleware'); const constants = require('../config/constants'); // Public routes router.get( '/', validateQuery(productValidator.create), // Reuse create schema for query validation productController.getProducts ); router.get( '/:id', productController.getProductById ); // Protected routes (seller only) router.post( '/', authenticate, authorize(constants.USER_ROLES.SELLER, constants.USER_ROLES.ADMIN), validate(productValidator.create), productController.createProduct ); router.patch( '/:id', authenticate, authorize(constants.USER_ROLES.SELLER, constants.USER_ROLES.ADMIN), validate(productValidator.update), productController.updateProduct ); router.delete( '/:id', authenticate, authorize(constants.USER_ROLES.SELLER, constants.USER_ROLES.ADMIN), productController.deleteProduct ); module.exports = router;
24. src/routes/index.js
const express = require('express'); const router = express.Router(); const config = require('../config/env'); const authRoutes = require('./auth.routes'); const productRoutes = require('./product.routes'); const { apiResponse } = require('../utils/apiResponse'); // Health check endpoint router.get('/health', (req, res) => { apiResponse(res, 200, { status: 'healthy', timestamp: new Date().toISOString(), service: config.APP_NAME, version: config.APP_VERSION, environment: config.NODE_ENV }, 'Service is healthy'); }); // API documentation router.get('/', (req, res) => { apiResponse(res, 200, { service: config.APP_NAME, version: config.APP_VERSION, documentation: `${req.protocol}://${req.get('host')}/docs`, endpoints: { auth: `${config.API_PREFIX}/auth`, products: `${config.API_PREFIX}/products`, health: `${config.API_PREFIX}/health` } }, 'Welcome to E-commerce API'); }); // Mount routes router.use('/auth', authRoutes); router.use('/products', productRoutes); module.exports = router;
25. src/app.js
const express = require('express'); const cors = require('cors'); const helmet = require('helmet'); const morgan = require('morgan'); const config = require('./config/env'); const logger = require('./utils/logger'); const { notFound, errorHandler } = require('./middleware/error.middleware'); const apiRoutes = require('./routes/index'); const app = express(); // ==================== MIDDLEWARE SETUP ==================== // Security middleware app.use(helmet()); // CORS configuration app.use(cors({ origin: config.CORS_ORIGIN, credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'] })); // Request logging app.use(morgan('combined', { stream: { write: (message) => logger.http(message.trim()) } })); // Body parsing middleware app.use(express.json()); app.use(express.urlencoded({ extended: true })); // ==================== ROUTES SETUP ==================== // API routes app.use(config.API_PREFIX, apiRoutes); // ==================== ERROR HANDLING ==================== // 404 handler app.use(notFound); // Global error handler app.use(errorHandler); module.exports = app;
26. src/server.js
const app = require('./app'); const config = require('./config/env'); const logger = require('./utils/logger'); const PORT = config.PORT || 3000; const server = app.listen(PORT, () => { logger.info(` ╔══════════════════════════════════════════════════════════╗ ║ 🚀 E-commerce API Started ║ ╠══════════════════════════════════════════════════════════╣ ║ Service: ${config.APP_NAME} ║ Version: ${config.APP_VERSION} ║ Environment: ${config.NODE_ENV} ║ Port: ${PORT} ║ API: http://localhost:${PORT}${config.API_PREFIX} ║ Health: http://localhost:${PORT}${config.API_PREFIX}/health ╚══════════════════════════════════════════════════════════╝ `); }); // Graceful shutdown process.on('SIGTERM', () => { logger.info('SIGTERM received. Shutting down gracefully...'); server.close(() => { logger.info('Process terminated'); }); }); process.on('SIGINT', () => { logger.info('SIGINT received. Shutting down gracefully...'); server.close(() => { logger.info('Process terminated'); }); }); // Handle unhandled promise rejections process.on('unhandledRejection', (reason, promise) => { logger.error('Unhandled Rejection at:', promise, 'reason:', reason); }); // Handle uncaught exceptions process.on('uncaughtException', (error) => { logger.error('Uncaught Exception:', error); process.exit(1); }); module.exports = server;
3.5 Flow Data dalam Struktur Rapi
// 📊 FLOW DATA: Client → Server → Database → Client // 1. CLIENT mengirim request // POST /api/v1/products // Headers: { Authorization: Bearer token } // Body: { title: "Laptop", price: 10000000, ... } // 2. ROUTES (src/routes/product.routes.js) // - Validasi path & method // - Terapkan middleware: auth, validation // - Panggil controller // 3. MIDDLEWARE (src/middleware/) // - auth.middleware.js: Cek token // - validation.middleware.js: Validasi data // 4. VALIDATORS (src/validators/product.validator.js) // - Validasi schema menggunakan Joi // - Return error jika invalid // 5. CONTROLLERS (src/controllers/product.controller.js) // - Terima request yang sudah divalidasi // - Panggil service layer // - Format response // 6. SERVICES (src/services/product.service.js) // - Business logic // - Validasi business rules // - Panggil repository layer // 7. REPOSITORIES (src/repositories/product.repository.js) // - Data access logic // - Query ke database // - Return data // 8. MODELS (src/models/Product.model.js) // - Data structure definition // - Business entity representation // 9. RESPONSE kembali ke CLIENT // - Format response konsisten // - Status code yang tepat // - Data yang sudah diproses
3.6 Keuntungan Struktur Rapi
1. Separation of Concerns
// Setiap layer punya tanggung jawab tunggal: // - Routes: Routing & middleware composition // - Controllers: Request/response handling // - Services: Business logic // - Repositories: Data access // - Models: Data structure
2. Testability
// Mudah testing tiap komponen: test('ProductService.createProduct should validate price', () => { // Test service logic tanpa HTTP }); test('ProductController should return 201 on success', () => { // Test controller response });
3. Maintainability
// Perubahan di satu layer tidak pengaruh layer lain: // Ubah database? Cuma ubah repository // Ubah business logic? Cuma ubah service // Ubah API response? Cuma ubah controller
4. Team Collaboration
// Multiple developers bisa kerja parallel: // Developer A: Kerja di auth feature // Developer B: Kerja di product feature // Developer C: Kerja di payment feature // Tidak ada conflict karena file terpisah
5. Scalability
// Mudah scale: // - Split monolith ke microservices // - Add new features tanpa ganggu existing // - Replace components (e.g., database driver)
3.7 Best Practices Struktur Project
1. Naming Convention Konsisten
// ✅ Good user.controller.js user.service.js user.repository.js user.model.js user.validator.js user.routes.js // ❌ Bad userCtrl.js UserService.js userRepo.js UserModel.js validateUser.js userRouter.js
2. File Size Management
// Jika file terlalu besar (> 300 lines), split: // ❌ Bad: user.service.js (1000 lines) // ✅ Good: // user.service.js (base class) // user.auth.service.js (auth logic) // user.profile.service.js (profile logic) // user.password.service.js (password logic)
3. Dependency Injection Pattern
// Gunakan dependency injection untuk loose coupling class ProductService { constructor(productRepository, logger) { this.repository = productRepository; this.logger = logger; } async getProducts() { return this.repository.findAll(); } } // Testing mudah dengan mock const mockRepo = { findAll: jest.fn() }; const service = new ProductService(mockRepo, console);
4. Barrel Exports
// Di setiap folder, buat index.js untuk export // src/services/index.js: module.exports = { authService: require('./auth.service'), productService: require('./product.service'), orderService: require('./order.service') }; // Penggunaan: const { authService, productService } = require('../services');
3.8 Common Anti-Patterns to Avoid
1. God Objects
// ❌ Bad: Semua logic di satu file // app.js (1000+ lines) // - Routes // - Controllers // - Services // - Database // - Middleware // - Config // ✅ Good: Separation of concerns // Setiap concern di file terpisah
2. Tight Coupling
// ❌ Bad: Controller langsung akses database app.post('/products', (req, res) => { const db = require('./database'); db.query('INSERT...'); // Direct database access }); // ✅ Good: Layer separation // Controller → Service → Repository → Database
3. Business Logic in Controllers
// ❌ Bad: Business logic di controller app.post('/orders', (req, res) => { // Price calculation // Inventory check // Payment processing // Email notification // Semua di controller! }); // ✅ Good: Business logic di service layer orderService.processOrder(orderData);
3.9 Tools untuk Maintain Structure
1. ESLint & Prettier - Code consistency
2. Husky & lint-staged - Pre-commit hooks
3. Jest - Testing framework
4. Docker - Consistent environment
5. CI/CD Pipeline - Automated testing & deployment
3.10 Latihan Praktikum
Exercise 1: Refactor Monolith ke Structured
// Diberikan file server.js monolith (500 lines), // refactor ke struktur rapi dengan: // 1. Routes terpisah // 2. Controllers terpisah // 3. Services terpisah // 4. Models terpisah
Exercise 2: Add New Feature
// Tambahkan fitur "Wishlist" ke e-commerce API: // 1. Buat Wishlist model // 2. Buat Wishlist repository // 3. Buat Wishlist service // 4. Buat Wishlist controller // 5. Buat Wishlist routes // 6. Buat Wishlist validators
Exercise 3: Implement Caching Layer
// Tambahkan caching layer antara service dan repository: // 1. Buat CacheService // 2. Modify repositories untuk check cache dulu // 3. Implement cache invalidation // 4. Add cache configuration
4. Daftar Pustaka
- Node.js Best Practices (2024). Project Structure. Diakses dari https://github.com/goldbergyoni/nodebestpractices
- Clean Architecture by Robert C. Martin (2017). Book on software architecture
- Express.js Application Structure (2024). Official Patterns. Diakses dari https://expressjs.com/en/advanced/best-practice-performance.html
- SOLID Principles (2024). Object-Oriented Design. Diakses dari https://www.digitalocean.com/community/conceptual_articles/s-o-l-i-d-the-first-five-principles-of-object-oriented-design
- Microservices vs Monolith (2024). Architecture Patterns. Diakses dari https://microservices.io/

0 Komentar