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