Skip to content

Latest commit

 

History

History
1270 lines (962 loc) · 46.5 KB

File metadata and controls

1270 lines (962 loc) · 46.5 KB

☕ 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
  11. Data Layer / ORM
  12. Membuat Module Baru
  13. Membuat Entity Baru
  14. Menjalankan Project
  15. Menjalankan Test
  16. Contoh Lengkap CRUD Module
  17. Roadmap

Tentang Project

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.

Apa yang Bisa Dilakukan

  • 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

Stack Teknis

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
PDF OpenPDF Fork open-source iText 4
Database (dev) H2 in-memory Zero setup, langsung jalan
Database (prod) PostgreSQL Production-grade RDBMS

Masalah yang Diselesaikan

1. "Framework sebagai black box"

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.

2. "Annotation magic yang tidak transparan"

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

3. "Dependency bloat"

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

4. "Boilerplate yang tidak terkontrol"

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.


Filosofi Desain

Eksplisit Lebih Baik dari Implisit

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.

Satu Layer, Satu Tanggung Jawab

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.

Library, Bukan Framework atas Framework

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.

Testability by Default

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.


Arsitektur

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

Struktur Folder

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

Framework Components

WebServer

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

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:

  1. Exact match — bandingkan path request dengan pattern secara literal
  2. Pattern match — pattern seperti /api/finance/accounts/:id dicocokkan 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);    // pattern

Path variable diekstrak secara otomatis dan bisa diakses via req.getPathVar("id").


Request dan Response

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.


MiddlewareChain

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

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);    // ambil

Tidak 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

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.


ExceptionHandler

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

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:

  1. Mengambil dependency dari container (misal: DataSource)
  2. Membuat instance repository, service, controller
  3. Mendaftarkan route ke router

Pendekatan ini memberikan setiap module otonomi penuh atas internal wiring-nya, sekaligus menjaga App.java tetap bersih.


Request Lifecycle

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

Dependency Injection

Framework ini tidak menggunakan DI container otomatis. Semua wiring dilakukan secara manual dan eksplisit.

Di App.java

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

Di FinanceModule

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

Routing

Registrasi Route

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

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 exportPdf
GET /api/{module}/{resource}/export/excel exportExcel
GET /api/{module}/{resource}/export/csv exportCsv

Query Parameters untuk list

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)

Authentication & Session

Mengapa HttpOnly Cookie, Bukan Bearer Token?

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

Alur Login

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

Alur Request Terautentikasi

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

Alur Logout

POST /api/auth/logout

AuthController.logout()
└── res.clearAuthCookie()
    → Set-Cookie: token=; HttpOnly; Path=/; Max-Age=0
    ← Browser menghapus cookie

Mengakses User yang Login

Di controller atau service mana pun:

SecurityContext.Principal principal = SecurityContext.requireAuthenticated();
String userId = principal.id();
String email  = principal.email();
String role   = principal.role();

Data Layer / ORM

Cara Kerja BaseRepository

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 kolom

Saat SELECT:

// ResultSet → instance baru via no-arg constructor
// Setiap field di-set via field.set(instance, rs.getObject(col))

Mendefinisikan Entity

@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 yang Dikelola BaseEntity (Otomatis)

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

Custom Query di Repository

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.

Soft Delete

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.


Membuat Module Baru

Ilustrasi: membuat module Logistics dengan entity Shipment.

Langkah 1 — Tambahkan di MODULES (generator)

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.

Langkah 2 — Daftarkan di App.java

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

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

Langkah 3 — Tambahkan DDL di schema.sql

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

Membuat Entity Baru

Ilustrasi: menambah entity Warehouse ke module Inventory secara manual (tanpa generator).

1. Entity Class

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

2. UseCase Interface

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

3. Repository

public class WarehouseRepository extends BaseRepository<Warehouse> {
    public WarehouseRepository(DataSource ds) { super(ds, Warehouse.class); }
    // tambahkan custom query jika diperlukan
}

4. Service

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
}

5. Daftarkan di Module

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

6. Tambahkan DDL

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

Menjalankan Project

Prasyarat

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

Langkah 1 — Generate Project

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

Generator akan membuat seluruh struktur project di direktori saat ini.

Langkah 2 — Compile

mvn clean compile

Langkah 3 — Jalankan (Mode Development)

mvn compile exec:java

Server berjalan di http://localhost:8080. Database H2 in-memory otomatis dibuat.

Langkah 4 — Verifikasi

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

Konfigurasi

Edit 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=86400000

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

Membangun Fat JAR

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

Menjalankan Test

# Semua test
mvn test

# Test spesifik
mvn test -Dtest=AuthServiceTest

# Test dengan output verbose
mvn test -Dtest=AuthServiceTest -pl . --no-transfer-progress

Cara Menulis Unit Test

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


Contoh Lengkap CRUD Module

Berikut adalah contoh end-to-end untuk module CRM dengan entity Contact.

Login dan Dapatkan Cookie

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

Response:

{
  "success": true,
  "message": "Login successful",
  "data": {
    "email": "admin@example.com",
    "role": "ADMIN"
  }
}

Cookie token otomatis tersimpan di cookies.txt.

Create Contact

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

Response:

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

List dengan Pagination dan Filter

curl -b cookies.txt -s \
  "http://localhost:8080/api/crm/contacts?page=0&perPage=5&keyword=budi&sortBy=name&direction=asc" \
  | python3 -m json.tool

Response:

{
  "success": true,
  "message": "Success",
  "data": {
    "data": [
      { "id": "550e...", "name": "Budi Santoso", ... }
    ],
    "total": 1,
    "page": 0,
    "perPage": 5,
    "totalPages": 1
  }
}

Get By ID

curl -b cookies.txt -s \
  http://localhost:8080/api/crm/contacts/550e8400-e29b-41d4-a716-446655440000 \
  | python3 -m json.tool

Update

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

Export

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

Delete (Soft)

curl -b cookies.txt -s -X DELETE \
  http://localhost:8080/api/crm/contacts/550e8400-e29b-41d4-a716-446655440000 \
  | python3 -m json.tool

Data tidak benar-benar dihapus dari database. is_deleted = true dan deleted_at diisi.

Error Handling

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

Roadmap

Framework ini masih dalam pengembangan aktif. Berikut fitur yang direncanakan:

v0.2 — Robustness

  • 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

v0.3 — Developer Experience

  • Hot reload — deteksi perubahan file dan restart otomatis
  • Route inspector — endpoint GET /_routes untuk list semua route terdaftar
  • Request/Response logging yang bisa dikonfigurasi (level, format)
  • Environment profilesdev, staging, prod dengan konfigurasi berbeda

v0.4 — Query Power

  • Join support di BaseRepository via annotation @JoinColumn
  • Transactions@Transactional manual atau BaseRepository.withTransaction()
  • Bulk insert/update untuk operasi batch
  • Soft delete filter yang bisa dinonaktifkan untuk admin endpoint
  • Database migration — built-in versi sederhana Flyway

v0.5 — Module System

  • 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

v0.6 — Security

  • 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

v1.0 — Production Ready

  • Distributed tracing — trace ID per request yang diteruskan ke downstream service
  • Health check endpointGET /health dengan status DB, memory, dll
  • Metrics endpointGET /metrics kompatibel dengan Prometheus
  • WebSocket support — upgrade HTTP connection ke WebSocket
  • File upload — multipart form data handling
  • Comprehensive documentation — JavaDoc untuk semua public API

Lisensi

MIT License — bebas digunakan, dimodifikasi, dan didistribusikan.


Berkontribusi

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.