Skip to content

ilhamrhmtkbr/ilhamrhmtkbr-framework-java-native-api

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 

Repository files navigation

☕ Framework

Framework Java enterprise buatan sendiri — tanpa Spring, tanpa Jakarta EE, tanpa magic.

Java Build License Status


Daftar Isi

  1. Tentang Project
  2. Masalah yang Diselesaikan
  3. Filosofi Desain
  4. Arsitektur
  5. Struktur Folder
  6. Framework Components
  7. Request Lifecycle
  8. Dependency Injection
  9. Routing
  10. Authentication & Session (JWT HttpOnly Cookie)
  11. RBAC — Role-Based Access Control
  12. CSRF Protection
  13. Token Refresh
  14. Data Layer / ORM
  15. Database Migration
  16. Environment Profiles
  17. Bulk Operations
  18. Audit Log
  19. Health Check & Prometheus Metrics
  20. WebSocket
  21. File Upload (Multipart)
  22. Email / Mail Service
  23. Membuat Module Baru
  24. Membuat Entity Baru
  25. Menjalankan Project
  26. Menjalankan Test
  27. Contoh Lengkap CRUD Module
  28. Roadmap

Tentang Project

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.

Fitur Utama

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
Email SMTP via Jakarta Mail (HTML template)
WebSocket Standalone WS server (Java-WebSocket)
File Upload Multipart parser tanpa dependency eksternal
Observability /health + /metrics format Prometheus

Stack Teknis

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
PDF OpenPDF 1.3 Fork open-source iText 4
Mail 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

Masalah yang Diselesaikan

1. Framework sebagai black box

Spring Boot luar biasa produktif, tetapi sebagian besar developer tidak tahu apa yang terjadi di baliknya. Framework ini memaksa pemahaman setiap lapisan secara eksplisit.

2. Annotation magic yang tidak transparan

@Autowired, @Transactional, @EnableJpaAuditing — semua menyembunyikan logika kompleks. Di framework ini, semua wiring dilakukan eksplisit di App.java dan setiap Module.

3. Dependency bloat

Proyek Spring Boot minimal membawa puluhan MB dependency. Framework ini minimalis — hanya library untuk concern yang tidak perlu dibangun ulang.

4. Boilerplate tidak terkontrol

Generator generate-project.sh membuat skeleton ERP dengan 64 entity di 9 module dalam hitungan detik, menghasilkan struktur yang 100% konsisten.


Filosofi Desain

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.


Arsitektur

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)          │
└──────────────────────────────────────────────────────────────────┘

Struktur Folder

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

Framework Components

WebServer

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 shutdown
  • AtomicLong totalRequests — counter untuk Prometheus metrics
  • stop(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();
}));

Router

Strategi matching dua tahap:

  1. Exact match — path literal, selalu dicek lebih dulu
  2. 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 & Response

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.


Container

Registry sederhana Map<Class<?>, Object> dengan circular dependency detection:

// Jika A → B → A, Container melempar:
// [Container] Circular dependency: A -> B -> A

Deteksi menggunakan LinkedHashSet<Class<?>> yang menyimpan "sedang di-resolve". Jika kelas yang sama muncul lagi saat resolusi sedang berjalan, terdeteksi sebagai siklus.


MiddlewareChain

Pattern chain-of-responsibility rekursif:

Logging → CORS → RateLimit → [CSRF] → Auth → Audit → Handler

Urutan penting:

  • AuthMiddleware sebelum AuditMiddleware — supaya SecurityContext sudah terisi saat audit dijalankan
  • RateLimitMiddleware sebelum Auth — blok dulu sebelum proses JWT

Request Lifecycle

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

Dependency Injection

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

Routing

Endpoint standar per entity

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 PDF
GET /api/{module}/{resource}/export/excel Excel
GET /api/{module}/{resource}/export/csv CSV

Query parameters list

GET /api/finance/accounts?page=0&perPage=10&sortBy=name&direction=asc&keyword=kas

Authentication & Session

Mengapa HttpOnly Cookie?

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.

Endpoint auth

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

RBAC — Role-Based Access Control

Tiga cara menggunakan RBAC di framework ini:

1. Di Controller (method level)

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
    // ...
}

2. Di Module saat registrasi route (route level)

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

3. Dokumentasi via annotation

@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 yang tersedia

Role Deskripsi
ADMIN Akses penuh
MANAGER Bisa approve dan manage
STAFF Default untuk user baru

CSRF Protection

Diimplementasikan dengan double-submit cookie pattern.

Cara kerja:

  1. Server set cookie csrf_token (bukan HttpOnly — bisa dibaca JS)
  2. JavaScript membaca cookie dan mengirimnya di header X-CSRF-Token
  3. 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' })
});

Token Refresh

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/refresh

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

Data Layer / ORM

Cara kerja BaseRepository

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.

Mendefinisikan Entity

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

Soft Delete

Semua delete tidak menghapus data fisik. is_deleted = true dan deleted_at diisi. Semua query search() dan findById() otomatis menyertakan WHERE is_deleted = false.

Custom Query

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

Database Migration

MigrationRunner adalah sistem migration versi sederhana — mirip Flyway tapi dibangun sendiri.

Cara kerja

  1. Membuat tabel schema_migrations jika belum ada
  2. Membaca migrations/list.txt dari classpath
  3. Untuk setiap file yang belum diaplikasikan: eksekusi SQL + catat versinya
  4. Urutan dijamin oleh sort alfabetis nama file (V001 < V002 < V003)

Struktur file migrasi

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

Menulis migrasi baru

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

Konfigurasi

# application-dev.properties  → SchemaRunner (cepat, tidak track versi)
db.migration=false

# application-prod.properties → MigrationRunner (versioned, aman untuk prod)
db.migration=true

Environment Profiles

Profile di-load berdasarkan env var APP_PROFILE (default: dev):

export APP_PROFILE=prod
mvn compile exec:java

AppConfig load base application.properties lalu merge application-{profile}.properties di atasnya. Property di file profile override base.

File profile yang di-generate

application-dev.properties (default):

db.url=jdbc:h2:mem:erpdb;...
db.migration=false
mail.enabled=false

application-staging.properties:

db.url=jdbc:postgresql://localhost:5432/erpdb_staging
db.migration=true
mail.enabled=true

application-prod.properties:

# Semua nilai sensitif dari env var: DB_URL, DB_USER, DB_PASSWORD, JWT_SECRET
db.migration=true
shutdown.timeout.seconds=60

Menyimpan secret dengan aman

Untuk 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-password

AppConfig membaca env var dengan prioritas lebih tinggi dari properties file.


Bulk Operations

Untuk operasi massal, BaseRepository menyediakan tiga method tambahan:

saveAll — loop sederhana

List<Product> products = loadFromCsv("products.csv");
List<Product> saved = productRepository.saveAll(products);

Cocok untuk batch kecil (< 100 item). Setiap item memanggil save() individual.

batchInsert — JDBC batch dalam satu transaksi

// Untuk insert massal performa tinggi (ratusan / ribuan record)
List<Product> newProducts = prepareProducts();
productRepository.batchInsert(newProducts);
// → Semua INSERT dalam satu JDBC batch, satu transaction, satu roundtrip

Jauh lebih cepat dari saveAll untuk data besar. Semua entity harus baru (tidak boleh ada id yang sudah ada).

softDeleteAll — satu UPDATE untuk banyak id

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


Audit Log

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.


Health Check & Prometheus Metrics

Endpoint

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

Health check sederhana

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

Prometheus metrics

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: 15s

WebSocket

WebSocket berjalan di port terpisah dari HTTP server (JDK HttpServer tidak support protocol upgrade).

Mengaktifkan WebSocket

Di App.java, uncomment satu baris:

// Aktifkan WebSocket pada port 8081
new com.company.erp.modules.ws.ChatWsHandler(8081).startAsync();

Membuat WsHandler kustom

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

Koneksi dari client

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!');

File Upload (Multipart)

Framework menyertakan parser multipart/form-data yang ditulis dari awal tanpa dependency eksternal.

Controller untuk upload

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

Registrasi route

router.post("/api/users/avatar", userCtrl::uploadAvatar);

Client (HTML form)

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

Client (curl)

curl -b cookies.txt -X POST http://localhost:8080/api/users/avatar \
  -F "name=John Doe" \
  -F "photo=@/path/to/avatar.jpg"

Email / Mail Service

Dikonfigurasi via application.properties. Default mail.enabled=false — aman untuk development.

Konfigurasi Gmail

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=true

Setup Gmail App Password:

  1. Aktifkan 2FA di akun Google
  2. Buka myaccount.google.com/apppasswords
  3. Generate 16-karakter app password
  4. Gunakan sebagai mail.smtp.password

Mengirim email custom

mailService.send(
    "recipient@example.com",
    "Laporan Bulanan — Januari 2025",
    "<h2>Laporan Keuangan</h2><p>Terlampir laporan bulan Januari...</p>"
);

Email non-fatal

Kegagalan pengiriman email tidak melempar exception ke caller. Error hanya di-log ke stderr. Operasi utama (register, dsb.) tetap berhasil meski email gagal dikirim.


Membuat Module Baru

Ilustrasi: tambah module Logistics.

Langkah 1 — Tambahkan di generator

MODULES["Logistics"]="
shipments:Shipment
carriers:Carrier
shipping_routes:ShippingRoute
"

Jalankan ./generate-project.sh. Semua file skeleton langsung terbuat.

Langkah 2 — Daftarkan di App.java

import com.company.erp.modules.logistics.LogisticsModule;

// Di dalam main():
modules.register(new LogisticsModule());

Langkah 3 — Tambahkan migrasi

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.


Membuat Entity Baru

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

Menjalankan Project

Prasyarat

  • Java 21+ (java -version)
  • Maven 3.8+ (mvn -version)

Generate & jalankan

chmod +x generate-project.sh
./generate-project.sh

mvn compile exec:java

Konfigurasi default (dev)

Server   : 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

Verifikasi cepat

# 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/metrics

Build fat JAR

mvn package
java -jar target/erp-app-1.0.0-SNAPSHOT.jar

Production

export 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.jar

Menjalankan Test

mvn test                              # semua test
mvn test -Dtest=AuthServiceTest       # test spesifik

Menulis unit test

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

Contoh Lengkap CRUD Module

Login

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

CRUD

# 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/csv

Token Refresh

curl -b jar.txt -c jar.txt -s -X POST http://localhost:8080/api/auth/refresh

Audit Log (ADMIN only)

curl -b jar.txt -s "http://localhost:8080/api/system/audit?limit=20" | python3 -m json.tool

Response format

Sukses:

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

Roadmap

✅ v0.2 — Robustness (selesai)

  • 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

✅ v0.3 — Feature Complete (selesai)

  • 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 /metrics format standar
  • WebSocket support — WsHandler abstract base + ChatWsHandler contoh
  • File upload — MultipartParser pure Java + Request.getMultipart()

🔲 v0.4 — Query Power

  • Join support — @JoinColumn annotation
  • 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

🔲 v0.5 — Developer Experience

  • Hot reload — restart otomatis saat file berubah
  • Route inspector — GET /_routes list semua route terdaftar
  • Request/Response logging yang bisa dikonfigurasi level dan formatnya
  • OpenAPI / Swagger — generate spec dari route registrations

🔲 v0.6 — Production Hardening

  • Distributed tracing — trace ID per request, propagasi ke downstream
  • Inter-module event bus — publish/subscribe tanpa direct coupling
  • Scheduled tasks — @Cron annotation tanpa Quartz
  • Circuit breaker — untuk downstream HTTP calls
  • Multi-tenancy — tenant isolation via request header

Lisensi

MIT License — bebas digunakan, dimodifikasi, dan didistribusikan.


Berkontribusi

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

About

Lightweight Java framework with Dependency Injection, Routing, and JWT Authentication

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages