Part 2: Core Principles
Series: Java Backend Coding Technology | Part: 2 of 5
Previous: Part 1: Introduction & Foundations | Next: Part 3: Basic Patterns & Structure
Overview
This part teaches the non-negotiable foundation of the technology: four return types and three core principles. Everything else in this series builds on these concepts.
By the end of this part, youâll understand:
- Why exactly four return types are all you need
- How to make invalid states unrepresentable through parsing
- Why business logic never throws exceptions
- How to compose operations without nesting complexity
These principles compress design decisions into mechanical rules. Master them, and the patterns in later parts become obvious.
The Four Return Kinds
Every function in this technology returns exactly one of four types. Not âusuallyâ or âpreferablyâ - exactly one, always. This isnât arbitrary restriction; itâs intentional compression of complexity into type signatures.
Why by criteria:
- Mental Overhead: Hidden error channels (exceptions), hidden optionality (null), hidden asynchrony (blocking I/O) force remembering behavior not in signatures. Explicit types eliminate this (+3).
- Reliability: Compiler verifies error handling, null safety, and async boundaries when encoded in types (+3).
- Complexity: Four types cover all scenarios - no guessing about combinations (+2).
T - Synchronous, Cannot Fail, Value Always Present
Use this when the operation is pure computation with no possibility of failure or missing data. Mathematical calculations, transformations of valid data, simple getters. If you canât think of a way this function could fail or return nothing, it returns T.
public record FullName(String value) {
public String initials() { // returns String (T)
return value.chars()
.filter(Character::isUpperCase)
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
.toString();
}
}
The signature String initials() tells you: this always succeeds, always returns a value, completes immediately.
Option<T> - Synchronous, Cannot Fail, Value May Be Missing
Use this when absence is a valid outcome, but failure isnât possible. Lookups that might not find anything, optional configuration, nullable database columns when null is semantically meaningful (not just âwe donât knowâ). The key: missing data is normal business behavior, not an error.
// Finding an optional user preference
public interface PreferenceRepository {
Option<Theme> findThemePreference(UserId id); // might not be set
}
The signature Option<Theme> tells you: this always succeeds, but the value might be absent. The caller must handle both cases (some() and none()).
Result<T> - Synchronous, Can Fail, Represents Business/Validation Errors
Use this when an operation might fail for business or validation reasons. Parsing input, enforcing invariants, business rules that can be violated. Failures are represented as typed Cause objects, not exceptions. Every failure path is explicit in the return type.
public record Email(String value) {
private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+{{CONTENT}}quot;);
private static final Fn1<Cause, String> INVALID_EMAIL = Causes.forValue("Invalid email format: {}");
public static Result<Email> email(String raw) {
return Verify.ensure(raw, Verify.Is::notNull)
.map(String::trim)
.flatMap(Verify.ensureFn(INVALID_EMAIL, Verify.Is::matches, EMAIL_PATTERN))
.map(Email::new);
}
}
The signature Result<Email> tells you: this might fail (invalid format), completes immediately, failure is typed (not an exception).
Promise<T> - Asynchronous, Can Fail, Represents Eventual Success or Failure
Use this for any I/O operation, external service call, or computation that might block. Promise<T> is semantically equivalent to Result<T> but asynchronous - failures are carried in the Promise itself, not nested inside it. This is Javaâs answer to Rustâs Future<Result<T>> without the nesting problem.
public interface AccountRepository {
Promise<Account> findById(AccountId id); // async lookup, can fail
}
The signature Promise<Account> tells you: this completes later (async), might fail (network, database), failure is carried in the Promise.
Why Exactly Four?
These four types form a complete basis for composition. You can lift âupâ when needed (Option to Result to Promise), but you never nest the same concern twice (Promise<Result<T>> is forbidden). Each type represents one orthogonal concern:
- Synchronous vs. asynchronous: now vs. later
- Can fail vs cannot fail: error channel present or absent
- Value vs optional value: presence guaranteed or not
Traditional Java mixes these concerns. A method returning User might throw exceptions (hidden error channel), return null (hidden optionality), or block on I/O (hidden asynchrony). You canât tell from the signature. With these four types, the signature tells you everything about the functionâs behavior before you read a line of implementation.
Decision table:
| Sync? | Can Fail? | May Be Absent? | Return Type |
|---|---|---|---|
| Yes | No | No | T |
| Yes | No | Yes | Option<T> |
| Yes | Yes | No | Result<T> |
| Async | Yes | No | Promise<T> |
This clarity is what makes AI-assisted development tractable. When generating code, an AI doesnât need to infer whether error handling is needed - the return type declares it. When reading code, a human doesnât need to trace execution paths to find hidden failure modes - theyâre in the type signature.
Quick Reference: Choosing the Right Type
Return T when:
- Pure computation (e.g.,
calculateTotal,formatCurrency) - Transformation of already-valid data (e.g.,
toUpperCase,extractId) - Getters for required fields (e.g.,
user.email(),order.total())
Return Option<T> when:
- Lookup might not find anything (e.g.,
findByUsername) - Field is genuinely optional in the domain (e.g.,
user.middleName()) - âNot foundâ is a normal outcome, not an error
Return Result<T> when:
- Validating input (e.g.,
Email.email(raw)) - Enforcing business rules (e.g.,
checkInvariant) - Parsing or constructing domain objects (e.g.,
OrderId.orderId(raw))
Return Promise<T> when:
- Any I/O operation (database, HTTP, file system)
- External service calls
- Operations that might block or take time
Parse, Donât Validate
Most Java code validates data after construction. You create an object with raw values, then call a validate() method that might throw exceptions or return error lists. This is backwards.
The principle: Make invalid states unrepresentable. If construction succeeds, the object is valid by definition. Validation is parsing - converting untyped or weakly-typed input into strongly typed domain objects that enforce invariants at the type level.
Why by criteria:
- Mental Overhead: No âremember to validateâ - type system guarantees validity (+2).
- Reliability: Compiler enforces that invalid objects cannot be constructed (+3).
- Design Impact: Business invariants concentrated in factories, not scattered (+2).
- Complexity: Single validation point per type eliminates redundant checks (+1).
The Traditional (Wrong) Approach
// DON'T: Validation separated from construction
public class Email {
private final String value;
public Email(String value) {
this.value = value; // accepts anything
}
public boolean isValid() { // The caller must remember to check
return value != null && value.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+{{CONTENT}}quot;);
}
}
// Client code must validate manually:
Email email = new Email(input);
if (!email.isValid()) {
throw new ValidationException("Invalid email");
}
Problems:
- You can construct invalid
Emailobjects - Validation is a separate step that callers might forget
- The
isValid()method returns a boolean, discarding information about whatâs wrong - You canât distinguish ânullâ from âmalformedâ from âtoo longâ without checking conditions individually
The Parse-Donât-Validate Approach
// DO: Validation IS construction
public record Email(String value) {
private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+{{CONTENT}}quot;);
private static final Fn1<Cause, String> INVALID_EMAIL = Causes.forValue("Invalid email format: {}");
public static Result<Email> email(String raw) {
return Verify.ensure(raw, Verify.Is::notNull)
.map(String::trim)
.flatMap(Verify.ensureFn(INVALID_EMAIL, Verify.Is::matches, EMAIL_PATTERN))
.map(Email::new);
}
}
// Client code gets the Result:
Result<Email> result = Email.email(input);
// If this is a Success, the Email is valid. Guaranteed.
The constructor is private (or package-private). The only way to get an Email is through the static factory email(), which returns Result<Email>. If you have an Email instance, itâs valid - no separate check needed. The type system enforces this.
Note: As of current Java versions, records do not support declaring the canonical constructor as private. This limitation means the constructor remains accessible within the same package. Future Java versions may address this. Until then, rely on team discipline and code review to ensure value objects are only constructed through their factory methods. The good news: violations are highly visible in code - since all components are normally constructed via factory methods, any direct new Email(...) call stands out immediately. This makes the issue easy to catch using automated static analysis checks or by instructing AI code review tools to flag direct constructor usage for value objects.
Naming Conventions
Factory naming: Factories are always named after their type, lowercase-first (camelCase). This creates a natural, readable call site: Email.email(...), Password.password(...), AccountId.accountId(...).
Itâs slightly redundant but:
- Unambiguous: You know exactly whatâs being created
- Grep-friendly: Searching for âEmail.emailâ finds all construction sites
- Allows static imports:
import static Email.emaillets you writeemail(raw)while preserving context
Validated input naming: Use the Valid prefix (not Validated) for types representing validated inputs or intermediate data:
// DO: Use Valid prefix
record ValidRequest(Email email, Password password) { ... }
record ValidUser(Email email, HashedPassword hashed) { ... }
// DON'T: Use Validated prefix (too verbose, no additional semantics)
record ValidatedRequest(...)
record ValidatedUser(...)
The Valid prefix is concise and conveys the same meaning. The past-tense Validated adds no semantic value - both indicate data has passed validation
// Usage examples
Result<Email> email = Email.email("[email protected]");
Result<Password> password = Password.password("Secret123");
Result<UserId> userId = UserId.userId("abc-123");
Optional Fields with Validation
What if a field is optional but must be valid when present? For example, a referral code thatâs not required but must match a pattern if provided.
Use Result<Option<T>> - validation can fail (Result), and if it succeeds, the value might be absent (Option).
public record ReferralCode(String value) {
private static final String PATTERN = "^[A-Z0-9]{6}{{CONTENT}}quot;;
public static Result<Option<ReferralCode>> referralCode(String raw) {
return isAbsent(raw)
? Result.success(Option.none())
: validatePresent(raw);
}
private static boolean isAbsent(String raw) {
return raw == null || raw.isEmpty();
}
private static Result<Option<ReferralCode>> validatePresent(String raw) {
return Verify.ensure(raw.trim(), Verify.Is::matches, PATTERN)
.map(ReferralCode::new)
.map(Option::some);
}
}
If raw is null or empty, we succeed with Option.none(). If itâs present, we validate and wrap in Option.some(). If validation fails, the Result itself is a failure.
Caller semantics are crystal clear:
Failure(cause): Invalid input (provided but doesnât match pattern)Success(None): No value provided (valid state)Success(Some(code)): Valid code provided
Normalization in Factories
Factories can normalize input (trim whitespace, lowercase email domains, etc.) as part of parsing. This keeps invariants in one place and ensures all instances are normalized consistently.
public static Result<Email> email(String raw) {
return Verify.ensure(raw, Verify.Is::notNull)
.map(String::trim) // Normalize: remove whitespace
.map(String::toLowerCase) // Normalize: lowercase for comparison
.flatMap(Verify.ensureFn(INVALID_EMAIL, Verify.Is::matches, EMAIL_PATTERN))
.map(Email::new);
}
Now all Email instances are guaranteed to be trimmed and lowercased. Domain logic never worries about case-insensitive comparison or leading/trailing spaces - itâs handled once, at construction time.
Why This Matters for AI
When an AI generates a value object, the structure is mechanical:
- Private constructor
- Static factory named after type
Result<T>orResult<Option<T>>return type- Validation via
Verifycombinators - Normalization in the pipeline
No guessing about where validation happens or how errors are reported. The AI learns the pattern once and applies it consistently.
No Business Exceptions
Business failures are not exceptional - theyâre expected outcomes of business rules. An invalid email isnât an exception; itâs a normal case of bad input. An account being locked isnât an exception; itâs a business state.
The rule: Business logic never throws exceptions for business failures. All failures flow through Result or Promise as typed Cause objects.
Why by criteria:
- Mental Overhead: Checked exceptions pollute signatures (+1 for Result). Unchecked are invisible - must read implementation (+2 for Result).
- Business/Technical Ratio: Stack traces are technical noise; typed Causes are domain concepts (+2 for Result).
- Reliability: Exceptions bypass type checker; Result makes all failures explicit and compiler-verified (+3 for Result).
- Complexity: Exception hierarchies create cross-package coupling (+1 for Result).
The Traditional (Wrong) Approach
// DON'T: Exceptions for business logic
public User loginUser(String email, String password) throws
InvalidEmailException,
InvalidPasswordException,
AccountLockedException,
CredentialMismatchException {
if (!isValidEmail(email)) {
throw new InvalidEmailException(email);
}
if (!isValidPassword(password)) {
throw new InvalidPasswordException();
}
User user = userRepo.findByEmail(email)
.orElseThrow(() -> new CredentialMismatchException());
if (user.isLocked()) {
throw new AccountLockedException(user.getId());
}
if (!passwordMatches(user, password)) {
throw new CredentialMismatchException();
}
return user;
}
Problems:
- Checked exceptions pollute signatures and force callers to handle or rethrow
- Unchecked exceptions are invisible in signatures - you canât tell what might fail without reading implementation
- Exception hierarchies create coupling
- Stack traces are expensive and often irrelevant for business failures
- Testing requires catching exceptions and inspecting types
The Result-Based Approach
// DO: Failures as typed values
public Result<User> loginUser(String emailRaw, String passwordRaw) {
return Result.all(Email.email(emailRaw),
Password.password(passwordRaw))
.flatMap(this::validateAndCheckStatus);
}
private Result<User> validateAndCheckStatus(Email email, Password password) {
return checkCredentials(email, password)
.flatMap(this::checkAccountStatus);
}
private Result<User> checkCredentials(Email email, Password password) {
return userRepo.findByEmail(email)
.flatMap(user -> validatePassword(user, password));
}
private Result<User> validatePassword(User user, Password password) {
return passwordMatches(user, password)
? Result.success(user)
: LoginError.InvalidCredentials.INSTANCE.result();
}
private Result<User> checkAccountStatus(User user) {
return user.isLocked()
? new LoginError.AccountLocked(user.id()).result()
: Result.success(user);
}
Every failure is a Cause. The LoginError is a sealed interface defining the failure modes:
public sealed interface LoginError extends Cause {
record AccountLocked(UserId userId) implements LoginError {
@Override
public String message() {
return "Account is locked: " + userId;
}
}
enum InvalidCredentials implements LoginError {
INSTANCE;
@Override
public String message() {
return "Invalid email or password";
}
}
}
Composite Failures: Collecting All Errors
Failures compose: Result.all(Email.email(...), Password.password(...)) collects validation failures into a CompositeCause automatically. If both email and password are invalid, the caller gets both errors, not just the first one encountered.
// If both fail:
Result.all(Email.email("not-an-email"),
Password.password("weak"))
.flatMap(this::processLogin);
// Returns: CompositeCause([
// "Invalid email format: not-an-email",
// "Password must be at least 8 characters"
// ])
This is far superior to traditional exception-based approaches where you only learn about one error at a time, forcing users to fix-and-retry repeatedly.
Adapter Exceptions: The Boundary
Foreign code (libraries, frameworks, databases) throws exceptions. Adapter leaves catch these and convert them to Cause objects. Business logic never sees foreign exceptions.
The Pragmatica library provides lift() methods for each monad type to handle exception-to-Cause conversion:
public interface UserRepository {
Promise<Option<User>> findByEmail(Email email);
}
// Implementation (adapter leaf)
class JpaUserRepository implements UserRepository {
public Promise<Option<User>> findByEmail(Email email) {
return Promise.lift(
RepositoryError::fromDatabaseException,
() -> entityManager.createQuery("SELECT u FROM User u WHERE u.email = :email", UserEntity.class)
.setParameter("email", email.value())
.getResultList()
.stream()
.findFirst()
.map(this::toDomain)
.orElse(Option.none())
);
}
}
The lift() methods handle try-catch boilerplate and exception-to-Cause conversion automatically via the provided exception-to-cause mapping function. Each monad type provides its own lift() method: Option.lift(), Result.lift(), and Promise.lift().
The adapter wraps checked PersistenceException in a domain Cause (RepositoryError.DatabaseFailure). Business logic never sees PersistenceException - only domain errors.
Benefits
Errors are just data: You compose them with map, flatMap, and all() like any other value.
Testing is easy: Assert on Cause types without catching exceptions:
@Test
void loginUser_fails_forInvalidEmail() {
loginUser("not-an-email", "Valid1234")
.onSuccess(Assertions::fail); // Should not succeed
}
AI generation is mechanical: The pattern is always the same:
SomeCause.INSTANCE.result()for ResultSomeCause.INSTANCE.promise()for Promise
No decisions about checked vs unchecked, when to catch, how to wrap.
Monadic Composition Rules
The four return kinds compose via map, flatMap, filter, and aggregation combinators (all, any). Understanding when to lift and how to avoid nesting is essential.
Lifting: Moving Between Types
You can lift a âlowerâ type into a âhigherâ one at call sites:
TâOption<T>(viaOption.option(value))TâResult<T>(viaResult.success(value))TâPromise<T>(viaPromise.success(value))Option<T>âResult<T>(viaoption.toResult(cause)oroption.await(cause))Option<T>âPromise<T>(viaoption.async(cause)oroption.async())Result<T>âPromise<T>(viaresult.async())
You lift when composing functions that return different types:
// Sync validation (Result) lifted into async flow (Promise)
public Promise<Response> execute(Request request) {
return ValidRequest.validate(request)
.async() // Result has dedicated async() method to convert to Promise
.flatMap(step1::apply) // step1 returns Promise
.flatMap(step2::apply); // step2 returns Promise
}
Forbidden Nesting: Donât Nest the Same Concern Twice
Promise<Result<T>> is forbidden. Promise<T> already carries failures - nesting Result inside creates two error channels and forces callers to unwrap twice. If a function is async and can fail, it returns Promise<T>, period.
Wrong:
// DON'T: Nested error channels
Promise<Result<User>> loadUser(UserId id) { /* ... */ }
// Caller must unwrap twice:
loadUser(id)
.flatMap(resultUser -> resultUser.match(
user -> Promise.success(user),
Cause::promise
)); // Absurd ceremony
Right:
// DO: One error channel
Promise<User> loadUser(UserId id) { /* ... */ }
// Caller just chains:
return loadUser(id).flatMap(nextStep);
Allowed Nesting: Result<Option>
Result<Option<T>> is permitted sparingly for âoptional value that can fail validation.â This represents: âIf present, must be valid. If absent, thatâs fine.â
Example: optional referral code that must match a pattern when provided.
Result<Option<ReferralCode>> refCode = ReferralCode.referralCode(input);
// Success(None) = not provided, valid
// Success(Some(code)) = provided and valid
// Failure(cause) = provided but invalid
Avoid Option<Result<T>> - it means âmaybe thereâs a result, and that result might have failed,â which is backwards. Just use Result<Option<T>>.
Aggregation: Combining Independent Operations
Use Result.all(...) or Promise.all(...) to combine multiple independent operations:
// Validation: collect multiple field validations
Result<ValidRequest> validated = Result.all(Email.email(raw.email()),
Password.password(raw.password()),
ReferralCode.referralCode(raw.referralCode()))
.flatMap(ValidRequest::new);
// Async: run independent queries in parallel
Promise<Report> report = Promise.all(userRepo.findById(userId),
orderRepo.findByUser(userId),
inventoryService.getAvailableItems())
.flatMap(this::generateReport);
If any input fails, all() fails immediately (fail-fast for Promise) or collects failures (CompositeCause for Result).
Composition Cheat Sheet
| You have | You need | Use |
|---|---|---|
T |
Option<T> |
Option.option(value) |
T |
Result<T> |
Result.success(value) |
T |
Promise<T> |
Promise.success(value) |
Option<T> |
Result<T> |
.toResult(cause) or .await(cause) |
Option<T> |
Promise<T> |
.async(cause) |
Result<T> |
Promise<T> |
.async() |
Multiple Result<T> |
Single Result |
Result.all(...) |
Multiple Promise<T> |
Single Promise |
Promise.all(...) |
Why These Rules?
They prevent complexity explosion. With exactly four return types and clear composition rules, you can always tell how to combine two functions by looking at their signatures. AI code generation becomes mechanical - given input and output types, thereâs one obvious way to compose.
Summary: The Foundation is Set
You now understand the core principles that make everything else possible:
Four return types:
T: Sync, canât fail, always presentOption<T>: Sync, canât fail, may be absentResult<T>: Sync, can fail, always present if successPromise<T>: Async, can fail
Three principles:
- Parse, donât validate: Make invalid states unrepresentable via factory methods
- No business exceptions: Errors are typed Cause values, not thrown
- Composition rules: Lift when needed, never nest the same concern twice
These arenât preferences or guidelines. Theyâre mechanical rules that make structural decisions for you. When you follow them, code becomes predictable for humans and AI alike.
Whatâs Next?
In Part 3: Basic Patterns & Structure, weâll learn the structural rules and basic patterns that handle 80% of daily coding:
- Single Pattern Per Function: one responsibility, mechanical refactoring
- Single Level of Abstraction: no complex logic in lambdas
- Leaf: the atomic unit of processing
- Condition: branching as values
- Iteration: functional collection processing
These patterns apply the core principles you just learned. Once you master them, youâll have a complete toolkit for writing clear, testable business logic.
Series Navigation
â Part 1: Introduction & Foundations | Index | Part 3: Basic Patterns & Structure â
Version: 1.0.0 (2025-10-05) | Part of: Java Backend Coding Technology Series