A Quarkus project for practicing modern Java with a functional mindset: sealed types as control flow, records as immutable data, pattern matching as dispatch, and railway-oriented composition for error handling.
./mvnw quarkus:devDev Services auto-starts PostgreSQL. Flyway runs migrations. Hit http://localhost:8080/chiron/q/dev-ui for the Dev UI.
curl -X POST http://localhost:8080/chiron/api/lendings \
-H "Content-Type: application/json" \
-d '{"bookId": 1, "memberId": 1}'| Imperative habit | Functional replacement | Benefit |
|---|---|---|
| Throw exceptions for business failures | Return sealed result types | Compiler enforces handling |
| Null checks with early returns | Optional chains / Either |
No forgotten paths |
| Mutable DTOs | Immutable records | Construct once, trust everywhere |
if-else trees |
Pattern matching on sealed types | Exhaustive, concise |
| Try-catch at every layer | Railway composition (flatMap) |
Errors flow to one exit point |
1. Sealed types = "one of these" (sum types)
sealed interface LendingResult {
record Success(LendingDetail book) implements LendingResult {}
record MemberNotFound(MemberId id) implements LendingResult {}
record AlreadyLent(LendingDetail detail) implements LendingResult {}
record MaximumLimitReached(MemberId id) implements LendingResult {}
}2. Records = "all of these together" (product types)
record LendCommand(BookItemId bookItemId, MemberId memberId) {}
record LendingDetail(BookItemId bookId, MemberId memberId, Instant dueTime, Instant borrowAt) {}3. Either + flatMap = railway composition
return findMember(cmd)
.flatMap(this::checkOverdue)
.flatMap(this::checkMaximumLentNumber)
.flatMap(m -> findBookItemAndMember(cmd, m))
.flatMap(this::checkIfAlreadyLent)
.fold(err -> err, this::persistAndReturnResult);Each step can succeed (Right) or fail (Left). First failure stops the chain — error flows to fold.
| ✅ Use it when | ❌ Don't force it when |
|---|---|
| Multi-step validation pipelines | Simple CRUD with no rules |
| Multiple distinct error types from one operation | The method can't fail |
| Data transformation (DTO ↔ Domain) | Performance-critical tight loops |
| Making illegal states unrepresentable | Team hasn't learned the pattern yet |
┌─────────────────────────────────┐
│ What to compute │ ← Functional (Either, sealed types, flatMap)
│ (logic, data flow, errors) │
└─────────────────────────────────┘
↕ orthogonal
┌─────────────────────────────────┐
│ How to execute it │ ← Concurrency (virtual threads, StructuredTaskScope)
│ (parallelism, threading) │
└─────────────────────────────────┘
- Sequential dependencies (step N needs step N-1) → functional pipeline
- Independent fan-out (fetch A, B, C in parallel) →
StructuredTaskScope - Blocking I/O on hot paths →
@RunOnVirtualThread
They compose — a pipeline runs on a virtual thread, and parallel results feed into flatMap chains.
src/main/java/dev/learning/
├── domain/
│ ├── entity/ # Panache entities (classes — Hibernate requirement)
│ ├── type/ # Value types — validated records (BookItemId, Email, Isbn, Either)
│ ├── command/ # Action records (LendCommand, CreateMemberCommand)
│ └── result/ # Sealed result interfaces (LendingResult, BookItemResult)
├── repository/ # Panache repositories — all DB access
├── service/ # Business logic — pipelines that return sealed results
├── resource/ # JAX-RS endpoints — parse DTOs, pattern match results → HTTP
└── dto/ # Request/response records — API boundary
- Services never call entity statics — always go through repositories
- Resources parse DTOs → domain commands, pattern match sealed results → HTTP
- Repositories accept/return domain types (
findByMemberId(MemberId), notfindById(Long)) - Entities are classes; everything else is records
Every service method follows:
Command (validated) → Pipeline (Either chain) → Sealed Result
- Parse — Resource converts DTO → Command with value types
- Decide — Service runs pipeline: reads via repo, applies rules
- Execute — Final step persists (the only side effect)
- Respond — Resource pattern-matches result → HTTP response
Side effects happen only at the end. Everything before is a decision.
Validate at construction — make invalid states unrepresentable:
public record Isbn(String value) {
public Isbn {
if (value == null || !value.matches("\\d{13}"))
throw new IllegalArgumentException("Invalid ISBN: " + value);
}
}Once an Isbn exists, it's guaranteed valid. The domain never sees raw strings.
Jackson needs simple types. So you're forced into two steps (unlike F# where parsing IS deserialization):
JSON ──jackson──→ DTO (raw types) ──resource parses──→ Command (domain types)
@POST
public Response register(@Valid LendRequest request) {
var command = new LendCommand(
new BookItemId(request.bookId()), // validates here
new MemberId(request.memberId()) // validates here
);
var result = lendingService.lend(command);
return switch (result) {
case Success(var detail) -> Response.ok(detail).build();
case MemberNotFound(var id) -> Response.status(404).entity(...).build();
// exhaustive — compiler enforces all cases
};
}No default needed on sealed types — the compiler guarantees exhaustiveness:
return switch (result) {
case LendingResult.Success(var detail) -> Response.ok(detail).build();
case LendingResult.AlreadyLent(var detail) -> Response.status(409).entity(...).build();
case LendingResult.MemberNotFound(var id) -> Response.status(404).entity(...).build();
case LendingResult.BookNotFound(var id) -> Response.status(404).entity(...).build();
case LendingResult.MemberHasOverdueBooks(var id, var books) -> Response.status(403).entity(...).build();
case LendingResult.MaximumLimitReached(var id) -> Response.status(403).entity(...).build();
};Resource (no TX) Service (@Transactional)
parse DTO TX BEGIN
call service ──────────→ repo.find() → managed entity
pattern match result ←── business rules + persist
build HTTP response TX COMMIT (auto-flush)
Entities live and die inside the transaction. What escapes is an immutable record inside a sealed result.
Flyway owns the schema — Hibernate DDL generation is disabled.
quarkus.hibernate-orm.schema-management.strategy=none
quarkus.flyway.migrate-at-start=truePanache uses GenerationType.SEQUENCE with INCREMENT BY 50 (batch allocation). Your migrations need:
CREATE SEQUENCE book_SEQ START WITH 1 INCREMENT BY 50;
CREATE TABLE book (
id BIGINT NOT NULL PRIMARY KEY,
...
);| Entity | Sequence expected |
|---|---|
Book |
book_SEQ |
Member |
member_SEQ |
BookLending |
book_lending_SEQ |
BookItem |
bookitem_SEQ |
- New entity → must call
persist() - Loaded entity → mutate fields, auto-flushed at commit (no explicit save)
src/test/resources/application.properties overrides main config:
drop-and-createfor clean schema each run- Flyway off (Hibernate owns schema in tests)
- Pinned Postgres image for reproducibility
| Doc | Topic |
|---|---|
| EITHER-COMPOSITION.md | Deep dive on Either, map vs flatMap, railway pattern |
| FUNCTIONAL-MINDSET.md | When and why to use functional style, trade-offs |
| PESSIMISTIC-LOCKING.md | Concurrent access patterns |
| FLYWAY-HIBERNATE-GOTCHAS.md | Migration pitfalls |
| JPA-TRICKS.md | Hibernate/Panache patterns |
| .kiro/steering/tldraw-diagrams.md | Steering guide for Puppeteer + tldraw-cli diagram rendering pipeline |
- Java 21+
- Quarkus 3.34
- Hibernate ORM with Panache
- RESTEasy Reactive + Jackson
- PostgreSQL (via Dev Services)
- Flyway
- Micrometer + Prometheus

