Java Backend Coding Technology skill for designing, implementing, and reviewing functional Java backend code...
A methodology for writing predictable, testable Java backend code optimized for human-AI collaboration.
Activate this skill when:
Result<T>, Option<T>, Promise<T> typesFor implementation work: Use jbct-coder subagent (Task tool with subagent_type: "jbct-coder")
For code review: Use jbct-reviewer subagent (Task tool with subagent_type: "jbct-reviewer")
For automated checking: Use jbct CLI tool (format, lint, check commands)
Parts of this skill live in the class-level header comments of Pragmatica Core source files β the single source of truth that cannot drift from the API. When a task touches one of these areas, READ THE SOURCE HEADER before writing code:
| Chapter | Source of truth |
|---|---|
| Core monads: combinator maps, construction, aggregation, conversions | org/pragmatica/lang/Result.java, Option.java, Promise.java headers |
Validation: ensure families, full Is predicate catalog, ensureOption, combine |
org/pragmatica/lang/Verify.java header |
| Intent annotations: when void/blocking/null is legitimate, decision procedures | org/pragmatica/lang/Contract.java, TerminalOperation.java, NullReturn.java headers |
| Built-in value objects: catalog, factories, validation rules | org/pragmatica/lang/vo/package-info.java (+ per-class headers) |
| Exception-safe parsing: wrapper catalog | org/pragmatica/lang/parse/package-info.java |
| Utilities: failure vocabulary, resilience (Retry/CircuitBreaker/RateLimiter/Idempotency), memoization, scheduling | org/pragmatica/lang/utils/package-info.java |
Resolution order (first that succeeds):
0. jbct doc <ClassOrPackage> β if the jbct CLI is available (jbct --version), this is the
preferred shortcut: jbct doc Verify, jbct doc org.pragmatica.lang.vo, jbct doc Result --api.
It applies the local-then-download resolution below automatically.
<checkout>/core/src/main/java/<path> directly. In sibling layouts (e.g. working inside
coding-technology/) this is ../pragmatica/core/src/main/java/<path>; inside the pragmatica
repo itself it is core/src/main/java/<path>.mvn dependency:get -Dartifact=org.pragmatica-lite:core:<version>:jar:sources then
unzip -p ~/.m2/repository/org/pragmatica-lite/core/<version>/core-<version>-sources.jar org/pragmatica/lang/Verify.java | head -120https://raw.githubusercontent.com/pragmaticalabs/pragmatica/main/core/src/main/java/<path>Read only the class header (first ~120 lines), not whole implementation files.
JBCT CLI provides automated formatting and compliance checking. Every finding names the rule
that produced it: JBCT-RET-* (return types), JBCT-VO-* (value objects), JBCT-EX-*
(exceptions), JBCT-NAM-* (naming), JBCT-LAM-* (lambdas), JBCT-STY-* (style),
JBCT-LOG-* (logging), JBCT-MIX-* (I/O in domain).
Check if installed:
jbct --version
Usage:
jbct format src/main/java # Format to JBCT style
jbct lint src/main/java # Check JBCT compliance
jbct check src/main/java # Combined format + lint
jbct init --slice my-service # Scaffold new slice project
jbct add-slice <name> # Add slice to existing project
jbct add-event <name> # Add event scaffolding
jbct add-persistence # Add PostgreSQL persistence support
If not installed, suggest:
π‘ JBCT CLI automates formatting and lint checks for JBCT compliance.
Install: curl -fsSL https://raw.githubusercontent.com/siy/jbct-cli/main/install.sh | sh
Requires: Java 25+
More info: https://github.com/siy/jbct-cli
JBCT reduces the space of valid choices to one good way to do most things through:
T, Option<T>, Result<T>, Promise<T>Cause valuesJava Backend Coding Technology (JBCT) proposes a different approach: reduce the space of valid choices until there's essentially one good way to do most things. Not through rigid frameworks or heavy ceremony, but through a small set of rules that make structure predictable, refactoring mechanical, and business logic clearly separated from technical concerns.
The benefits compound:
Unified structure means humans can read AI-generated code without guessing about hidden assumptions, and AI can read human code without inferring structure from context. A use case looks the same whether you wrote it, your colleague wrote it, or an AI assistant generated it. The structure carries the intent.
Minimal technical debt emerges naturally because refactoring rules are built into the methodology. When a function grows beyond one clear operation, the rules tell you exactly how to split it. When a component gets reused, there's one obvious place to move it. Debt doesn't accumulate because prevention is cheaper than cleanup.
Close business modeling happens when you're not fighting technical noise. Value objects enforce domain invariants at construction time. Use cases read like business processes because each step does one thing. Errors are domain concepts, not stack traces. Product owners can read the code structure and recognize their requirements.
Requirement discovery becomes systematic. When you structure code as validation β steps β composition, gaps become obvious. Missing validation rules surface when you define value objects. Unclear business logic reveals itself when you can't name a step clearly. Edge cases emerge when you model errors as explicit types.
Common language emerges when patterns become vocabulary. The six patterns (Leaf, Sequencer, Fork-Join, Condition, Iteration, Aspects) describe both code structure and business processes. When business says "First we verify, then we process, then we notify"βthat's a Sequencer. When they say "We need profile, preferences, and history"βthat's a Fork-Join. The translation is mechanical, and requirements discussions become technical design sessions.
Business logic as a readable language happens when patterns become vocabulary. The four return types, parse-don't-validate, and the fixed pattern catalog form a consistent way to express domain concepts in code. Anyone who understands the domain can pick up a new codebase virtually instantly.
Hide the machinery, keep the meaning is the property those last two add up to, and it deserves its own name. Technical noise is pushed to the edges and the business facts survive in the types: a return type states whether a step can fail, an Option parameter states that the domain allows absence, Promise.all states that steps are independent, a sealed error type states the complete failure catalog. The code executes and testifies at once β and because the testimony lives in types, the compiler keeps it true. The full inventory is a table in From Process to Patterns, and it is why JBCT code stays legible to humans and AI assistants alike after its authors have moved on.
Deterministic code generation becomes possible when the mapping from requirements to code is mechanical. Given a use case specification - inputs, outputs, validation rules, steps - there's essentially one correct structure. Different developers (or AI assistants) should produce nearly identical implementations.
The normalization boundary. "Nearly identical" has a precise scope, and stating the scope is what makes the claim testable. JBCT derives the package hierarchy (the telescope rule), the Java types, the step contracts and return types, the pattern for each composition, the failure representation, the placement of shared code, the concurrency structure, and the testing obligations. JBCT does not normalize algorithms inside atomic leaves, framework-specific adapter internals, module promotion (content-invariant along derived boundaries β Project Structure has the full treatment), the test-input supply vehicle, or test-data representation. Variation below this line is style; variation above it is a defect. A newly discovered variation receives an explicit ruling: closed by a rule, or placed below the line. The boundary comes from measurement rather than taste β inspecting independent implementations of one design found their skeletons identical, and what differed became the first rulings.
Construction that scales is what these properties buy at team size. It is a different claim from runtime parallelism: whether steps run in parallel follows from their data dependencies, while whether they can be built in parallel follows from their contracts β and JBCT steps share only their typed input and output, so the pieces of even a strictly sequential chain can be built concurrently by builders who coordinate on nothing beyond the types. Uniformity supplies the rest: when every unit is shaped from the same six patterns and four return types, any builder, human or AI agent, picks up any unit already knowing its shape, so ramp-up cost falls toward zero and workers stay interchangeable across the codebase. The other half of the pair β cutting the system into units that change for different reasons, so builders rarely meet at all β is design-phase work, developed in the companion Process-First Design. The author's Software's Second Free Lunch carries the full argument.
A Broader Movement: JBCT is not alone in pursuing compile-time guarantees and type-driven design. Similar philosophies appear in database design (7NF type-first approaches), distributed systems, and functional programming communities. The common thread: shift errors from runtime to compile-time, make invalid states unrepresentable, and reduce cognitive load through explicit contracts.
These patterns are never acceptable in JBCT code. Hunt for them aggressively.
| Violation | Detection | Why Forbidden |
|---|---|---|
*Impl classes |
grep -r "class.*Impl" |
Use lambdas for behavior, records for data |
| Null checks in business logic | if (x == null) or != null |
Use Option<T> instead |
| Throwing exceptions | throw new in business code |
Use Result<T> or Promise<T> |
| Catching exceptions | catch in business code |
Lift at adapter boundaries only |
Void type parameter |
Result<Void>, Promise<Void> |
Use Unit. void return OK with @Contract (external API) or fire-and-forget |
Result.failure(cause) |
Direct call | Use cause.result() fluent style |
Promise.failure(cause) |
Direct call | Use cause.promise() fluent style |
| Multi-statement lambdas | x -> { stmt1; stmt2; } |
Extract to named method |
Promise.await() in business logic |
.await() in domain/usecase |
Blocks thread. Stay in chain. OK in tests; use @TerminalOperation for CLI/fire-and-forget |
@SuppressWarnings misuse |
Used instead of @Contract/@TerminalOperation |
Use @Contract for void return (external API), @TerminalOperation for legitimate await() |
Abandoned Result/Promise |
Statement discarding return value | Every Result/Promise must be returned or handled |
Exception: Methods annotated with @Contract are exempt from all JBCT lint rules. Use @Contract for Java API boundary methods (annotation processors, Maven Mojos).
| Pattern | Issue | Fix |
|---|---|---|
fold() for simple cases |
Obscures intent | Use .toResult(), .async(), .or() |
| Complex lambda body | Logic in map/flatMap | Extract to method reference |
| Long sequencer chains | >5 flatMap calls | Group into sub-operations |
| Nested records for behavior | record X() implements Y {} |
Use lambda |
// β FORBIDDEN: Impl class
public class UserServiceImpl implements UserService { ... }
// β
CORRECT: Lambda factory
static UserService userService(UserRepository repo) {
return userId -> repo.findById(userId);
}
// β FORBIDDEN: Null check
if (user != null) { process(user); }
// β
CORRECT: Option
findUser(id).onSuccess(this::process);
// β FORBIDDEN: Result.failure()
return Result.failure(USER_NOT_FOUND);
// β
CORRECT: Fluent style
return USER_NOT_FOUND.result();
// β FORBIDDEN: Multi-statement lambda
.map(user -> {
var enriched = enrich(user);
return format(enriched);
})
// β
CORRECT: Extract to method
.map(this::enrichAndFormat)
Every function in JBCT returns one of four semantic shapes: T, Option<T>, Result<T>, or Promise<T>. Not "usually" or "preferably"βone of the four, always. Two qualifications keep the rule exact rather than merely emphatic. The shapes compose in one permitted way, Result<Option<T>> and its asynchronous form Promise<Option<T>>, where fallibility and optionality are genuinely independent concerns; every deeper nesting is a smell this chapter names later. And void remains available at the edges as a deliberate signal that failure is irrelevant to the caller, distinct from Result<Unit>, where it is not. This isn't an arbitrary restriction; it's intentional compression of complexity into type signatures.
Why by criteria:
// T - Pure computation, cannot fail, always present
public String initials() { return ...; }
// Option<T> - May be absent, cannot fail
public Option<Theme> findTheme(UserId id) { return ...; }
// Result<T> - Can fail (validation/business errors)
public static Result<Email> email(String raw) { return ...; }
// Promise<T> - Asynchronous, can fail
public Promise<User> loadUser(UserId id) { return ...; }
| Type | Use Case |
|---|---|
T |
Synchronous, cannot fail, always present |
Option<T> |
Synchronous, cannot fail, might be absent |
Result<T> |
Synchronous, can fail |
Promise<T> |
Asynchronous, can fail |
Result<Option<T>> |
Optional value that can fail validation |
Promise<Option<T>> |
Async lookup that might not find anything |
| Type | Why Discouraged |
|---|---|
Optional<T> |
Use Option<T> for consistency |
CompletableFuture<T> |
Use Promise<T> for consistent error handling |
Framework-specific types (Mono<T>, ResponseEntity<T>) |
Keep business logic framework-agnostic |
| Type | Why Forbidden |
|---|---|
Promise<Result<T>> |
Promise already carries failures - double error channel |
Result<Result<T>> |
Nested failures create unwrapping ceremony |
Option<Option<T>> |
Nested optionality is meaningless |
Promise<Option<Result<T>>> |
Triple nesting - architectural smell |
Option<List<T>> |
A collection already carries emptiness as a value - a second absence channel says it twice |
Rule: Each concern (optionality, failure, asynchrony) appears at most once in a return type; emptiness is the collection's own concern, already carried as a value.
Critical Rules:
Void type parameter - always use Unit (Result<Unit>, Promise<Unit>). void return is OK for fire-and-forgetResult.unitResult() for successful Result<Unit>// β
CORRECT: Validation = Construction
public record Email(String value) {
private static final Fn1<Cause, String> INVALID_EMAIL =
Causes.forOneValue("Invalid email: %s");
public static Result<Email> email(String raw) {
return Verify.ensure(raw, Verify.Is::present)
.map(String::trim)
.filter(INVALID_EMAIL, PATTERN.asMatchPredicate())
.map(Email::new);
}
}
// β WRONG: Separate validation
public record Email(String value) {
public Result<Email> validate() { ... } // Don't do this
}
Key Points:
Email.email(...)Verify first, lambdas last. Before writing any predicate lambda in a validation chain, check
the Verify.Is catalog β full catalog and ensure overload families: see the Verify.java
source header (Source-Anchored Chapters above). Hand-rolling a check that duplicates a catalog
predicate is a JBCT violation. Hottest entries:
Verify.Is::present // not null and not blank β the "required string" check
Verify.ensure(v, Is::lenBetween, 3, 50) // parameterized predicates need no capturing lambda
Verify.ensureOption(opt, predicate) // Result<Option<T>> contract for optional values
Parse Subpackage - Exception-safe JDK wrappers:
import org.pragmatica.lang.parse.Number;
import org.pragmatica.lang.parse.DateTime;
import org.pragmatica.lang.parse.Network;
Number.parseInt(raw) // Result<Integer>
DateTime.parseLocalDate(raw) // Result<LocalDate>
Network.parseUUID(raw) // Result<UUID>
Example:
public record Age(int value) {
private static final Cause AGE_OUT_OF_RANGE = Causes.cause("Age must be 0-150");
public static Result<Age> age(String raw) {
return Number.parseInt(raw)
.filter(AGE_OUT_OF_RANGE, v -> Verify.Is.between(v, 0, 150))
.map(Age::new);
}
}
public interface RegisterUser extends UseCase.WithPromise<Response, Request> {
record Request(String email, String password) {}
record Response(UserId userId, ConfirmationToken token) {}
// Nested API: steps as single-method interfaces
interface CheckEmail { Promise<ValidRequest> apply(ValidRequest valid); }
interface SaveUser { Promise<User> apply(ValidRequest valid); }
// Validated input with Valid prefix (not Validated)
record ValidRequest(Email email, Password password) {
static Result<ValidRequest> validRequest(Request raw) {
return Result.all(Email.email(raw.email()),
Password.password(raw.password()))
.map(ValidRequest::new);
}
}
// β
CORRECT: Factory returns lambda directly
static RegisterUser registerUser(CheckEmail checkEmail, SaveUser saveUser) {
return request -> ValidRequest.validRequest(request)
.async()
.flatMap(checkEmail::apply)
.flatMap(saveUser::apply);
}
}
β ANTI-PATTERN: Nested Record Implementation
NEVER create factories with nested record implementations:
// β WRONG - Verbose, no benefit
static RegisterUser registerUser(CheckEmail check, SaveUser save) {
record registerUser(CheckEmail check, SaveUser save) implements RegisterUser {
@Override
public Promise<Response> execute(Request request) { ... }
}
return new registerUser(check, save);
}
Rule: Records are for data (value objects), lambdas are for behavior (use cases, steps).
Core Rules:
Pattern-Specific Safety:
Example - Thread-Safe Fork-Join:
// β
CORRECT: Immutable cart passed to both operations
Promise.all(applyBogo(cart), // cart is immutable
applyPercentOff(cart)) // cart is immutable
.map(this::mergeDiscounts);
// β WRONG: Shared mutable context creates data race
private final DiscountContext context = new DiscountContext();
Promise.all(applyBogo(cart, context), // mutates context
applyPercentOff(cart, context)) // DATA RACE
.map(this::merge);
See Thread Safety for comprehensive thread safety coverage, including detailed examples and common mistakes.
Rule: Lambdas passed to monadic operations (map, flatMap, recover, filter) must be minimal.
Allowed:
Email::new, this::processUser, User::iduser -> validate(requiredRole, user)RepositoryError.DatabaseFailure::newForbidden:
if, ternary, switch)Pattern matching: Use switch expressions in named methods:
// Extract type matching to named method
.recover(this::recoverKnownErrors)
private Promise<T> recoverKnownErrors(Cause cause) {
return switch (cause) {
case NotFound ignored, Timeout ignored -> DEFAULT.promise();
default -> cause.promise();
};
}
Multi-case matching: Comma-separated for same recovery:
private Promise<Theme> recoverWithDefault(Cause cause) {
return switch (cause) {
case NotFound ignored, Timeout ignored, ServiceUnavailable ignored ->
Promise.success(Theme.DEFAULT);
default -> cause.promise();
};
}
Error constants: Define once, reuse everywhere:
Rule: One pattern per method. Never combine patterns in a single method body.
// β WRONG: Mixed patterns (Sequencer + Fork-Join + Condition)
public Promise<Response> execute(Request request) {
return validate(request)
.async()
.flatMap(valid -> {
if (valid.isPremium()) {
return Promise.all(fetchA(valid), fetchB(valid))
.map(this::merge);
}
return fetchBasic(valid);
});
}
// β
CORRECT: Decomposed into single-pattern methods
public Promise<Response> execute(Request request) {
return validate(request)
.async()
.flatMap(this::routeByType); // Sequencer
}
private Promise<Response> routeByType(ValidRequest valid) {
return valid.isPremium() // Condition
? processPremium(valid)
: processBasic(valid);
}
private Promise<Response> processPremium(ValidRequest valid) {
return Promise.all(fetchA(valid), fetchB(valid)) // Fork-Join
.map(this::merge);
}
Every method must have clear data flow:
// Input: ValidRequest (email, password)
// Output: User (id, email, hashedPassword)
// Dependencies: hashPassword, userRepository
private Promise<User> createUser(ValidRequest valid) {
return hashPassword.apply(valid.password())
.flatMap(hashed -> userRepository.save(
new User(UserId.generate(), valid.email(), hashed)));
}
When multi-step operations need data from earlier steps, use explicit intermediate records instead of nested closures:
// β WRONG: Nested closures lose clarity
return loadUser(userId)
.flatMap(user -> loadOrders(user.id())
.flatMap(orders -> loadPreferences(user.id())
.map(prefs -> new Dashboard(user, orders, prefs))));
// β
CORRECT: Growing context with intermediate records
record UserWithOrders(User user, List<Order> orders) {}
record DashboardContext(User user, List<Order> orders, Preferences prefs) {}
return loadUser(userId)
.flatMap(user -> loadOrders(user.id())
.map(orders -> new UserWithOrders(user, orders)))
.flatMap(ctx -> loadPreferences(ctx.user().id())
.map(prefs -> new DashboardContext(ctx.user(), ctx.orders(), prefs)))
.map(this::buildDashboard);
Benefits:
private static final Cause NOT_FOUND = new UserNotFound("User not found");
private static final Cause TIMEOUT = new ServiceUnavailable("Request timed out");
private Promise<User> recoverNetworkError(Cause cause) {
return switch (cause) {
case NetworkError.Timeout ignored -> TIMEOUT.promise();
default -> cause.promise();
};
}
JBCT's six patterns come from the process side β the data dependency graph's operators β and code written in them is an executable business process specification.
| Pattern | Role |
|---|---|
| Leaf | Atomic operation, no composition |
| Sequencer | Dependent steps in order |
| Fork-Join | Independent concurrent operations |
| Condition | Routing, no transformation |
| Iteration | Collection processing |
| Aspects | Cross-cutting concerns wrapping logic |
Atomic unit - one operation, no composition:
public Promise<User> findUser(UserId id) {
return Promise.lift(
RepositoryError.DatabaseFailure::new,
() -> jdbcTemplate.queryForObject(...)
);
}
Linear dependent steps (most common use case pattern):
return ValidRequest.validRequest(request)
.async()
.flatMap(checkEmail::apply)
.flatMap(hashPassword::apply)
.flatMap(saveUser::apply)
.flatMap(sendEmail::apply);
Parallel independent operations (requires immutable inputs):
return Promise.all(fetchProfile.apply(userId),
fetchPreferences.apply(userId),
fetchOrders.apply(userId))
.map((profile, prefs, orders) ->
new Dashboard(profile, prefs, orders));
Thread Safety: All parallel operations must receive immutable inputs. No shared mutable state.
Branching as values (no mutation):
return userType.equals("premium")
? processPremium.apply(request)
: processBasic.apply(request);
Functional collection processing:
var results = items.stream()
.map(Item::validate)
.toList();
return Result.allOf(results)
.map(validItems -> process(validItems));
Cross-cutting concerns without mixing:
return withRetry(
retryPolicy,
withMetrics(metricsPolicy, coreOperation)
);
// Option β Result/Promise
option.toResult(cause) // or .await(cause)
option.async(cause)
// Result β Promise
result.async()
// Promise β Result (blocking)
promise.await()
promise.await(timeout)
// Cause β Result/Promise (prefer over failure constructors)
cause.result()
cause.promise()
// Result.all - Accumulates all failures (1-15 params)
Result.all(result1, result2, result3)
.map((v1, v2, v3) -> combine(v1, v2, v3));
// Promise.all - Parallel, fail-fast on first failure (1-15 params)
Promise.all(promise1, promise2, promise3)
.map((v1, v2, v3) -> combine(v1, v2, v3));
// Promise.allOrCancel - Like all(), but cancels remaining on first failure (1-15 params)
Promise.allOrCancel(promise1, promise2, promise3)
.map((v1, v2, v3) -> combine(v1, v2, v3));
// Option.all - Fail-fast on first empty (1-15 params)
Option.all(opt1, opt2, opt3)
.map((v1, v2, v3) -> combine(v1, v2, v3));
// Collection variants
Promise.allOf(collection) // Promise<List<Result<T>>> - collects all
Promise.allOfOrCancel(collection) // Like allOf(), cancels remaining on first failure
Promise.any(promise1, promise2) // First success wins
Result.allOf(collection) // Result<List<T>> - accumulates failures
Instance variants (for-comprehension style, same semantics):
promise.all(fn1, fn2, fn3).map(combine); // Parallel, fail-fast
promise.allOrCancel(fn1, fn2, fn3).map(combine); // Parallel, fail-fast + cancel
// Lift exceptions in adapters
Promise.lift(
RepositoryError.DatabaseFailure::new,
() -> jdbcTemplate.queryForObject(...)
);
// With custom exception mapper (constructor reference preferred)
Result.lift(
CustomError.ProcessingFailed::new,
() -> riskyOperation()
);
Absorbing a failure requires saying why. A .recover(...) or swallowing .onFailure(...) in a
composition drops a failure the caller will never see, so the site must name which recovery strategy
it is using and what guarantee that earns. Absorption without a stated justification is the defect β
not absorption itself.
// FER: the buy is already committed by the time the fact is published, so a publish failure is
// swallowed rather than reported to a buyer who has been charged. Guarantee earned: the response
// is truthful about the purchase, not about the fact. Mechanism: a single attempt -- no retry and
// no outbox, so a lost fact leaves downstream projections stale until the next fact or an
// operator re-drive.
private Promise<Response> publishSold(Confirmation confirmation) {
return seatSold.publish(confirmation.fact())
.recover(_ -> Unit.unit())
.map(_ -> confirmation.response());
}
Name the triple, the guarantee, and the mechanism. The rule and its vocabulary are book-owned:
The patterns above answer "this operation failed β what value do I return instead?" A harder question sits one level up: a step fails after earlier steps already changed state β a seat is held, an authorization placed β and that state is now invalid. There are exactly three responses, and naming all three keeps the choice deliberate instead of defaulting to the first.
fresh -> stale -> expired rather than fail outright. The .or(...) and graceful-degradation patterns above are FER. Reach for it when forward progress is worth more than perfect consistency β telemetry, notifications, optional enrichment.Which applies is a judgment β reversibility, the value of partial progress, the domain's shape, coordination cost β and mixed strategies are normal: one booking flow can use BER for the payment, FER for the confirmation email, and design-out for the seat model, all at once. Name the triple for each step that changes state, and recovery becomes a design decision rather than an afterthought.
Record the choice where a failure is absorbed. .recover(...) turns a failure into a value and ends its journey. That is often exactly right - a notification that fails must not fail the purchase it reports - but an absorption nobody explained is indistinguishable from an accident. Name the response as BER, FER or design-out in a comment at the absorption, followed by the reason: // FER: a lost receipt email must not void the sale. A reason without the name does not say which of the three was chosen. Every absorption carries its own, so adding a .recover(...) means adding its sentence, even in a file that already explains others.
TypeName.typeName(...) (lowercase-first)Valid prefix (not Validated): ValidRequest, ValidUserEmailNotFound, AccountLocked, PaymentFailed*State suffix for the sealed sum of lifecycle states β HoldState, BookingState, SeatState β with variants kept bare (Free, Held, Confirmed, Cancelled, never HeldState). Reserve the suffix for the lifecycle sum a guarded transition advances, not every mutable holder; it joins the suffix-by-role family (*Request, *Response, Cause).method_[scenario_]expectation β at least two underscore-separated segments (validate_rejectsEmpty, or the fuller register_succeeds_forNewEmail)httpClient, apiKey not HTTPClient, APIKeySource: Adapted from Derrick Brandt's systematic approach.
Use zone-appropriate verbs to maintain consistent abstraction levels. The rule and the tables are book-owned and reproduced below.
Stepdown rule test: Read code aloud with "to" before functions - should flow naturally:
// "To execute, we validate the request, then process payment, then send confirmation"
return ValidRequest.validRequest(request)
.async()
.flatMap(this::processPayment)
.flatMap(this::sendConfirmation);
The zone is the constraint; the verb lists below are illustrative, not exhaustive. A name is correct when its verb matches the altitude it is declared at, not when it appears in a table. The tables name representative verbs for each zone so the distinction has something concrete to stand on β they were never meant as a closed vocabulary, and a census of real JBCT codebases found the majority of production method names heading verbs no list contained.
The distinction that does the work is this: Zone 2 names the intent, Zone 3 names the mechanism.
A step interface says what the workflow needs to happen; a leaf says how it is done. LoadUser
is a step because loading is the intent; fetchFromDatabase and findByEmail are leaves because
fetching over a network and searching an index are mechanisms. This is why the anti-pattern below
is a real defect rather than a style preference.
Zone 2 verbs (step interfaces β orchestration), representative:
| Verb | When to Use | Example |
|---|---|---|
validate |
Checking rules/constraints | ValidateInput |
process |
Transforming or interpreting data | ProcessPayment |
handle |
Coordinating reactions to events | HandleRefund |
load |
Retrieving data for use | LoadUserProfile |
save |
Persisting changes | SaveOrder |
check |
Verifying conditions | CheckInventory |
Zone 3 verbs (leaves β implementation), representative:
| Verb | Typical Use | Example |
|---|---|---|
get |
Retrieve a value | getTimestamp() |
fetch |
Pull from external source | fetchWeatherData() |
find |
Search for a value that may be absent | findByEmail() |
parse |
Break down structured input | parseJson() |
calculate |
Perform computation | calculateTax() |
create |
Construct a value from parts | createInvoice() |
build |
Assemble a value incrementally | buildQuery() |
insert |
Write a new row or entry | insertPayment() |
hash |
Cryptographic transformation | hashPassword() |
format |
Build structured output | formatDate() |
send |
Transmit over network | sendEmail() |
The primary test β do not mix zones. A step interface that uses a Zone 3 verb has named a
mechanism where it owed an intent, and the mismatch is checkable without consulting any list: a step
interface named FetchUserData should be LoadUserData, because fetch commits the orchestration
layer to how the data arrives. This test catches real defects. Absence from a table does not β a
verb missing from both lists is unlisted, not wrong.
Methods returning boolean take an is, has, or can prefix, and the set is closed β these
three, no others:
boolean isExpired() // state of the receiver
boolean hasBalance() // possession of a part or property
boolean canWithdraw() // permission or capability
The prefix says which question is being asked, so a caller reading if (account.canWithdraw())
knows a permission is being checked rather than a state inspected. Predicates never take a verb from
the zone tables: checkExpiry() returning boolean should be isExpired().
com.example.app/
βββ usecase/
β βββ registeruser/ # Self-contained vertical slice
β β βββ RegisterUser.java # Use case interface + factory
β β βββ [internal types] # ValidRequest, etc.
β βββ loginuser/
β βββ LoginUser.java
βββ domain/
β βββ shared/ # Reusable value objects ONLY
β βββ Email.java
β βββ Password.java
β βββ UserId.java
βββ adapter/
βββ rest/ # Inbound (HTTP)
βββ persistence/ # Outbound (DB)
βββ messaging/ # Outbound (queues)
Placement Rules:
domain/shared/Every failure is a Cause, and Cause has exactly one abstract member: message(). The construction idiom satisfies it structurally, so no error type ever hand-writes prose in a method body.
A use case's failures form a sealed interface with two kinds of members. A data-carrying failure is a record: its components are the error's data, in declaration order, with a trailing String message component whose generated accessor is the message() implementation. Its FACTORY, built from a message template and the canonical constructor reference, is the construction path. Fixed-text failures share one enum in a prescribed shape - a single message field, a constructor, a field-returning accessor - with each failure declared as one constant carrying its text.
public sealed interface LoginError extends Cause {
record AccountLocked(UserId userId, String message) implements LoginError {
static final Fn1<AccountLocked, UserId> FACTORY =
Causes.forOneValue("Account is locked: %s", AccountLocked::new);
}
enum General implements LoginError {
INVALID_CREDENTIALS("Invalid email or password");
private final String message;
General(String message) { this.message = message; }
@Override
public String message() { return message; }
}
}
Construction sites:
LoginError.AccountLocked.FACTORY.apply(user.id()).result(); // data-carrying
LoginError.General.INVALID_CREDENTIALS.result(); // fixed text
Three rules govern the shape:
message component comes last, which is what lets the constructor reference serve as the factory argument. Zero data is a property, not an omission: INVALID_CREDENTIALS deliberately says nothing about which credential failed.case General.INVALID_CREDENTIALS ->) discriminate constants in a switch over the sealed interface, and listing every constant preserves exhaustiveness: adding a constant breaks every switch, exactly as adding a record does.FACTORY is the only constructor call site. new AccountLocked(id, "hand-typed prose") compiles and silently decouples the stored message from the declared template; routed through the factory, template and data cannot disagree.Two mixins nested in Cause remove the remaining overrides. A failure wrapping an underlying cause implements Cause.Wrapped with an origin component; the mixin derives source() from it. The component cannot be named source - the record accessor's return type would clash with Cause.source(), and origin is the name that avoids the trap. A failure no retry can change implements Cause.Terminal, which retry facilities consult to stop immediately.
record PaymentFailed(Cause origin, String message) implements TransferError, Cause.Wrapped {
static final Fn1<PaymentFailed, Cause> FACTORY =
Causes.forOneValue("Payment step failed: %s", PaymentFailed::new);
}
// translation at a composition boundary:
paymentStep.execute(order).mapError(PaymentFailed.FACTORY);
Composition sites accept the fully-typed factory directly. Where only some constants of an enum are terminal, a constant body overrides isTerminal() per constant.
Causes.cause("Age must be 0-150") remains the sanctioned form where no caller can act on the distinction - value-object validation whose failures all land in the same composite. The line is behavioral: when a caller would branch on the failure, render it separately, or count it, it is worth a type. The single-argument template overloads (Causes.forOneValue(String) and friends) belong to this same ad-hoc tier; in domain code a parameterized failure is worth naming, because the template form bakes its data into prose and discards it.
// Test failures - use .onSuccess(Assertions::fail)
@Test
void validation_fails_forInvalidInput() {
ValidRequest.validRequest(new Request("invalid", "bad"))
.onSuccess(Assertions::fail);
}
// Test successes - chain onFailure then onSuccess
@Test
void validation_succeeds_forValidInput() {
ValidRequest.validRequest(new Request("valid@example.com", "Valid1234"))
.onFailure(Assertions::fail)
.onSuccess(valid -> {
assertEquals("valid@example.com", valid.email().value());
});
}
// Async tests - use .await() first
@Test
void execute_succeeds_forValidInput() {
useCase.execute(request)
.await()
.onFailure(Assertions::fail)
.onSuccess(response -> {
assertEquals("expected", response.value());
});
}
JBCT uses Pragmatica Core 1.0.0-rc3 for functional types.
Maven (preferred):
<dependency>
<groupId>org.pragmatica-lite</groupId>
<artifactId>core</artifactId>
<version>1.0.0-rc3</version>
</dependency>
Gradle (only if explicitly requested):
implementation 'org.pragmatica-lite:core:1.0.0-rc3'
Library documentation: https://central.sonatype.com/artifact/org.pragmatica-lite/core
Check org.pragmatica.lang.vo BEFORE writing any value object β hand-rolling a VO that
duplicates a built-in (Email, Url, Uuid, NonBlankString, IsoDateTime) is a JBCT
violation. Catalog with factories and validation rules: see the vo/package-info.java source
header (Source-Anchored Chapters above). Build custom VOs only for domain-specific types
(OrderId, Username, ReferralCode).
Note: Email appears throughout this skill as a teaching example for writing validation
chains β in production code, use org.pragmatica.lang.vo.Email.
Static imports reduce code verbosity:
// Recommended static imports
import static org.pragmatica.lang.Result.all;
import static org.pragmatica.lang.Result.success;
import static org.pragmatica.lang.vo.Email.email; // built-in VO
import static com.example.domain.Password.password; // domain-specific VO
// Concise code
return all(email(raw), password(raw)).flatMap(ValidRequest::validRequest);
Use cause.result() and cause.promise() instead of Result.failure(cause):
// β
DO: Fluent style
return INVALID_EMAIL.result();
return USER_NOT_FOUND.promise();
// β DON'T: Static factory style
return Result.failure(INVALID_EMAIL);
return Promise.failure(USER_NOT_FOUND);
This skill provides quick reference and learning resources. For complex implementation and review tasks, use specialized subagents:
How to invoke: Use Task tool with subagent_type: "jbct-coder"
What it provides:
Result.all()How to invoke: Use Task tool with subagent_type: "jbct-reviewer"
What it provides:
Result.all()π‘ Tip: For automatic generation following this workflow, use the jbct-coder subagent.
β Using business exceptions instead of Result/Promise
β Nested records in use case factories (use lambdas)
β Void type parameter (use Unit; void return is OK for fire-and-forget)
β Promise<Result<T>> (redundant nesting)
β Separate validation methods (parse at construction)
β Public constructors on value objects
β Complex logic in lambdas (extract to methods)
β Validated prefix (use Valid)
π‘ Tip: For automated code review checking these mistakes, use the jbct-reviewer subagent.
Before considering JBCT code complete, verify ALL of these:
*Impl classesnull checks in business logicthrow/catch in business logicVoid type parameter (use Unit; void return OK for fire-and-forget)Result.failure() or Promise.failure() (use cause.result()/cause.promise())TypeName.typeName(...)Valid prefix (not Validated)NotFound, Failed, Expired)Result<T>This skill contains comprehensive guidance organized by topic:
Repository: https://github.com/siy/coding-technology