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
- Data Layer / ORM
- Membuat Module Baru
- Membuat Entity Baru
- Menjalankan Project
- Menjalankan Test
- Contoh Lengkap CRUD Module
- Roadmap
Helios adalah framework web Java yang dibangun dari awal sebagai proyek eksplorasi arsitektur. Tujuannya bukan untuk menggantikan Spring Boot, melainkan untuk memahami bagaimana sebuah framework enterprise sesungguhnya bekerja — dari HTTP server, routing, DI container, hingga ORM — dengan menulis setiap komponennya sendiri.
Project ini lahir dari satu pertanyaan sederhana:
"Jika kita tidak boleh pakai Spring, bagaimana caranya membangun aplikasi Java enterprise yang konsisten, maintainable, dan bisa scale?"
Jawabannya adalah framework ini.
- Menerima dan merutekan HTTP request dengan pattern matching (
/api/users/:id) - Menjalankan middleware chain (logging, CORS, autentikasi)
- Memvalidasi request body secara deklaratif dengan annotation
- Melakukan operasi CRUD ke database via JDBC tanpa ORM eksternal
- Mengamankan endpoint dengan JWT yang disimpan di HttpOnly cookie
- Men-generate laporan dalam format PDF, Excel, dan CSV
- Mengelola dependency antar komponen tanpa classpath scanning
| Kategori | Library | Alasan |
|---|---|---|
| HTTP Server | JDK com.sun.net.httpserver |
Built-in Java 21, tidak butuh dependency |
| Concurrency | Java 21 Virtual Threads | Lightweight, satu thread per request |
| Connection Pool | HikariCP 5 | Paling efisien, konfigurasi minimal |
| JSON | Jackson Databind | Standard de facto di ekosistem Java |
| JWT | JJWT 0.12 | Library kecil, tidak membawa framework |
| Password | jBCrypt | Satu class, battle-tested |
| Excel | Apache POI | Standard untuk format Office |
| OpenPDF | Fork open-source iText 4 | |
| Database (dev) | H2 in-memory | Zero setup, langsung jalan |
| Database (prod) | PostgreSQL | Production-grade RDBMS |
Spring Boot luar biasa produktif, tetapi sebagian besar developer tidak benar-benar tahu apa yang terjadi di baliknya. Ketika ada error aneh di BeanCreationException atau NoSuchBeanDefinitionException, banyak yang langsung copy-paste solusi dari Stack Overflow tanpa mengerti akarnya.
Framework ini memaksa kita untuk memahami setiap lapisan: bagaimana request masuk, bagaimana dependency di-resolve, bagaimana query SQL terbentuk.
@Autowired, @Transactional, @EnableJpaAuditing — semua ini menyembunyikan logika yang sesungguhnya kompleks di balik satu baris kode. Di framework ini, semua wiring dilakukan secara eksplisit di App.java dan setiap Module. Tidak ada yang tersembunyi.
Proyek Spring Boot minimal sudah membawa puluhan MB dependency. Framework ini dirancang minimalis — hanya library untuk concern yang benar-benar tidak perlu dibangun ulang (connection pool, JWT, BCrypt).
Dengan generator (generate-project.sh), seluruh skeleton ERP dengan 64 entity di 9 module bisa dibuat dalam hitungan detik, menghasilkan struktur yang 100% konsisten antar module.
Tidak ada classpath scanning. Tidak ada auto-configuration. Setiap komponen didaftarkan secara manual. Ini terlihat seperti lebih banyak kode, tetapi hasilnya adalah kode yang mudah dibaca dari atas ke bawah — bahkan oleh developer yang baru bergabung.
Controller → menerima request, mengembalikan response
UseCase → mendefinisikan kontrak operasi (interface)
Service → implementasi alur bisnis
Repository → akses data, tidak tahu ada HTTP di atas
Setiap layer hanya boleh bergantung ke layer di bawahnya. Controller tidak boleh langsung akses DataSource. Repository tidak boleh tahu ada Response.
HikariCP, Jackson, JJWT, jBCrypt — ini adalah library murni yang melakukan satu hal dengan sangat baik. Framework ini hanya sebagai perekat (glue code) antar library tersebut, bukan layer abstraksi tambahan di atas layer abstraksi yang sudah ada.
Setiap Service menerima Repository via constructor. Tidak ada static method, tidak ada singleton tersembunyi. Untuk test, cukup passing mock repository — tidak butuh Spring Test Context, tidak butuh @MockBean.
Framework menggunakan Modular Monolith dengan Feature-based Packages. Setiap module bisnis (finance, hrm, inventory, dll.) berdiri sendiri, tetapi berjalan dalam satu JVM.
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT (Browser / API Client) │
└──────────────────────────────┬──────────────────────────────────┘
│ HTTP Request
▼
┌─────────────────────────────────────────────────────────────────┐
│ WebServer (JDK HttpServer + Java 21 Virtual Threads) │
│ Satu virtual thread per request — lightweight, non-blocking │
└──────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ MiddlewareChain │
│ ┌────────────────┐ ┌────────────────┐ ┌──────────────────┐ │
│ │LoggingMiddleware│→ │ CorsMiddleware │→ │ AuthMiddleware │ │
│ │ (timing, log) │ │ (CORS headers) │ │ (JWT cookie → │ │
│ └────────────────┘ └────────────────┘ │ SecurityContext)│ │
│ └──────────────────┘ │
└──────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Router │
│ Exact match dulu → lalu pattern match (:id) │
│ GET /api/finance/accounts/export/pdf ✓ exact │
│ GET /api/finance/accounts/:id ✓ pattern │
└──────────────────────────────┬──────────────────────────────────┘
│ matched Handler
▼
┌─────────────────────────────────────────────────────────────────┐
│ Controller (per entity) │
│ Membaca Request, memanggil UseCase, menulis Response │
└──────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Service (implements UseCase) │
│ Orchestration: validasi → repo → transform → return │
└──────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Repository (extends BaseRepository<T>) │
│ Reflection-based SQL via @Entity, @Table, @Column │
└──────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ DataSource (HikariCP connection pool) │
│ H2 (dev) / PostgreSQL (prod) │
└─────────────────────────────────────────────────────────────────┘
src/
├── main/
│ ├── java/com/company/erp/
│ │ │
│ │ ├── App.java ← Bootstrap: wiring semua komponen
│ │ │
│ │ ├── framework/ ← CORE FRAMEWORK (tidak disentuh developer)
│ │ │ ├── config/
│ │ │ │ └── AppConfig.java ← Baca application.properties / env vars
│ │ │ ├── container/
│ │ │ │ └── Container.java ← DI registry (Map<Class, instance>)
│ │ │ ├── data/
│ │ │ │ ├── annotation/ ← @Entity, @Table, @Id, @Column
│ │ │ │ ├── BaseRepository.java ← Generic JDBC CRUD via reflection
│ │ │ │ ├── DataSource.java ← HikariCP wrapper
│ │ │ │ ├── PageResult.java ← Pagination container
│ │ │ │ ├── Repository.java ← Interface kontrak data access
│ │ │ │ └── SchemaRunner.java ← Eksekusi schema.sql saat startup
│ │ │ ├── exception/
│ │ │ │ ├── ExceptionHandler.java ← Map Exception → HTTP response
│ │ │ │ ├── FrameworkException.java
│ │ │ │ ├── NotFoundException.java
│ │ │ │ ├── UnauthorizedException.java
│ │ │ │ └── ForbiddenException.java
│ │ │ ├── http/
│ │ │ │ ├── Handler.java ← @FunctionalInterface: (req, res) → void
│ │ │ │ ├── HttpMethod.java ← Enum: GET, POST, PUT, DELETE, ...
│ │ │ │ ├── Request.java ← Wrapper HttpExchange: path, query, body, cookie
│ │ │ │ ├── Response.java ← Builder: status, json, binary, cookie
│ │ │ │ ├── Router.java ← Route table + pattern matching
│ │ │ │ └── WebServer.java ← JDK HttpServer + virtual thread executor
│ │ │ ├── middleware/
│ │ │ │ ├── Middleware.java ← Interface: execute(req, res, next)
│ │ │ │ ├── MiddlewareChain.java ← Recursive chain-of-responsibility
│ │ │ │ ├── CorsMiddleware.java
│ │ │ │ └── LoggingMiddleware.java
│ │ │ ├── module/
│ │ │ │ ├── AppModule.java ← Interface: register(container, router)
│ │ │ │ └── ModuleRegistry.java ← Daftar dan boot semua module
│ │ │ ├── response/
│ │ │ │ ├── ApiResponse.java ← {success, message, data}
│ │ │ │ └── PageResponse.java ← {data, total, page, perPage, totalPages}
│ │ │ ├── security/
│ │ │ │ ├── AuthMiddleware.java ← Baca cookie → parse JWT → set SecurityContext
│ │ │ │ ├── JwtUtil.java ← Generate & parse JWT
│ │ │ │ ├── PasswordHasher.java ← BCrypt wrapper
│ │ │ │ └── SecurityContext.java ← ThreadLocal<Principal>
│ │ │ ├── util/
│ │ │ │ ├── ExportUtil.java ← PDF / Excel / CSV → byte[]
│ │ │ │ └── JsonUtil.java ← Jackson ObjectMapper wrapper
│ │ │ └── validation/
│ │ │ ├── annotation/ ← @NotBlank, @NotNull, @Size, @Email
│ │ │ ├── ValidationException.java
│ │ │ └── Validator.java ← Reflect fields, check annotations
│ │ │
│ │ ├── shared/
│ │ │ └── entity/
│ │ │ └── BaseEntity.java ← id, createdAt, updatedAt, deleted
│ │ │
│ │ └── modules/ ← KODE APLIKASI (digenerate & dikembangkan)
│ │ ├── auth/ ← Manual (tidak digenerate)
│ │ │ ├── AuthModule.java
│ │ │ ├── controller/AuthController.java
│ │ │ ├── dto/
│ │ │ ├── entity/{User, Role}.java
│ │ │ ├── repository/UserRepository.java
│ │ │ └── service/{AuthService, DataInitializer}.java
│ │ │
│ │ └── {module}/ ← Digenerate per module
│ │ ├── {Module}Module.java ← Wiring repo + service + controller + routes
│ │ └── {entity}/ ← Digenerate per 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 ← Konfigurasi server, DB, JWT
│ └── schema.sql ← DDL, dieksekusi saat startup
│
└── test/
└── java/com/company/erp/
└── modules/
├── auth/AuthServiceTest.java
└── {module}/{entity}/{Entity}ServiceTest.java
WebServer adalah entry point HTTP. Ia membungkus com.sun.net.httpserver.HttpServer dari JDK — sebuah HTTP server sederhana yang sudah built-in di Java tanpa dependency tambahan.
Yang membuat implementasi ini menarik adalah penggunaan Java 21 Virtual Threads:
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());Setiap request yang masuk dijalankan di virtual thread yang terpisah. Virtual thread adalah lightweight thread yang dikelola JVM, bukan OS. Ribuan virtual thread bisa berjalan bersamaan tanpa overhead context-switching OS yang besar — mirip dengan pendekatan async/non-blocking, tetapi dengan model pemrograman synchronous yang jauh lebih mudah dibaca.
Setelah request selesai, WebServer selalu memanggil response.send() di blok finally — memastikan tidak ada request yang menggantung tanpa response.
Router menyimpan tabel route sebagai List<Route>. Setiap Route menyimpan tiga hal: HTTP method, pattern URL, dan handler.
Cara matching: Router mencoba dua strategi secara berurutan:
- Exact match — bandingkan path request dengan pattern secara literal
- Pattern match — pattern seperti
/api/finance/accounts/:iddicocokkan segment per segment
Urutan ini penting. Tanpa exact match di awal, request ke /api/finance/accounts/export/pdf bisa tertangkap oleh route /api/finance/accounts/:id karena export akan dianggap sebagai nilai :id. Dengan exact match diprioritaskan, route yang lebih spesifik selalu menang.
// Di FinanceModule — export didaftarkan SEBELUM :id
router.get("/api/finance/accounts/export/pdf", ctrl::exportPdf); // exact → menang
router.get("/api/finance/accounts/:id", ctrl::getById); // patternPath variable diekstrak secara otomatis dan bisa diakses via req.getPathVar("id").
Request adalah wrapper tipis di atas HttpExchange dari JDK. Ia tidak melakukan parsing di constructor — parsing dilakukan lazy saat method dipanggil (body hanya dibaca sekali dan di-cache).
Cookie parsing dilakukan manual dengan split string:
Cookie: token=eyJhb...; session=abc123
──────────────────────────────
split(";") → split("=", 2)
Response adalah builder pattern. Setiap method mengembalikan this sehingga bisa di-chain:
res.status(201)
.setAuthCookie(token, maxAge)
.json(ApiResponse.success("Created", data));Penting: res.json(...) tidak langsung menulis ke socket. Ia hanya menyiapkan byte array di memory. Penulisan ke socket terjadi saat res.send() dipanggil — yang dilakukan oleh WebServer di blok finally, bukan oleh controller. Ini memastikan response selalu terkirim bahkan jika terjadi exception di tengah jalan.
Middleware adalah pattern chain of responsibility. Setiap middleware memutuskan apakah akan meneruskan request ke middleware berikutnya atau menghentikannya.
public interface Middleware {
void execute(Request req, Response res, Handler next) throws Exception;
}MiddlewareChain menggunakan rekursi untuk menjalankan chain:
private void run(int idx, Request req, Response res, Handler final_) throws Exception {
if (idx >= chain.size()) {
final_.handle(req, res); // semua middleware selesai → jalankan handler
return;
}
// jalankan middleware ke-idx, berikan "next" yang akan memanggil run(idx+1, ...)
chain.get(idx).execute(req, res, (r, rr) -> run(idx + 1, r, rr, final_));
}Urutan eksekusi: LoggingMiddleware → CorsMiddleware → AuthMiddleware → Controller.
AuthMiddleware selalu membersihkan SecurityContext di blok finally, memastikan data user dari satu request tidak bocor ke request lain yang berjalan di thread yang sama (penting terutama jika thread pool di-reuse).
Container adalah DI registry yang paling sederhana yang bisa dibayangkan: sebuah HashMap<Class<?>, Object>.
container.register(DataSource.class, dataSource); // daftar
DataSource ds = container.get(DataSource.class); // ambilTidak ada magic. Tidak ada reflection. Tidak ada classpath scanning. Seluruh wiring ditulis secara eksplisit di App.java dan setiap Module.
Kenapa pendekatan ini? Karena transparansi. Jika ada masalah dengan dependency, cukup lihat App.java — semua ada di sana, berurutan, tidak tersembunyi di balik annotation.
Validator menggunakan reflection untuk memeriksa annotation di setiap field:
for (Field f : obj.getClass().getDeclaredFields()) {
if (f.isAnnotationPresent(NotBlank.class) && value.isBlank())
errors.put(f.getName(), "must not be blank");
}Annotation yang tersedia: @NotBlank, @NotNull, @Size(min, max), @Email.
Jika ada error, Validator.validate(obj) melempar ValidationException yang berisi Map<String, List<String>> — mapping nama field ke list pesan error. ExceptionHandler menangkap ini dan mengembalikan HTTP 422 dengan detail error per field.
Menggunakan pattern matching for switch (Java 21) untuk memetakan exception ke HTTP status code:
if (t instanceof NotFoundException nx) res.status(404)...
else if (t instanceof UnauthorizedException ux) res.status(401)...
else if (t instanceof ValidationException vx) res.status(422)...
else res.status(500)...Ini adalah single point of failure handling — seluruh aplikasi hanya punya satu tempat yang menentukan bagaimana error ditampilkan ke client.
AppModule adalah kontrak yang harus diimplementasikan setiap module bisnis:
public interface AppModule {
void register(Container container, Router router);
}Di dalam register(), sebuah module melakukan tiga hal:
- Mengambil dependency dari container (misal:
DataSource) - Membuat instance repository, service, controller
- Mendaftarkan route ke router
Pendekatan ini memberikan setiap module otonomi penuh atas internal wiring-nya, sekaligus menjaga App.java tetap bersih.
Berikut perjalanan lengkap sebuah GET /api/finance/accounts?page=0&keyword=kas:
1. JDK HttpServer menerima TCP connection
└── Virtual thread baru dibuat untuk request ini
2. WebServer membuat Request dan Response wrapper
3. MiddlewareChain.execute() dipanggil:
a. LoggingMiddleware
└── Catat timestamp mulai, teruskan ke next
b. CorsMiddleware
└── Tambah Access-Control headers, teruskan ke next
c. AuthMiddleware
└── Baca cookie "token"
└── Parse JWT → extract {id, email, role}
└── Set SecurityContext.set(new Principal(...))
└── Teruskan ke next
4. Router.match("GET", "/api/finance/accounts")
└── Exact match → AccountController::list
5. AccountController.list()
└── SecurityContext.requireAuthenticated() → OK
└── Baca query params: page=0, keyword="kas"
└── Panggil useCase.list(0, 10, "created_at", "desc", "kas")
6. AccountService.list()
└── Panggil repository.search("kas", 0, 10, "created_at", "desc")
7. AccountRepository (extends BaseRepository)
└── Build SQL: SELECT id,name,... FROM accounts
WHERE is_deleted=false
AND LOWER(name) LIKE LOWER('%kas%')
ORDER BY created_at DESC LIMIT 10 OFFSET 0
└── Jalankan via HikariCP connection
└── Map ResultSet → List<Account> via reflection
└── Return PageResult<Account>
8. AccountService
└── PageResult.map(mapper::toResponse)
└── Return PageResult<AccountResponse>
9. AccountController
└── res.json(ApiResponse.success(PageResponse.of(result)))
└── Response body disiapkan sebagai byte[]
10. WebServer (finally block)
└── res.send() → tulis byte[] ke socket
11. AuthMiddleware (finally block)
└── SecurityContext.clear()
12. LoggingMiddleware (setelah next() selesai)
└── Catat: "GET /api/finance/accounts → 200 (12ms)"
Framework ini tidak menggunakan DI container otomatis. Semua wiring dilakukan secara manual dan eksplisit.
// 1. Buat infrastructure objects
AppConfig config = AppConfig.load();
DataSource dataSource = DataSource.create(config);
JwtUtil jwtUtil = new JwtUtil(config.getJwtSecret(), config.getJwtExpirationMs());
// 2. Daftarkan ke container
Container container = new Container();
container.register(DataSource.class, dataSource);
container.register(JwtUtil.class, jwtUtil);
// 3. Boot semua module
ModuleRegistry modules = new ModuleRegistry();
modules.register(new FinanceModule());
modules.boot(container, router);public void register(Container container, Router router) {
DataSource ds = container.get(DataSource.class); // ambil dari container
// buat instance secara manual — tidak ada @Autowired
AccountRepository accountRepo = new AccountRepository(ds);
AccountService accountSvc = new AccountService(accountRepo);
AccountController accountCtrl = new AccountController(accountSvc);
// daftarkan route
router.get("/api/finance/accounts", accountCtrl::list);
...
}Keuntungan pendekatan ini:
- Mudah di-trace: ikuti saja kode dari
App.javake bawah - Mudah di-test: tidak perlu Spring Test Context
- Compile-time safe: jika dependency tidak ada, langsung error saat kompilasi
- Zero reflection overhead saat startup
Route didaftarkan di setiap {Module}Module.java:
// Urutan penting: exact routes dulu, baru pattern routes
router.get("/api/finance/accounts/export/pdf", accountCtrl::exportPdf); // exact
router.get("/api/finance/accounts/export/excel", accountCtrl::exportExcel); // exact
router.get("/api/finance/accounts/export/csv", accountCtrl::exportCsv); // exact
router.get("/api/finance/accounts/:id", accountCtrl::getById); // pattern
router.get("/api/finance/accounts", accountCtrl::list); // exact
router.post("/api/finance/accounts", accountCtrl::create); // exact
router.put("/api/finance/accounts/:id", accountCtrl::update); // pattern
router.delete("/api/finance/accounts/:id", accountCtrl::delete); // pattern| 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 |
exportPdf |
GET |
/api/{module}/{resource}/export/excel |
exportExcel |
GET |
/api/{module}/{resource}/export/csv |
exportCsv |
GET /api/finance/accounts?page=0&perPage=10&sortBy=name&direction=asc&keyword=kas
| Parameter | Default | Keterangan |
|---|---|---|
page |
0 |
Nomor halaman (0-based) |
perPage |
10 |
Jumlah item per halaman |
sortBy |
created_at |
Kolom untuk sorting |
direction |
desc |
asc atau desc |
keyword |
`` | Filter nama (LIKE) |
Penyimpanan JWT di localStorage atau sessionStorage rentan terhadap serangan XSS (Cross-Site Scripting). Script yang berhasil diinjeksikan ke halaman bisa membaca token dan mengirimnya ke server penyerang.
HttpOnly cookie tidak bisa diakses oleh JavaScript sama sekali. Browser mengirimkannya secara otomatis di setiap request, dan hanya server yang bisa membaca atau menghapusnya.
Set-Cookie: token=eyJhbGc...; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400
─────────── ──────── ────────────── ────────────
JS tidak Semua Blok CSRF via Expired 24 jam
bisa akses path same-site only
1. POST /api/auth/login { "email": "...", "password": "..." }
2. AuthService.login()
├── Cari user di DB via UserRepository.findByEmail()
├── Verifikasi password via BCrypt.checkpw()
├── Generate JWT via JwtUtil.generate(userId, {email, role})
└── Return LoginResult(token, userResponse)
3. AuthController.login()
└── res.setAuthCookie(token, maxAgeSeconds)
→ Set-Cookie: token=<jwt>; HttpOnly; ...
4. Client menerima cookie — browser menyimpan otomatis
1. Browser otomatis mengirim cookie di setiap request
2. AuthMiddleware membaca cookie "token"
├── Parse JWT → extract claims
└── SecurityContext.set(new Principal(id, email, role))
3. Controller memanggil SecurityContext.requireAuthenticated()
├── Jika Principal ada → lanjut
└── Jika tidak ada → throw UnauthorizedException → HTTP 401
4. Setelah request selesai (finally):
└── SecurityContext.clear() ← PENTING: bersihkan ThreadLocal
POST /api/auth/logout
AuthController.logout()
└── res.clearAuthCookie()
→ Set-Cookie: token=; HttpOnly; Path=/; Max-Age=0
← Browser menghapus cookie
Di controller atau service mana pun:
SecurityContext.Principal principal = SecurityContext.requireAuthenticated();
String userId = principal.id();
String email = principal.email();
String role = principal.role();BaseRepository<T> menggunakan reflection untuk membangun SQL secara otomatis dari annotation entity. Ini dilakukan sekali di constructor, bukan setiap kali query dieksekusi.
Saat konstruksi:
// BaseRepository membaca annotation @Table dan semua @Column dari class
Table ann = entityClass.getAnnotation(Table.class);
this.tableName = ann.value(); // "accounts"
this.fieldMappings = Arrays.stream(entityClass.getDeclaredFields())
.filter(f -> f.isAnnotationPresent(Column.class))
.map(f -> new FieldMapping(f, f.getAnnotation(Column.class).value()))
.toList();
// [{field: name, col: "name"}, {field: description, col: "description"}, ...]Saat INSERT:
// Column names dari BaseEntity + fieldMappings
// ["id", "created_at", "updated_at", "is_deleted", "name", "description", "is_active"]
// PreparedStatement dengan ? placeholder untuk setiap kolomSaat SELECT:
// ResultSet → instance baru via no-arg constructor
// Setiap field di-set via field.set(instance, rs.getObject(col))@Entity
@Table("products") // nama tabel di database
public class Product extends BaseEntity { // id, createdAt, updatedAt, deleted
@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
}| Field Java | Kolom SQL | Diisi Oleh |
|---|---|---|
id |
id (VARCHAR 36) |
BaseRepository.insert() → UUID |
createdAt |
created_at |
BaseRepository.insert() |
updatedAt |
updated_at |
BaseRepository.insert() + update() |
deletedAt |
deleted_at |
BaseRepository.softDelete() |
deleted |
is_deleted |
BaseRepository.softDelete() |
Untuk query yang tidak bisa ditangani BaseRepository, tambahkan method langsung:
public class ProductRepository extends BaseRepository<Product> {
public ProductRepository(DataSource ds) { super(ds, Product.class); }
// Custom query: produk berdasarkan kategori
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> results = new ArrayList<>();
while (rs.next()) results.add(map(rs)); // map() dari BaseRepository
return results;
} catch (Exception e) {
throw new RuntimeException("findByCategoryId failed", e);
}
}
}cols() dan map() adalah protected method dari BaseRepository yang bisa digunakan oleh subclass.
Semua operasi delete tidak menghapus data dari database. BaseRepository.softDelete() hanya mengupdate:
UPDATE accounts SET is_deleted = true, deleted_at = NOW() WHERE id = ?Semua query search() dan findById() otomatis menyertakan WHERE is_deleted = false, sehingga data yang di-soft-delete tidak muncul di hasil query tanpa tindakan khusus.
Ilustrasi: membuat module Logistics dengan entity Shipment.
Buka generate-project.sh dan tambahkan entry baru:
MODULES["Logistics"]="
shipments:Shipment
carriers:Carrier
shipping_routes:ShippingRoute
"Jalankan generator ulang. Semua file skeleton akan dibuat otomatis.
import com.company.erp.modules.logistics.LogisticsModule;
// di dalam main():
modules.register(new LogisticsModule());CREATE TABLE IF NOT EXISTS shipments (
id VARCHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
-- tambahkan kolom bisnis di sini
tracking_no VARCHAR(100),
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
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
);Ilustrasi: menambah entity Warehouse ke module Inventory secara manual (tanpa generator).
// src/.../modules/inventory/warehouse/Warehouse.java
@Entity
@Table("warehouses")
public class Warehouse extends BaseEntity {
@Column("name") private String name;
@Column("address") private String address;
@Column("city") private String city;
@Column("capacity") private Integer capacity;
@Column("is_active") private Boolean isActive = true;
// getter dan setter
}// Warehouse UseCase.java
public interface WarehouseUseCase {
PageResult<WarehouseResponse> list(int page, int perPage,
String sortBy, String direction, String keyword);
WarehouseResponse getById(String id);
WarehouseResponse create(WarehouseRequest request);
WarehouseResponse update(String id, WarehouseRequest request);
void delete(String id);
byte[] exportPdfBytes();
byte[] exportExcelBytes();
byte[] exportCsvBytes();
}public class WarehouseRepository extends BaseRepository<Warehouse> {
public WarehouseRepository(DataSource ds) { super(ds, Warehouse.class); }
// tambahkan custom query jika diperlukan
}public class WarehouseService implements WarehouseUseCase {
private final WarehouseRepository repository;
private final WarehouseMapper mapper = new WarehouseMapper();
public WarehouseService(WarehouseRepository repository) {
this.repository = repository;
}
@Override
public PageResult<WarehouseResponse> list(...) {
return repository.search(keyword, page, perPage, sortBy, direction)
.map(mapper::toResponse);
}
// ... implementasi method lainnya
}Buka InventoryModule.java dan tambahkan:
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/export/excel", warehouseCtrl::exportExcel);
router.get("/api/inventory/warehouses/export/csv", warehouseCtrl::exportCsv);
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", warehouseCtrl::delete);-- di schema.sql
CREATE TABLE IF NOT EXISTS warehouses (
id VARCHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
address TEXT,
city VARCHAR(100),
capacity INTEGER,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
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
);- Java 21+ (
java -version) - Maven 3.8+ (
mvn -version)
chmod +x generate-project.sh
./generate-project.shGenerator akan membuat seluruh struktur project di direktori saat ini.
mvn clean compilemvn compile exec:javaServer berjalan di http://localhost:8080. Database H2 in-memory otomatis dibuat.
# Login
curl -c cookies.txt -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"password"}'
# List accounts (kirim cookie otomatis)
curl -b cookies.txt http://localhost:8080/api/finance/accountsEdit src/main/resources/application.properties:
server.port=8080
# Development (H2 in-memory)
db.url=jdbc:h2:mem:erpdb;DB_CLOSE_DELAY=-1;NON_KEYWORDS=VALUE
db.user=sa
db.password=
# JWT
jwt.secret=ganti-dengan-secret-minimal-256-bit-di-production
jwt.expiration.ms=86400000Untuk production, gunakan environment variable:
export DB_URL=jdbc:postgresql://localhost:5432/erpdb
export DB_USER=appuser
export DB_PASSWORD=secret
export JWT_SECRET=super-secret-production-key
mvn package
java -jar target/erp-app-1.0.0-SNAPSHOT.jarmvn package
java -jar target/erp-app-1.0.0-SNAPSHOT.jar# Semua test
mvn test
# Test spesifik
mvn test -Dtest=AuthServiceTest
# Test dengan output verbose
mvn test -Dtest=AuthServiceTest -pl . --no-transfer-progressUnit test di framework ini tidak butuh Spring Test Context, tidak butuh embedded server. Cukup instantiasi Service dengan mock Repository:
@ExtendWith(MockitoExtension.class)
class AccountServiceTest {
@Mock AccountRepository repository;
AccountService service;
@BeforeEach
void setUp() {
service = new AccountService(repository); // inject mock langsung
}
@Test
void getById_found() {
Account entity = new Account();
entity.setId("abc"); entity.setName("Kas");
when(repository.findById("abc")).thenReturn(Optional.of(entity));
AccountResponse resp = service.getById("abc");
assertThat(resp.getName()).isEqualTo("Kas");
}
@Test
void getById_notFound() {
when(repository.findById("x")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getById("x"))
.isInstanceOf(NotFoundException.class)
.hasMessageContaining("x");
}
}Tidak ada @SpringBootTest, tidak ada @MockBean, tidak ada startup overhead. Test selesai dalam milidetik.
Berikut adalah contoh end-to-end untuk module CRM dengan entity Contact.
curl -c cookies.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.toolResponse:
{
"success": true,
"message": "Login successful",
"data": {
"email": "admin@example.com",
"role": "ADMIN"
}
}Cookie token otomatis tersimpan di cookies.txt.
curl -b cookies.txt -s -X POST http://localhost:8080/api/crm/contacts \
-H "Content-Type: application/json" \
-d '{"name":"Budi Santoso","description":"Lead dari LinkedIn","isActive":true}' \
| python3 -m json.toolResponse:
{
"success": true,
"message": "Contact created",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Budi Santoso",
"description": "Lead dari LinkedIn",
"isActive": true,
"createdAt": "2025-01-15T10:30:00",
"updatedAt": "2025-01-15T10:30:00"
}
}curl -b cookies.txt -s \
"http://localhost:8080/api/crm/contacts?page=0&perPage=5&keyword=budi&sortBy=name&direction=asc" \
| python3 -m json.toolResponse:
{
"success": true,
"message": "Success",
"data": {
"data": [
{ "id": "550e...", "name": "Budi Santoso", ... }
],
"total": 1,
"page": 0,
"perPage": 5,
"totalPages": 1
}
}curl -b cookies.txt -s \
http://localhost:8080/api/crm/contacts/550e8400-e29b-41d4-a716-446655440000 \
| python3 -m json.toolcurl -b cookies.txt -s -X PUT \
http://localhost:8080/api/crm/contacts/550e8400-e29b-41d4-a716-446655440000 \
-H "Content-Type: application/json" \
-d '{"name":"Budi Santoso","description":"Prospek potensial","isActive":true}' \
| python3 -m json.tool# PDF
curl -b cookies.txt -o contacts.pdf \
http://localhost:8080/api/crm/contacts/export/pdf
# Excel
curl -b cookies.txt -o contacts.xlsx \
http://localhost:8080/api/crm/contacts/export/excel
# CSV
curl -b cookies.txt -o contacts.csv \
http://localhost:8080/api/crm/contacts/export/csvcurl -b cookies.txt -s -X DELETE \
http://localhost:8080/api/crm/contacts/550e8400-e29b-41d4-a716-446655440000 \
| python3 -m json.toolData tidak benar-benar dihapus dari database. is_deleted = true dan deleted_at diisi.
401 Unauthorized (tidak login):
{ "success": false, "message": "Authentication required" }404 Not Found:
{ "success": false, "message": "Contact not found: abc123" }422 Validation Error:
{
"success": false,
"message": "Validation failed",
"data": {
"name": ["must not be blank"],
"email": ["invalid email format"]
}
}Framework ini masih dalam pengembangan aktif. Berikut fitur yang direncanakan:
- Circular dependency detection di Container
- Request body size limit untuk mencegah OOM attack
- Connection pool monitoring — expose metrik HikariCP
- Graceful shutdown — tunggu request yang sedang diproses sebelum mati
- Rate limiting middleware — batasi request per IP
- Hot reload — deteksi perubahan file dan restart otomatis
- Route inspector — endpoint
GET /_routesuntuk list semua route terdaftar - Request/Response logging yang bisa dikonfigurasi (level, format)
- Environment profiles —
dev,staging,proddengan konfigurasi berbeda
- Join support di
BaseRepositoryvia annotation@JoinColumn - Transactions —
@Transactionalmanual atauBaseRepository.withTransaction() - Bulk insert/update untuk operasi batch
- Soft delete filter yang bisa dinonaktifkan untuk admin endpoint
- Database migration — built-in versi sederhana Flyway
- Inter-module event bus — module bisa publish/subscribe event tanpa direct coupling
- Module isolation — option untuk menjalankan module di classloader terpisah
- Lazy module loading — module hanya di-load jika ada request ke route-nya
- Role-based access control via
@RequiresRole("ADMIN")pada controller method - CSRF protection dengan double-submit cookie pattern
- Token refresh — silent refresh sebelum JWT expire
- Audit log — catat semua operasi write per user
- Distributed tracing — trace ID per request yang diteruskan ke downstream service
- Health check endpoint —
GET /healthdengan status DB, memory, dll - Metrics endpoint —
GET /metricskompatibel dengan Prometheus - WebSocket support — upgrade HTTP connection ke WebSocket
- File upload — multipart form data handling
- Comprehensive documentation — JavaDoc untuk semua public API
MIT License — bebas digunakan, dimodifikasi, dan didistribusikan.
Pull request disambut baik. Untuk perubahan besar, buka issue terlebih dahulu untuk mendiskusikan apa yang ingin diubah.
Pastikan test tetap passing: mvn test
Dibuat dengan ☕ dan rasa ingin tahu yang tidak kunjung padam.