Framework Java enterprise buatan sendiri — tanpa Spring, tanpa Jakarta EE, tanpa magic.
- Tentang Project
- Masalah yang Diselesaikan
- Filosofi Desain
- Arsitektur
- Struktur Folder
- Framework Components
- Request Lifecycle
- Dependency Injection
- Routing
- Authentication & Session (JWT HttpOnly Cookie)
- RBAC — Role-Based Access Control
- CSRF Protection
- Token Refresh
- Data Layer / ORM
- Database Migration
- Environment Profiles
- Bulk Operations
- Audit Log
- Health Check & Prometheus Metrics
- WebSocket
- File Upload (Multipart)
- Email / Mail Service
- Membuat Module Baru
- Membuat Entity Baru
- Menjalankan Project
- Menjalankan Test
- Contoh Lengkap CRUD Module
- Roadmap
Framework web Java yang dibangun dari awal sebagai proyek eksplorasi arsitektur. Tujuannya bukan untuk menggantikan Spring Boot, melainkan untuk memahami dan mendemonstrasikan bagaimana sebuah framework enterprise sesungguhnya bekerja — dari HTTP server, routing, DI container, hingga ORM, migration, RBAC, dan WebSocket — dengan menulis setiap komponennya sendiri.
| Kategori | Fitur |
|---|---|
| HTTP | JDK HttpServer + Java 21 Virtual Threads |
| Routing | Exact-match + pattern /:id, CORS, rate limiting |
| Security | JWT di HttpOnly Cookie, RBAC, CSRF, token refresh |
| Middleware | Logging, CORS, Rate Limit, CSRF, Auth, Audit |
| Data | Reflection-based ORM, HikariCP, bulk ops |
| Migration | Versioned DB migration (V001, V002, ...) |
| Profiles | dev / staging / prod environment configs |
| Audit | Auto-log semua write operation per user |
| Export | PDF (OpenPDF), Excel (Apache POI), CSV |
| SMTP via Jakarta Mail (HTML template) | |
| WebSocket | Standalone WS server (Java-WebSocket) |
| File Upload | Multipart parser tanpa dependency eksternal |
| Observability | /health + /metrics format Prometheus |
| Kategori | Library | Alasan |
|---|---|---|
| HTTP Server | JDK com.sun.net.httpserver |
Built-in Java 21, zero dependency |
| Concurrency | Java 21 Virtual Threads | 1 thread per request, non-blocking |
| Connection Pool | HikariCP 5 | Paling efisien, production-proven |
| JSON | Jackson Databind 2.17 | Standard de facto |
| JWT | JJWT 0.12 | Library kecil, API modern |
| Password | jBCrypt 0.4 | Battle-tested, satu class |
| Excel | Apache POI 5.2 | Standard untuk format Office |
| OpenPDF 1.3 | Fork open-source iText 4 | |
| Angus Mail (Jakarta Mail) | Reference implementation | |
| WebSocket | Java-WebSocket 1.5 | Minimal, no servlet container |
| Database (dev) | H2 in-memory | Zero setup, langsung jalan |
| Database (prod) | PostgreSQL 42 | Production-grade RDBMS |
Spring Boot luar biasa produktif, tetapi sebagian besar developer tidak tahu apa yang terjadi di baliknya. Framework ini memaksa pemahaman setiap lapisan secara eksplisit.
@Autowired, @Transactional, @EnableJpaAuditing — semua menyembunyikan logika kompleks. Di framework ini, semua wiring dilakukan eksplisit di App.java dan setiap Module.
Proyek Spring Boot minimal membawa puluhan MB dependency. Framework ini minimalis — hanya library untuk concern yang tidak perlu dibangun ulang.
Generator generate-project.sh membuat skeleton ERP dengan 64 entity di 9 module dalam hitungan detik, menghasilkan struktur yang 100% konsisten.
Eksplisit lebih baik dari implisit — Tidak ada classpath scanning, tidak ada auto-configuration. Setiap komponen didaftarkan manual.
Satu layer, satu tanggung jawab — Controller tidak boleh akses DataSource. Repository tidak tahu ada HTTP di atasnya.
Library, bukan framework atas framework — HikariCP, Jackson, JJWT adalah library murni. Framework ini hanya menjadi perekat antar mereka.
Testability by default — Setiap Service menerima Repository via constructor. Unit test = instantiasi dengan mock, tanpa Spring context.
Modular Monolith dengan Feature-based Packages. Setiap module bisnis berdiri sendiri dalam satu JVM.
┌──────────────────────────────────────────────────────────────────┐
│ CLIENT (Browser / API / Mobile) │
└────────────────────────────┬─────────────────────────────────────┘
│ HTTP Request
▼
┌──────────────────────────────────────────────────────────────────┐
│ WebServer — JDK HttpServer + Java 21 Virtual Threads │
└────────────────────────────┬─────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ MiddlewareChain │
│ LoggingMiddleware → CorsMiddleware → RateLimitMiddleware → │
│ [CsrfMiddleware] → AuthMiddleware → AuditMiddleware │
│ │ │
│ SecurityContext.set(Principal) ◄── JWT cookie "token" │
└────────────────────────────┬─────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Router — exact match first, then pattern /:id │
│ /health /metrics /api/auth/* /api/{module}/{resource}/* │
└────────────────────────────┬─────────────────────────────────────┘
│ matched Handler
▼
┌──────────────────────────────────────────────────────────────────┐
│ Controller — baca Request, panggil UseCase, tulis Response │
│ SecurityContext.requireAuthenticated() / requireRole("ADMIN") │
└────────────────────────────┬─────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Service (implements UseCase) — orchestration + validation │
└────────────────────────────┬─────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Repository (extends BaseRepository<T>) │
│ findById · search · save · saveAll · batchInsert · softDelete │
└────────────────────────────┬─────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ DataSource (HikariCP) → H2 (dev) / PostgreSQL (prod) │
└──────────────────────────────────────────────────────────────────┘
src/
├── main/
│ ├── java/com/company/erp/
│ │ ├── App.java ← Bootstrap: wiring semua komponen
│ │ │
│ │ ├── framework/ ← CORE FRAMEWORK
│ │ │ ├── audit/
│ │ │ │ ├── AuditLog.java ← Entri audit (id, user, method, path, status, ip)
│ │ │ │ └── AuditRepository.java ← Direct JDBC (tidak extends BaseRepository)
│ │ │ ├── config/
│ │ │ │ └── AppConfig.java ← Load base.properties + profile override
│ │ │ ├── container/
│ │ │ │ └── Container.java ← DI registry + circular dep detection
│ │ │ ├── data/
│ │ │ │ ├── annotation/ ← @Entity @Table @Id @Column
│ │ │ │ ├── BaseRepository.java ← JDBC ORM: CRUD + bulk + search + pagination
│ │ │ │ ├── DataSource.java ← HikariCP wrapper + ping + poolStats
│ │ │ │ ├── PageResult.java ← Record: items, total, page, perPage
│ │ │ │ ├── Repository.java ← Interface kontrak (saveAll, batchInsert, dll)
│ │ │ │ └── SchemaRunner.java ← Eksekusi schema.sql (dev mode)
│ │ │ ├── exception/
│ │ │ │ ├── ExceptionHandler.java ← Map exception → HTTP status
│ │ │ │ ├── FrameworkException.java ← Base (400)
│ │ │ │ ├── NotFoundException.java ← 404
│ │ │ │ ├── UnauthorizedException.java ← 401
│ │ │ │ ├── ForbiddenException.java ← 403
│ │ │ │ ├── PayloadTooLargeException.java ← 413
│ │ │ │ └── TooManyRequestsException.java ← 429
│ │ │ ├── http/
│ │ │ │ ├── Handler.java ← @FunctionalInterface: (req,res) throws Exception
│ │ │ │ ├── HttpMethod.java ← Enum: GET POST PUT DELETE PATCH OPTIONS
│ │ │ │ ├── MultipartData.java ← Record: files + fields dari upload
│ │ │ │ ├── MultipartParser.java ← Pure-Java multipart/form-data parser
│ │ │ │ ├── Request.java ← Wrapper: path, query, body, cookie, multipart
│ │ │ │ ├── Response.java ← Builder: status, json, binary, cookie, body
│ │ │ │ ├── Router.java ← Route table + exact/pattern match
│ │ │ │ ├── UploadedFile.java ← Record: fieldName, filename, contentType, content
│ │ │ │ └── WebServer.java ← JDK server + virtual threads + graceful shutdown
│ │ │ ├── mail/
│ │ │ │ └── MailService.java ← SMTP via Jakarta Mail, HTML template
│ │ │ ├── middleware/
│ │ │ │ ├── AuditMiddleware.java ← Log write ops ke audit_logs (setelah auth)
│ │ │ │ ├── CorsMiddleware.java ← CORS headers + preflight
│ │ │ │ ├── CsrfMiddleware.java ← Double-submit cookie (opt-in)
│ │ │ │ ├── LoggingMiddleware.java ← Method + path + status + duration
│ │ │ │ ├── Middleware.java ← Interface: execute(req,res,next)
│ │ │ │ ├── MiddlewareChain.java ← Recursive chain-of-responsibility
│ │ │ │ └── RateLimitMiddleware.java ← Sliding-window per IP
│ │ │ ├── migration/
│ │ │ │ └── MigrationRunner.java ← Versioned migration: V001,V002,...
│ │ │ ├── module/
│ │ │ │ ├── AppModule.java ← Interface: register(container, router)
│ │ │ │ └── ModuleRegistry.java ← Daftar + boot semua module
│ │ │ ├── response/
│ │ │ │ ├── ApiResponse.java ← Record: {success, message, data}
│ │ │ │ └── PageResponse.java ← Record: {data, total, page, perPage, totalPages}
│ │ │ ├── security/
│ │ │ │ ├── annotation/
│ │ │ │ │ └── RequiresRole.java ← Dokumentasi role requirement
│ │ │ │ ├── AuthMiddleware.java ← Baca cookie → parse JWT → set SecurityContext
│ │ │ │ ├── JwtUtil.java ← generate + parse + isValid
│ │ │ │ ├── PasswordHasher.java ← BCrypt wrapper
│ │ │ │ ├── SecurityContext.java ← ThreadLocal<Principal> + requireRole
│ │ │ │ └── SecurityGuard.java ← Route-level RBAC wrapper
│ │ │ ├── util/
│ │ │ │ ├── ExportUtil.java ← toPdfBytes · toExcelBytes · toCsvBytes
│ │ │ │ └── JsonUtil.java ← Jackson wrapper
│ │ │ ├── validation/
│ │ │ │ ├── annotation/ ← @NotBlank @NotNull @Size @Email
│ │ │ │ ├── ValidationException.java ← Map<String, List<String>> errors
│ │ │ │ └── Validator.java ← Reflect fields + check annotations
│ │ │ └── websocket/
│ │ │ └── WsHandler.java ← Abstract base (extends Java-WebSocket)
│ │ │
│ │ ├── shared/
│ │ │ └── entity/BaseEntity.java ← id, createdAt, updatedAt, deleted
│ │ │
│ │ └── modules/
│ │ ├── auth/ ← Manual (tidak di-generate)
│ │ │ ├── AuthModule.java ← login, register, logout, me, refresh
│ │ │ ├── controller/AuthController.java
│ │ │ ├── dto/ ← LoginRequest, RegisterRequest, UserResponse
│ │ │ ├── entity/{User, Role}.java
│ │ │ ├── repository/UserRepository.java
│ │ │ └── service/{AuthService, DataInitializer}.java
│ │ ├── system/ ← Framework observability
│ │ │ ├── HealthController.java ← /health · /metrics · /api/system/health · /api/system/audit
│ │ │ └── SystemModule.java
│ │ ├── ws/
│ │ │ └── ChatWsHandler.java ← Contoh WebSocket handler
│ │ └── {module}/ ← Di-generate
│ │ ├── {Module}Module.java ← Wiring semua entity
│ │ └── {entity}/
│ │ ├── {Entity}.java
│ │ ├── {Entity}UseCase.java
│ │ ├── {Entity}Service.java
│ │ ├── {Entity}Repository.java
│ │ ├── {Entity}Mapper.java
│ │ ├── {Entity}Controller.java
│ │ └── dto/
│ │ ├── {Entity}Request.java
│ │ └── {Entity}Response.java
│ │
│ └── resources/
│ ├── application.properties ← Base config
│ ├── application-dev.properties ← H2 in-memory
│ ├── application-staging.properties ← PostgreSQL staging
│ ├── application-prod.properties ← PostgreSQL prod (env vars)
│ ├── schema.sql ← DDL untuk SchemaRunner (dev)
│ └── migrations/
│ ├── list.txt ← Daftar migration files (urut)
│ ├── V001__create_base_tables.sql
│ └── V002__create_audit_log.sql
│
└── test/
└── java/com/company/erp/
└── modules/auth/AuthServiceTest.java
Membungkus com.sun.net.httpserver.HttpServer dari JDK. Setiap request dijalankan di Java 21 Virtual Thread — ringan, satu thread per request, model pemrograman synchronous.
Fitur:
AtomicInteger activeRequests— track in-flight requests untuk graceful shutdownAtomicLong totalRequests— counter untuk Prometheus metricsstop(int timeoutSeconds)— tunggu request selesai sebelum mati
// Graceful shutdown otomatis via shutdown hook di App.java
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
server.stop(30); // tunggu 30 detik
dataSource.close();
}));Strategi matching dua tahap:
- Exact match — path literal, selalu dicek lebih dulu
- Pattern match — segment dengan
:param
Ini menghindari konflik antara /export/pdf dan /:id — route export selalu menang karena didaftarkan sebelum /:id dan exact match diprioritaskan oleh router.
Request membaca body lazy (di-cache setelah pertama kali). Ukuran body dibatasi 10 MB untuk mencegah OOM attack:
// Parsing multipart/form-data upload
MultipartData data = req.getMultipart();
UploadedFile file = data.file("photo").orElseThrow();
String name = data.field("name", "");
byte[] content = file.content();Response adalah builder — tidak menulis ke socket sampai WebServer memanggil send() di blok finally. Controller hanya menyiapkan response, tidak mengirimnya.
Registry sederhana Map<Class<?>, Object> dengan circular dependency detection:
// Jika A → B → A, Container melempar:
// [Container] Circular dependency: A -> B -> ADeteksi menggunakan LinkedHashSet<Class<?>> yang menyimpan "sedang di-resolve". Jika kelas yang sama muncul lagi saat resolusi sedang berjalan, terdeteksi sebagai siklus.
Pattern chain-of-responsibility rekursif:
Logging → CORS → RateLimit → [CSRF] → Auth → Audit → Handler
Urutan penting:
AuthMiddlewaresebelumAuditMiddleware— supaya SecurityContext sudah terisi saat audit dijalankanRateLimitMiddlewaresebelum Auth — blok dulu sebelum proses JWT
1. JDK HttpServer → virtual thread baru
2. WebServer: activeRequests++, buat Request + Response
3. MiddlewareChain:
a. LoggingMiddleware → catat start time
b. CorsMiddleware → set CORS headers
c. RateLimitMiddleware → cek sliding window per IP → 429 jika melebihi
d. [CsrfMiddleware] → set/validate csrf_token cookie (opt-in)
e. AuthMiddleware → baca cookie "token" → parse JWT → SecurityContext.set(principal)
f. AuditMiddleware → jalankan handler, lalu log jika write + auth + sukses
4. Router.match(path, method)
→ exact match dulu, lalu pattern match
→ NotFoundException jika tidak ditemukan
5. Controller → SecurityContext.requireAuthenticated()
→ panggil useCase.method(...)
6. Service → Validator.validate(request) → repository.save(entity)
7. BaseRepository → JDBC PreparedStatement → HikariCP → Database
8. Response.json(data) → siapkan byte[] di memory
9. WebServer finally: res.send() → tulis ke socket
10. AuditMiddleware finally: simpan AuditLog ke DB (non-fatal)
11. AuthMiddleware finally: SecurityContext.clear()
12. LoggingMiddleware: log "GET /api/... 200 12ms"
13. WebServer: activeRequests--
Wiring sepenuhnya eksplisit — tidak ada classpath scanning, tidak ada @Autowired.
// App.java
AppConfig config = AppConfig.load();
DataSource dataSource = DataSource.create(config);
JwtUtil jwtUtil = new JwtUtil(config.getJwtSecret(), config.getJwtExpirationMs());
PasswordHasher hasher = new PasswordHasher();
MailService mailService = new MailService(config);
AuditRepository auditRepo = new AuditRepository(dataSource);
Container container = new Container();
container.register(AppConfig.class, config);
container.register(DataSource.class, dataSource);
container.register(JwtUtil.class, jwtUtil);
container.register(MailService.class, mailService);
container.register(AuditRepository.class, auditRepo);Di Module, dependency diambil dari container dan disusun manual:
public class FinanceModule implements AppModule {
@Override
public void register(Container container, Router router) {
DataSource ds = container.get(DataSource.class);
AccountRepository accountRepo = new AccountRepository(ds);
AccountService accountSvc = new AccountService(accountRepo);
AccountController accountCtrl = new AccountController(accountSvc);
router.get("/api/finance/accounts/:id", accountCtrl::getById);
router.post("/api/finance/accounts", accountCtrl::create);
// ...
}
}| Method | Path | Handler |
|---|---|---|
GET |
/api/{module}/{resource} |
list — paginasi + filter |
GET |
/api/{module}/{resource}/:id |
getById |
POST |
/api/{module}/{resource} |
create |
PUT |
/api/{module}/{resource}/:id |
update |
DELETE |
/api/{module}/{resource}/:id |
delete (soft) |
GET |
/api/{module}/{resource}/export/pdf |
|
GET |
/api/{module}/{resource}/export/excel |
Excel |
GET |
/api/{module}/{resource}/export/csv |
CSV |
GET /api/finance/accounts?page=0&perPage=10&sortBy=name&direction=asc&keyword=kas
JWT di localStorage rentan XSS — script yang diinjeksikan bisa mencuri token. HttpOnly cookie tidak bisa diakses JavaScript sama sekali. Browser mengirimkannya otomatis.
Set-Cookie: token=eyJhbGc...; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400
SameSite=Strict juga mitigasi CSRF untuk browser modern — cookie hanya dikirim pada same-origin request.
| Method | Path | Keterangan |
|---|---|---|
POST |
/api/auth/register |
Buat akun, set cookie |
POST |
/api/auth/login |
Login, set cookie |
POST |
/api/auth/logout |
Hapus cookie (Max-Age=0) |
GET |
/api/auth/me |
Info user dari SecurityContext |
POST |
/api/auth/refresh |
Perpanjang sesi, perbarui cookie |
Tiga cara menggunakan RBAC di framework ini:
public void deleteUser(Request req, Response res) {
SecurityContext.requireRole("ADMIN"); // throw 403 jika bukan ADMIN
userService.delete(req.getPathVar("id"));
res.json(ApiResponse.success("Deleted", null));
}
public void approveRequest(Request req, Response res) {
SecurityContext.requireAnyRole("ADMIN", "MANAGER"); // salah satu cukup
// ...
}// Hanya ADMIN yang bisa DELETE
router.delete("/api/finance/accounts/:id",
SecurityGuard.role("ADMIN", accountCtrl::delete));
// ADMIN atau MANAGER bisa CREATE
router.post("/api/finance/invoices",
SecurityGuard.anyRole(invoiceCtrl::create, "ADMIN", "MANAGER"));SecurityGuard membungkus handler dengan pemeriksaan role sebelum handler dieksekusi.
@RequiresRole("ADMIN")
public void deleteUser(Request req, Response res) { ... }@RequiresRole adalah annotation dokumentasi — IDE dan reviewer tahu method ini butuh role tertentu. Enforcement tetap dilakukan via SecurityContext.requireRole().
| Role | Deskripsi |
|---|---|
ADMIN |
Akses penuh |
MANAGER |
Bisa approve dan manage |
STAFF |
Default untuk user baru |
Diimplementasikan dengan double-submit cookie pattern.
Cara kerja:
- Server set cookie
csrf_token(bukan HttpOnly — bisa dibaca JS) - JavaScript membaca cookie dan mengirimnya di header
X-CSRF-Token - Server membandingkan: header == cookie → valid
Catatan: Karena SameSite=Strict sudah ada di JWT cookie, CSRF protection bersifat defense-in-depth. Aktifkan untuk browser clients (SPA), tidak perlu untuk mobile API:
# application.properties
csrf.enabled=true// Frontend JavaScript
const csrfToken = document.cookie
.split('; ')
.find(r => r.startsWith('csrf_token='))
?.split('=')[1];
fetch('/api/finance/accounts', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Kas Besar' })
});JWT memiliki expiry. Daripada memaksa user login ulang, gunakan endpoint refresh untuk memperbarui cookie secara silent:
# Client memanggil ini secara berkala (misal: setiap 23 jam)
curl -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/auth/refreshAuthService.refresh() memvalidasi token lama dan menerbitkan token baru dengan klaim yang sama. Cookie baru otomatis di-set di response.
Strategi refresh di frontend:
// Panggil refresh saat aplikasi dimuat dan setiap beberapa jam
async function refreshToken() {
try {
await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
} catch (e) {
// Token invalid → redirect ke login
window.location.href = '/login';
}
}
// Auto-refresh setiap 23 jam
setInterval(refreshToken, 23 * 60 * 60 * 1000);Reflection dijalankan sekali di constructor, bukan setiap query:
// Saat AccountRepository dibuat:
// 1. Baca @Table("accounts") → tableName = "accounts"
// 2. Scan semua field dengan @Column → fieldMappings = [{name, "name"}, {description, "description"}, ...]Semua query dibangun dinamis dari fieldMappings. Developer tidak perlu menulis SQL untuk operasi standar.
@Entity
@Table("products")
public class Product extends BaseEntity {
@Column("name") private String name;
@Column("price") private java.math.BigDecimal price;
@Column("stock_qty") private Integer stockQty;
@Column("is_active") private Boolean isActive = true;
// getter dan setter untuk setiap field
}Semua delete tidak menghapus data fisik. is_deleted = true dan deleted_at diisi. Semua query search() dan findById() otomatis menyertakan WHERE is_deleted = false.
public class ProductRepository extends BaseRepository<Product> {
public ProductRepository(DataSource ds) { super(ds, Product.class); }
// Custom query menggunakan cols() dan map() yang inherited dari BaseRepository
public List<Product> findByCategoryId(String categoryId) {
String sql = "SELECT " + cols() + " FROM products " +
"WHERE category_id = ? AND is_deleted = false";
try (Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, categoryId);
ResultSet rs = ps.executeQuery();
List<Product> list = new ArrayList<>();
while (rs.next()) list.add(map(rs));
return list;
} catch (Exception e) {
throw new RuntimeException("findByCategoryId failed", e);
}
}
}MigrationRunner adalah sistem migration versi sederhana — mirip Flyway tapi dibangun sendiri.
- Membuat tabel
schema_migrationsjika belum ada - Membaca
migrations/list.txtdari classpath - Untuk setiap file yang belum diaplikasikan: eksekusi SQL + catat versinya
- Urutan dijamin oleh sort alfabetis nama file (
V001<V002<V003)
src/main/resources/migrations/
├── list.txt ← wajib ada, satu file per baris
├── V001__create_base_tables.sql
├── V002__create_audit_log.sql
└── V003__add_product_category.sql ← tambahkan di sini
migrations/list.txt:
V001__create_base_tables.sql
V002__create_audit_log.sql
V003__add_product_category.sql
-- V003__add_product_category.sql
ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id VARCHAR(36);
CREATE INDEX IF NOT EXISTS idx_product_category ON products (category_id);Kemudian daftarkan di list.txt. Migrasi yang sudah diaplikasikan tidak akan dijalankan ulang.
# application-dev.properties → SchemaRunner (cepat, tidak track versi)
db.migration=false
# application-prod.properties → MigrationRunner (versioned, aman untuk prod)
db.migration=trueProfile di-load berdasarkan env var APP_PROFILE (default: dev):
export APP_PROFILE=prod
mvn compile exec:javaAppConfig load base application.properties lalu merge application-{profile}.properties di atasnya. Property di file profile override base.
application-dev.properties (default):
db.url=jdbc:h2:mem:erpdb;...
db.migration=false
mail.enabled=falseapplication-staging.properties:
db.url=jdbc:postgresql://localhost:5432/erpdb_staging
db.migration=true
mail.enabled=trueapplication-prod.properties:
# Semua nilai sensitif dari env var: DB_URL, DB_USER, DB_PASSWORD, JWT_SECRET
db.migration=true
shutdown.timeout.seconds=60Untuk production, jangan simpan password di file .properties. Gunakan environment variable:
export DB_URL=jdbc:postgresql://prod-db:5432/erpdb
export DB_USER=erp_app
export DB_PASSWORD=super-secret
export JWT_SECRET=256-bit-random-key
export MAIL_USERNAME=sender@company.com
export MAIL_PASSWORD=app-specific-passwordAppConfig membaca env var dengan prioritas lebih tinggi dari properties file.
Untuk operasi massal, BaseRepository menyediakan tiga method tambahan:
List<Product> products = loadFromCsv("products.csv");
List<Product> saved = productRepository.saveAll(products);Cocok untuk batch kecil (< 100 item). Setiap item memanggil save() individual.
// Untuk insert massal performa tinggi (ratusan / ribuan record)
List<Product> newProducts = prepareProducts();
productRepository.batchInsert(newProducts);
// → Semua INSERT dalam satu JDBC batch, satu transaction, satu roundtripJauh lebih cepat dari saveAll untuk data besar. Semua entity harus baru (tidak boleh ada id yang sudah ada).
List<String> expiredIds = getExpiredProductIds();
productRepository.softDeleteAll(expiredIds);
// → UPDATE products SET is_deleted=true WHERE id IN (?,?,?,...)Satu query SQL, jauh lebih efisien dari loop softDelete().
AuditMiddleware berjalan setelah AuthMiddleware sehingga memiliki akses ke SecurityContext.
Apa yang di-log:
- Semua request POST / PUT / DELETE / PATCH
- Hanya jika pengguna terautentikasi
- Hanya jika response sukses (status < 400)
Melihat audit log:
# Requires ADMIN role
curl -b cookies.txt "http://localhost:8080/api/system/audit?limit=100"Response:
{
"success": true,
"data": [
{
"id": "550e8400-...",
"userId": "user-123",
"userEmail": "admin@example.com",
"method": "DELETE",
"path": "/api/finance/accounts/abc",
"status": 200,
"ip": "192.168.1.1",
"createdAt": "2025-01-15T10:30:00"
}
]
}Catatan desain: AuditMiddleware tidak melempar exception jika gagal menyimpan log. Kegagalan audit tidak boleh menggagalkan operasi utama.
| Endpoint | Auth | Format | Deskripsi |
|---|---|---|---|
GET /health |
❌ Publik | JSON | Untuk load balancer / liveness probe |
GET /metrics |
❌ Publik | Prometheus text | Untuk Prometheus scraper |
GET /api/system/health |
✅ Auth | JSON | Detail: DB pool, memory, uptime |
GET /api/system/audit |
✅ ADMIN | JSON | Recent audit log entries |
curl http://localhost:8080/health{ "success": true, "data": { "status": "UP", "timestamp": "2025-01-15T10:30:00" } }Return 503 jika database unreachable (berguna untuk Kubernetes readiness probe).
curl http://localhost:8080/metrics# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total 1234
# HELP http_active_requests Active HTTP requests
# TYPE http_active_requests gauge
http_active_requests 3
# HELP jvm_memory_used_bytes JVM used memory
# TYPE jvm_memory_used_bytes gauge
jvm_memory_used_bytes 268435456
# HELP db_pool_active Active DB connections
# TYPE db_pool_active gauge
db_pool_active 2
# HELP db_pool_waiting Threads awaiting connection
# TYPE db_pool_waiting gauge
db_pool_waiting 0
Konfigurasi Prometheus (prometheus.yml):
scrape_configs:
- job_name: erp-app
static_configs:
- targets: ['localhost:8080']
metrics_path: /metrics
scrape_interval: 15sWebSocket berjalan di port terpisah dari HTTP server (JDK HttpServer tidak support protocol upgrade).
Di App.java, uncomment satu baris:
// Aktifkan WebSocket pada port 8081
new com.company.erp.modules.ws.ChatWsHandler(8081).startAsync();public class NotificationWsHandler extends WsHandler {
public NotificationWsHandler(int port) { super(port); }
@Override
public void onOpen(WebSocket conn, ClientHandshake h) {
// Bisa cek token dari query parameter: ws://localhost:8081?token=xxx
String token = h.getResourceDescriptor(); // path + query
System.out.println("[Notification] Client connected: " + conn.getRemoteSocketAddress());
}
@Override
public void onMessage(WebSocket conn, String message) {
// Echo atau broadcast
broadcastMessage("{\"type\":\"message\",\"data\":\"" + message + "\"}");
}
@Override
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
System.out.println("[Notification] Client disconnected");
}
}const ws = new WebSocket('ws://localhost:8081');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
};
ws.onopen = () => ws.send('Hello, server!');Framework menyertakan parser multipart/form-data yang ditulis dari awal tanpa dependency eksternal.
public void uploadAvatar(Request req, Response res) throws Exception {
SecurityContext.requireAuthenticated();
MultipartData data = req.getMultipart();
UploadedFile photo = data.file("photo")
.orElseThrow(() -> new FrameworkException("Field 'photo' tidak ditemukan"));
String name = data.field("name", "unknown");
// Validasi
if (photo.size() > 5 * 1024 * 1024)
throw new PayloadTooLargeException("File maksimal 5 MB");
if (!photo.contentType().startsWith("image/"))
throw new FrameworkException("Hanya file gambar yang diizinkan");
// Simpan ke disk
String userId = SecurityContext.get().id();
Path dest = Path.of("uploads/" + userId + "_" + photo.filename());
Files.write(dest, photo.content());
res.json(ApiResponse.success("Upload berhasil", Map.of("path", dest.toString())));
}router.post("/api/users/avatar", userCtrl::uploadAvatar);<form method="POST" action="/api/users/avatar" enctype="multipart/form-data">
<input type="text" name="name">
<input type="file" name="photo" accept="image/*">
<button type="submit">Upload</button>
</form>curl -b cookies.txt -X POST http://localhost:8080/api/users/avatar \
-F "name=John Doe" \
-F "photo=@/path/to/avatar.jpg"Dikonfigurasi via application.properties. Default mail.enabled=false — aman untuk development.
mail.enabled=true
mail.from=noreply@company.com
mail.smtp.host=smtp.gmail.com
mail.smtp.port=587
mail.smtp.username=your-gmail@gmail.com
mail.smtp.password=xxxx-xxxx-xxxx-xxxx # App Password (bukan password akun)
mail.smtp.auth=true
mail.smtp.starttls=trueSetup Gmail App Password:
- Aktifkan 2FA di akun Google
- Buka
myaccount.google.com/apppasswords - Generate 16-karakter app password
- Gunakan sebagai
mail.smtp.password
mailService.send(
"recipient@example.com",
"Laporan Bulanan — Januari 2025",
"<h2>Laporan Keuangan</h2><p>Terlampir laporan bulan Januari...</p>"
);Kegagalan pengiriman email tidak melempar exception ke caller. Error hanya di-log ke stderr. Operasi utama (register, dsb.) tetap berhasil meski email gagal dikirim.
Ilustrasi: tambah module Logistics.
MODULES["Logistics"]="
shipments:Shipment
carriers:Carrier
shipping_routes:ShippingRoute
"Jalankan ./generate-project.sh. Semua file skeleton langsung terbuat.
import com.company.erp.modules.logistics.LogisticsModule;
// Di dalam main():
modules.register(new LogisticsModule());Buat src/main/resources/migrations/V003__add_logistics.sql:
CREATE TABLE IF NOT EXISTS shipments (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
tracking_no VARCHAR(100),
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
-- ... kolom bisnis lainnya
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP, is_deleted BOOLEAN NOT NULL DEFAULT FALSE
);Daftarkan di migrations/list.txt.
Ilustrasi: tambah Warehouse ke module Inventory secara manual.
// 1. Entity
@Entity @Table("warehouses")
public class Warehouse extends BaseEntity {
@Column("name") private String name;
@Column("city") private String city;
@Column("capacity") private Integer capacity;
@Column("is_active") private Boolean isActive = true;
// getter/setter
}
// 2. Repository
public class WarehouseRepository extends BaseRepository<Warehouse> {
public WarehouseRepository(DataSource ds) { super(ds, Warehouse.class); }
}
// 3. Service — implements WarehouseUseCase (buat interface-nya juga)
// 4. Daftarkan di InventoryModule
WarehouseRepository warehouseRepo = new WarehouseRepository(ds);
WarehouseService warehouseSvc = new WarehouseService(warehouseRepo);
WarehouseController warehouseCtrl = new WarehouseController(warehouseSvc);
router.get("/api/inventory/warehouses/export/pdf", warehouseCtrl::exportPdf);
router.get("/api/inventory/warehouses/:id", warehouseCtrl::getById);
router.get("/api/inventory/warehouses", warehouseCtrl::list);
router.post("/api/inventory/warehouses", warehouseCtrl::create);
router.put("/api/inventory/warehouses/:id", warehouseCtrl::update);
router.delete("/api/inventory/warehouses/:id",
SecurityGuard.role("ADMIN", warehouseCtrl::delete)); // contoh RBAC- Java 21+ (
java -version) - Maven 3.8+ (
mvn -version)
chmod +x generate-project.sh
./generate-project.sh
mvn compile exec:javaServer : http://localhost:8080
Database : H2 in-memory (auto reset setiap restart)
H2 Console : http://localhost:8080/h2-console
JDBC URL : jdbc:h2:mem:erpdb
Migration : SchemaRunner (schema.sql)
Mail : DISABLED
Profile : dev
# Health check
curl http://localhost:8080/health
# Login
curl -c jar.txt -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"password"}'
# List data (gunakan cookie)
curl -b jar.txt http://localhost:8080/api/finance/accounts
# Metrics
curl http://localhost:8080/metricsmvn package
java -jar target/erp-app-1.0.0-SNAPSHOT.jarexport APP_PROFILE=prod
export DB_URL=jdbc:postgresql://prod-db:5432/erpdb
export DB_USER=erp_app
export DB_PASSWORD=secret
export JWT_SECRET=256-bit-random-secret
java -jar target/erp-app-1.0.0-SNAPSHOT.jarmvn test # semua test
mvn test -Dtest=AuthServiceTest # test spesifikTidak perlu Spring context — instantiasi langsung dengan mock:
@ExtendWith(MockitoExtension.class)
class AccountServiceTest {
@Mock AccountRepository repository;
AccountService service;
@BeforeEach
void setUp() { service = new AccountService(repository); }
@Test
void create_shouldValidateAndSave() {
AccountRequest req = new AccountRequest();
req.setName("Kas Kecil");
Account entity = new Account(); entity.setId("1"); entity.setName("Kas Kecil");
when(repository.save(any())).thenReturn(entity);
AccountResponse resp = service.create(req);
assertThat(resp.getName()).isEqualTo("Kas Kecil");
verify(repository, times(1)).save(any());
}
}curl -c jar.txt -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"password"}' | python3 -m json.tool# Create
curl -b jar.txt -s -X POST http://localhost:8080/api/crm/contacts \
-H "Content-Type: application/json" \
-d '{"name":"Budi Santoso","description":"Lead baru"}' | python3 -m json.tool
# List dengan filter
curl -b jar.txt -s "http://localhost:8080/api/crm/contacts?keyword=budi&page=0&perPage=5"
# Get by ID
curl -b jar.txt -s http://localhost:8080/api/crm/contacts/{id}
# Update
curl -b jar.txt -s -X PUT http://localhost:8080/api/crm/contacts/{id} \
-H "Content-Type: application/json" \
-d '{"name":"Budi Santoso","description":"Prospek hot","isActive":true}'
# Delete (soft)
curl -b jar.txt -s -X DELETE http://localhost:8080/api/crm/contacts/{id}
# Export
curl -b jar.txt -o contacts.pdf http://localhost:8080/api/crm/contacts/export/pdf
curl -b jar.txt -o contacts.xlsx http://localhost:8080/api/crm/contacts/export/excel
curl -b jar.txt -o contacts.csv http://localhost:8080/api/crm/contacts/export/csvcurl -b jar.txt -c jar.txt -s -X POST http://localhost:8080/api/auth/refreshcurl -b jar.txt -s "http://localhost:8080/api/system/audit?limit=20" | python3 -m json.toolSukses:
{ "success": true, "message": "Success", "data": { ... } }Paginasi:
{ "success": true, "data": { "data": [...], "total": 42, "page": 0, "perPage": 10, "totalPages": 5 } }Error validasi (422):
{ "success": false, "message": "Validation failed", "data": { "name": ["must not be blank"] } }Rate limited (429):
{ "success": false, "message": "Too many requests — retry in 60s" }- Circular dependency detection di Container
- Request body size limit (10 MB hard limit)
- Connection pool monitoring — HikariCP MXBean
- Graceful shutdown — tunggu in-flight requests
- Rate limiting middleware — sliding window per IP
- Database migration — MigrationRunner (V001, V002, ...)
- Environment profiles — dev / staging / prod
- Bulk insert/update —
batchInsert()+softDeleteAll() - Role-based access control —
SecurityGuard+requireRole() - CSRF protection — double-submit cookie (opt-in)
- Token refresh —
POST /api/auth/refresh - Audit log — write ops per user ke
audit_logs - Health check —
GET /health(public) +GET /api/system/health(detailed) - Prometheus metrics —
GET /metricsformat standar - WebSocket support —
WsHandlerabstract base +ChatWsHandlercontoh - File upload —
MultipartParserpure Java +Request.getMultipart()
- Join support —
@JoinColumnannotation - Transactions —
BaseRepository.withTransaction(Runnable) - Soft delete filter bypass — untuk admin endpoint yang perlu lihat data dihapus
- Full-text search — per-field keyword filter selain
name - Pagination cursor-based — alternatif offset untuk dataset besar
- Hot reload — restart otomatis saat file berubah
- Route inspector —
GET /_routeslist semua route terdaftar - Request/Response logging yang bisa dikonfigurasi level dan formatnya
- OpenAPI / Swagger — generate spec dari route registrations
- Distributed tracing — trace ID per request, propagasi ke downstream
- Inter-module event bus — publish/subscribe tanpa direct coupling
- Scheduled tasks —
@Cronannotation tanpa Quartz - Circuit breaker — untuk downstream HTTP calls
- Multi-tenancy — tenant isolation via request header
MIT License — bebas digunakan, dimodifikasi, dan didistribusikan.
Pull request disambut baik. Untuk perubahan besar, buka issue terlebih dahulu.
Pastikan test tetap passing: mvn test
Dibuat dengan ☕ dan rasa ingin tahu yang tidak kunjung padam.
"Cara terbaik memahami sebuah framework adalah membangunnya sendiri."