What You’ll Learn
- Complete use case from requirements to implementation
- How all patterns work together in practice
- Step-by-step evolutionary development
Prerequisites: Testing in Practice
Requirements
Use case: Register a new user account.
Inputs (raw):
- Email (string)
- Password (string)
- Referral code (optional string)
Outputs:
- User ID
- Confirmation token
Validation rules:
- Email: not null, valid format, lowercase normalized
- Password: not null, min 8 chars, at least one uppercase, one digit
- Referral code: optional; if present, must be exactly 6 uppercase alphanumeric characters
Cross-field rules:
- Email must not be registered yet
Steps:
- Validate input
- Check email uniqueness (async, database)
- Hash password (sync, expensive computation)
- Save the user to the database (async)
- Generate confirmation token (async, calls external service)
Step 1: Use Case Interface
package com.example.app.usecase.registeruser; import org.pragmatica.lang.*; public interface RegisterUser { record Request(String email, String password, String referralCode) {} record Response(UserId userId, ConfirmationToken token) {} Promise<Response> execute(Request request); interface CheckEmailUniqueness { Promise<ValidRequest> apply(ValidRequest valid); } interface CreateValidUser { Promise<ValidUser> apply(ValidRequest valid); } interface SaveUser { Promise<User> apply(ValidUser validUser); } interface GenerateToken { Promise<Response> apply(User user); } static RegisterUser registerUser(CheckEmailUniqueness checkEmail, CreateValidUser createValidUser, SaveUser saveUser, GenerateToken generateToken) { return request -> ValidRequest.validRequest(request) .async() .flatMap(checkEmail::apply) .flatMap(createValidUser::apply) .flatMap(saveUser::apply) .flatMap(generateToken::apply); } }
This is a Sequencer pattern: validate -> check uniqueness -> create user -> save -> generate token.
Why interface + factory? Every component — use case, step, adapter — is defined as an interface with a static factory method. This is not arbitrary convention:
- Substitutability: Anyone can implement the interface. Testing, stubbing incomplete implementations, swapping adapters — all work without framework magic or inheritance hierarchies.
- Implementation isolation: Each implementation is self-contained. No shared base classes, no abstract methods to override, no coupling between implementations. Each intersection between implementations is unnecessary coupling with corresponding maintenance overhead — up to needing deep understanding of two projects instead of one, with zero benefit.
- Disposable implementation: A local record or lambda returned by the factory can’t be referenced externally. The implementation is replaceable by definition. The interface is the design artifact; the implementation is incidental.
Step 2: Validated Request
record ValidRequest(Email email, Password password, Option<ReferralCode> referralCode) { public static Result<ValidRequest> validRequest(Request raw) { return Result.all(Email.email(raw.email()), Password.password(raw.password()), ReferralCode.referralCode(raw.referralCode())) .map(ValidRequest::new); } }
This is Fork-Join pattern: validate three fields independently, collect results.
Step 3: Value Objects
Email:
package com.example.app.domain.shared; public record Email(String value) { private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-z0-9+_.-]+@[a-z0-9.-]+$"); private static final Fn1<Cause, String> INVALID_EMAIL = Causes.forOneValue("Invalid email format: %s"); public static Result<Email> email(String raw) { return Verify.ensure(raw, Verify.Is::present) .map(String::trim) .map(String::toLowerCase) .filter(INVALID_EMAIL, EMAIL_PATTERN.asMatchPredicate()) .map(Email::new); } }
Password:
public record Password(String value) { private static final Cause TOO_SHORT = Causes.cause("Password must be at least 8 characters"); private static final Cause MISSING_UPPERCASE = Causes.cause("Password must contain uppercase letter"); private static final Cause MISSING_DIGIT = Causes.cause("Password must contain digit"); public static Result<Password> password(String raw) { return Verify.ensure(raw, Verify.Is::present) .filter(TOO_SHORT, s -> Verify.Is.lenBetween(s, 8, 128)) .flatMap(Password::ensureUppercase) .flatMap(Password::ensureDigit) .map(Password::new); } private static Result<String> ensureUppercase(String raw) { return contains(raw, Character::isUpperCase) ? Result.success(raw) : MISSING_UPPERCASE.result(); } private static Result<String> ensureDigit(String raw) { return contains(raw, Character::isDigit) ? Result.success(raw) : MISSING_DIGIT.result(); } private static boolean contains(CharSequence sequence, IntPredicate predicate) { return sequence.chars().anyMatch(predicate); } }
ReferralCode (optional-with-validation):
public record ReferralCode(String value) { private static final Pattern PATTERN = Pattern.compile("^[A-Z0-9]{6}$"); private static final Cause INVALID_FORMAT = Causes.cause("Invalid referral code format"); public static Result<Option<ReferralCode>> referralCode(String raw) { return Verify.ensureOption( Option.option(raw).map(String::trim).filter(s -> !s.isEmpty()), PATTERN.asMatchPredicate(), INVALID_FORMAT ).map(opt -> opt.map(ReferralCode::new)); } public boolean isPremium() { return value.startsWith("VIP"); } }
The Verify.ensureOption() method (Pragmatica Core 0.9.0+) handles Result<Option<ReferralCode>> elegantly: if the Option is empty, succeeds with Option.none(); if present and valid, succeeds with Option.some(value); if present and invalid, fails.
Step 4: Step Implementations
CheckEmailUniqueness (adapter leaf):
interface CheckEmailUniqueness { Promise<ValidRequest> apply(ValidRequest request); static CheckEmailUniqueness checkEmailUniqueness(UserRepository repository) { return request -> repository.findByEmail(request.email()) .flatMap(user -> checkPresence(user, request)); } static Promise<ValidRequest> checkPresence(Option<User> user, ValidRequest request) { return user.isPresent() ? RegistrationError.General.EMAIL_ALREADY_REGISTERED.promise() : Promise.success(request); } }
HashPassword (business leaf):
interface HashPassword { Result<HashedPassword> apply(Password password); static HashPassword hashPassword(BCryptPasswordEncoder encoder) { return password -> Result.lift1(RegistrationError.PasswordHashingFailed::new, encoder::encode, password.value()) .map(HashedPassword::new); } }
SaveUser (adapter leaf):
class JooqUserRepository implements SaveUser { private final DSLContext dsl; public Promise<User> apply(ValidUser user) { return Promise.lift(RepositoryError.DatabaseFailure::cause, () -> saveUser(user)); } private User saveUser(ValidUser user) { String id = dsl.insertInto(USERS) .set(USERS.EMAIL, user.email().value()) .set(USERS.PASSWORD_HASH, user.hashed().value()) .set(USERS.REFERRAL_CODE, user.refCode().map(ReferralCode::value).or(null)) .returningResult(USERS.ID) .fetchSingle() .value1(); return new User(new UserId(id), user.email()); } }
GenerateToken (adapter leaf):
class TokenServiceClient implements GenerateToken { private final HttpClient httpClient; public Promise<Response> apply(User user) { return httpClient.post("/tokens/confirm", Map.of("userId", user.id().value())) .map(resp -> buildResponse(user.id(), resp)) .mapError(this::mapTokenError); } private Response buildResponse(UserId userId, Map<String, String> resp) { return new Response(userId, new ConfirmationToken(resp.get("token"))); } private Cause mapTokenError(Cause cause) { return RegistrationError.General.TOKEN_GENERATION_FAILED; } }
Step 5: Error Types
public sealed interface RegistrationError extends Cause { enum General implements RegistrationError { EMAIL_ALREADY_REGISTERED("Email already registered"), WEAK_PASSWORD_FOR_PREMIUM("Premium codes require 10+ char passwords"), TOKEN_GENERATION_FAILED("Token generation failed"); private final String message; General(String message) { this.message = message; } @Override public String message() { return message; } } record PasswordHashingFailed(Throwable cause) implements RegistrationError { @Override public String message() { return "Password hashing failed: " + Causes.fromThrowable(cause); } } }
Sealed interface ensures exhaustive pattern matching.
Step 6: Tests
Validation tests:
@Test void validRequest_fails_forInvalidEmail() { var request = new Request("not-an-email", "Valid1234", null); ValidRequest.validRequest(request) .onSuccess(Assertions::fail); } @Test void validRequest_succeeds_forValidInput() { var request = new Request("[email protected]", "Valid1234", "ABC123"); ValidRequest.validRequest(request) .onFailure(Assertions::fail) .onSuccess(valid -> { assertEquals("[email protected]", valid.email().value()); assertTrue(valid.referralCode().isPresent()); }); }
Happy path test:
@Test void execute_succeeds_forValidInput() { CheckEmailUniqueness checkEmail = req -> Promise.success(req); CreateValidUser createUser = req -> Promise.success( new ValidUser(req.email(), new HashedPassword("hashed"), req.referralCode())); SaveUser saveUser = user -> Promise.success(new User(new UserId("user-123"), user.email())); GenerateToken generateToken = user -> Promise.success( new Response(user.id(), new ConfirmationToken("token-456"))); var useCase = RegisterUser.registerUser(checkEmail, createUser, saveUser, generateToken); var request = new Request("[email protected]", "Valid1234", null); useCase.execute(request) .await() .onFailure(Assertions::fail) .onSuccess(response -> { assertEquals("user-123", response.userId().value()); assertEquals("token-456", response.token().value()); }); }
Failure scenario:
@Test void execute_fails_whenEmailAlreadyExists() { CheckEmailUniqueness checkEmail = req -> RegistrationError.General.EMAIL_ALREADY_REGISTERED.promise(); // ... other stubs useCase.execute(request) .await() .onSuccess(Assertions::fail); }
Pattern Summary
| Component | Pattern | Purpose |
|---|---|---|
| RegisterUser.execute | Sequencer | Chain dependent steps |
| ValidRequest.validRequest | Fork-Join | Validate independent fields |
| Email.email | Leaf | Atomic validation |
| Password.password | Leaf | Atomic validation with Sequencer internally |
| CheckEmailUniqueness | Leaf + Condition | Database check with branching |
| SaveUser | Adapter Leaf | Database write |
| RegistrationError | Sealed Interface | Typed error hierarchy |
Key Takeaways
- Use case interface - Contains factory, step interfaces, types
- Sequencer for main flow - Chain steps with flatMap
- Fork-Join for validation - Accumulate errors with Result.all()
- Value objects as Leaves - Parse-don’t-validate
- Adapters implement step interfaces - JOOQ, HTTP clients
- Tests use stubs - Only adapters stubbed
Exercises
See Appendix B for exercises on:
- Exercise 5.1: Complete Use Case Design
What’s Next
Complete Example - PlaceOrder presents another complete example - PlaceOrder - demonstrating more complex patterns including Fork-Join for parallel data fetching.