Part 9: Building Production Systems
Series: Java Backend Coding Technology | Part: 9 of 9
Previous: Part 8: Testing in Practice | Complete Series: Index
Overview
Youβve learned the principles, patterns, and testing approaches. Now weβll bring everything together by building a complete production system from requirements to deployment.
By the end of this part, youβll see:
- Complete Use Case: RegisterUser from requirements through implementation and tests
- Project Structure: How to organize packages and modules for vertical slicing
- Framework Integration: Connecting functional code to Spring Boot and JOOQ
- Next Steps: Where to go from here
This is where theory becomes practice.
Complete Use Case Walkthrough
Letβs build RegisterUser from scratch, following the technology step-by-step.
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)
Async flow: Steps 2, 4, 5 are async. Use Promise<Response>.
Step 1: Package and Use Case Interface
Package: com.example.app.usecase.registeruser
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 β hash password β save β generate token. Five steps, clearly defined.
Step 2: Validated Request
Nested record with the factory method:
record ValidRequest(Email email, Password password, Option<ReferralCode> referralCode) {
// From raw Request: parse per-field VOs
public static Result<ValidRequest> validRequest(Request raw) {
return Result.all(Email.email(raw.email()),
Password.password(raw.password()),
ReferralCode.referralCode(raw.referralCode()))
.flatMap(ValidRequest::new);
}
}
This is Fork-Join pattern: validate three fields independently, collect results. If all succeed, construct ValidRequest. If any fail, collect all errors in CompositeCause.
Step 3: Value Objects (Business Leaves)
Email:
package com.example.app.domain.shared;
import org.pragmatica.lang.*;
public record Email(String value) {
// private Email {} // Not yet supported in Java
private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-z0-9+_.-]+@[a-z0-9.-]+{{CONTENT}}quot;);
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::notNull)
.map(String::trim)
.map(String::toLowerCase)
.flatMap(Verify.ensureFn(INVALID_EMAIL, Verify.Is::matches, EMAIL_PATTERN))
.map(Email::new);
}
}
Leaf pattern: atomic validation, returns Result
Password:
package com.example.app.domain.shared;
import org.pragmatica.lang.*;
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::notNull)
.flatMap(Verify.ensureFn(TOO_SHORT, Verify.Is::lenBetween, 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);
}
public int length() {
return value.length();
}
public boolean contains(Username username) {
return value.toLowerCase().contains(username.value().toLowerCase());
}
}
ReferralCode (optional-with-validation):
package com.example.app.domain.shared;
import org.pragmatica.lang.*;
public record ReferralCode(String value) {
// private ReferralCode {} // Not yet supported in Java
private static final String REFERRAL_PATTERN = "^[A-Z0-9]{6}{{CONTENT}}quot;;
public static Result<Option<ReferralCode>> referralCode(String raw) {
return switch (raw) {
case null, "" -> Result.success(Option.none());
default -> Verify.ensure(raw.trim(), Verify.Is::matches, REFERRAL_PATTERN)
.map(ReferralCode::new)
.map(Option::some);
};
}
public boolean isPremium() {
return value.startsWith("VIP");
}
}
Returns Result<Option<ReferralCode>>: validation can fail (Result), and if successful, value may be absent (Option).
All three live in com.example.app.domain.shared because theyβre reusable across use cases.
Step 4: Step Interfaces
// Step 1: Check email uniqueness
public interface CheckEmailUniqueness {
Promise<ValidRequest> apply(ValidRequest request);
}
// Step 2: Hash password (sync, so we lift in the sequencer)
public interface HashPassword {
Result<HashedPassword> apply(Password password);
}
// Step 3: Save the user
public interface SaveUser {
Promise<UserId> apply(ValidUser user);
}
// Step 4: Generate a confirmation token
public interface GenerateToken {
Promise<Response> apply(UserId userId);
}
Supporting types:
record ValidUser(Email email, HashedPassword hashed, Option<ReferralCode> refCode) {}
record HashedPassword(String value) {}
record UserId(String value) {}
record ConfirmationToken(String value) {}
Step 5: 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);
}
}
Condition pattern: check if email exists, branch to success or failure.
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);
}
}
Uses Result.lift1 to handle potential exceptions from BCrypt.
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).orElse(null))
.returningResult(USERS.ID)
.fetchSingle()
.value1();
return new User(new UserId(id), user.email());
}
}
Uses Promise.lift to handle JOOQ exceptions, converts to domain Cause.
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))
.recover(this::mapTokenError);
}
private Response buildResponse(UserId userId, Map<String, String> resp) {
return new Response(userId, new ConfirmationToken(resp.get("token")));
}
private Promise<Response> mapTokenError(Throwable err) {
return RegistrationError.General.TOKEN_GENERATION_FAILED.promise();
}
}
Step 6: Errors
package com.example.app.usecase.registeruser;
import org.pragmatica.lang.Cause;
public sealed interface RegistrationError extends Cause {
enum General implements RegistrationError {
EMAIL_ALREADY_REGISTERED("Email already registered"),
WEAK_PASSWORD_FOR_PREMIUM("Premium referral codes require passwords of at least 10 characters"),
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. Each error is a typed value, not an exception.
Step 7: Testing
Note: This section shows basic test examples. For comprehensive testing strategy including evolutionary testing, test organization, and utilities, see Part 7: Testing Philosophy and Part 8: Testing in Practice.
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_fails_forWeakPassword() {
var request = new Request("[email protected]", "weak", null);
ValidRequest.validRequest(request)
.onSuccess(Assertions::fail);
}
@Test
void validRequest_fails_forInvalidReferralCode() {
var request = new Request("[email protected]", "Valid1234", "abc");
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 (with stubs):
@Test
void execute_succeeds_forValidInput() {
CheckEmailUniqueness checkEmail = req -> Promise.success(req);
HashPassword hashPassword = pwd -> Result.success(new HashedPassword("hashed"));
SaveUser saveUser = user -> Promise.success(new UserId("user-123"));
GenerateToken generateToken = id -> Promise.success(
new Response(id, new ConfirmationToken("token-456"))
);
var useCase = RegisterUser.registerUser(checkEmail, hashPassword, 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.EmailAlreadyRegistered.INSTANCE.promise();
HashPassword hashPassword = pwd -> Result.success(new HashedPassword("hashed"));
SaveUser saveUser = user -> Promise.success(new UserId("user-123"));
GenerateToken generateToken = id -> Promise.success(
new Response(id, new ConfirmationToken("token-456"))
);
var useCase = RegisterUser.registerUser(checkEmail, hashPassword, saveUser, generateToken);
var request = new Request("[email protected]", "Valid1234", null);
useCase.execute(request)
.await()
.onSuccess(Assertions::fail);
}
Project Structure & Package Organization
Vertical Slicing Philosophy
This technology organizes code around vertical slices - each use case is self-contained with its own business logic, validation, and error handling. Unlike architectures that centralize all business logic into one functional core, we isolate business logic within each use case package. This creates clear boundaries and prevents coupling between unrelated features.
Why vertical slicing (by criteria):
- Complexity: Minimizes coupling between unrelated features - each slice independent (+3).
- Business/Technical Ratio: Package names reflect domain use cases, not technical layers (+2).
- Mental Overhead: All related code in one place - less navigation across packages (+2).
- Design Impact: Forces proper boundaries - business logic cannot leak between use cases (+2).
Package Structure
The standard package layout:
com.example.app/
βββ usecase/
β βββ registeruser/ # Use case 1 (vertical slice)
β β βββ RegisterUser.java # Use case interface + factory
β β βββ RegistrationError.java # Sealed error interface
β β βββ [internal types] # ValidRequest, intermediate records
β β
β βββ getuserprofile/ # Use case 2 (vertical slice)
β βββ GetUserProfile.java
β βββ ProfileError.java
β βββ [internal types]
β
βββ domain/
β βββ shared/ # Reusable value objects only
β βββ Email.java
β βββ Password.java
β βββ UserId.java
β βββ [other VOs]
β
βββ adapter/
β βββ rest/ # Inbound adapters (HTTP)
β β βββ UserController.java
β β βββ [other controllers]
β β
β βββ persistence/ # Outbound adapters (DB, external APIs)
β βββ JooqUserRepository.java
β βββ [other repositories]
β
βββ config/ # Framework configuration
βββ UseCaseConfig.java
βββ [other configs]
Package Placement Rules
Use Case Packages (com.example.app.usecase.<usecasename>):
- Use case interface and factory method
- Error types specific to this use case (sealed interface)
- Step interfaces (nested in use case interface)
- Internal validation types (ValidRequest, intermediate records)
- Rule: If a type is used only by this use case, it stays here
Domain Shared (com.example.app.domain.shared):
- Value objects reused across multiple use cases
- Rule: Move here immediately when a second use case needs the same value object
- Anti-pattern: Donβt create this upfront - let reuse drive the move
Adapter Packages (com.example.app.adapter.*):
adapter.rest- HTTP controllers, request/response DTOsadapter.persistence- Database repositories, ORM entitiesadapter.messaging- Message queue consumers/producersadapter.external- HTTP clients for external services- Rule: Adapters implement step interfaces from use cases
Config Package (com.example.app.config):
- Spring/framework configuration
- Bean wiring, dependency injection setup
- Rule: No business logic, only infrastructure configuration
Module Organization (Optional)
For larger systems, split into Gradle/Maven modules:
:domain # Pure Java - value objects, no framework deps
:application # Use cases and step interfaces
:adapters # All adapter implementations
:bootstrap # Main class, configuration, framework setup
When to use modules:
- Team size > 5 developers
- Multiple deployment units from same codebase
- Enforcing compile-time dependency boundaries
- Independent library publication
For smaller systems:
- Single module with packages is sufficient
- Simpler build, faster iteration
- Package discipline enforces boundaries
Key Principles
1. Vertical Slicing: Each use case package is a vertical slice containing everything needed for that feature. Business logic doesnβt leak across use case boundaries.
2. Minimal Sharing: Only share value objects when truly reusable. Premature sharing creates coupling.
3. Framework at Edges: Business logic (use cases, domain) has zero framework dependencies. Adapters and config handle framework integration.
4. Clear Dependencies:
- Use cases depend on: domain.shared
- Adapters depend on: use cases (implement step interfaces)
- Config depends on: use cases + adapters (wires them together)
- Never: use case depending on adapter, adapter depending on another adapter
5. Adapter Isolation: All I/O operations live in adapters. This enables framework swapping (Spring β Micronaut, JDBC β JOOQ) without touching business logic.
Example: Where Things Go
Creating a new Email value object:
- First use case: Put in
usecase.registeruserpackage - Second use case needs it: Move to
domain.shared
Creating a new use case:
com.example.app.usecase.updateprofile/
βββ UpdateProfile.java # Interface + factory
βββ UpdateError.java # Errors
βββ ValidUpdateRequest.java # Internal validation
Implementing database access:
com.example.app.adapter.persistence/
βββ JooqProfileRepository.java # implements UpdateProfile.SaveProfile
Wiring in Spring:
com.example.app.config/
βββ ProfileConfig.java # @Bean methods connecting pieces
Module Organization (Multi-Module Projects)
For larger projects or teams, splitting into multiple modules provides compile-time boundaries and clearer separation of concerns. This is optional - single-module projects work fine for most teams.
When to Use Modules
Consider multi-module structure when:
- Team size: 5+ developers working on same codebase
- Deployment units: Different components deploy independently (microservices)
- Compile-time enforcement: Need hard boundaries between layers (prevent accidental adapterβdomain dependencies)
- Build performance: Modules enable incremental compilation
- Reusability: Shared domain logic used across multiple applications
When single module is sufficient:
- Small to medium teams (< 5 developers)
- Monolithic deployment
- Package conventions provide sufficient structure
- Build time is acceptable (< 30 seconds)
Module Structure
Standard multi-module layout:
my-app/ # Root project
βββ my-app-domain/ # Module 1: Domain logic
β βββ src/main/java/
β βββ com.example.app/
β βββ domain/
β βββ shared/ # Shared value objects
β βββ Email.java
β βββ UserId.java
β βββ Money.java
βββ my-app-application/ # Module 2: Use cases
β βββ src/main/java/
β βββ com.example.app/
β βββ usecase/
β βββ registeruser/
β β βββ RegisterUser.java
β βββ getprofile/
β βββ GetProfile.java
βββ my-app-adapters/ # Module 3: Infrastructure
β βββ src/main/java/
β βββ com.example.app/
β βββ adapter/
β βββ rest/ # HTTP controllers
β βββ persistence/ # Database
β βββ messaging/ # Event bus
βββ my-app-bootstrap/ # Module 4: Main application
βββ src/main/java/
βββ com.example.app/
βββ Application.java # Spring Boot main
βββ config/ # Wiring
Module Dependencies
Dependency rules (enforced by build tool):
domain β (no dependencies)
β
application β domain
β
adapters β application, domain
β
bootstrap β adapters, application, domain
- domain: Pure value objects, no external dependencies
- application: Use cases, depends only on domain
- adapters: Implementation, depends on application & domain
- bootstrap: Assembly, depends on everything
Gradle example:
// settings.gradle
rootProject.name = 'my-app'
include 'my-app-domain'
include 'my-app-application'
include 'my-app-adapters'
include 'my-app-bootstrap'
// my-app-domain/build.gradle
dependencies {
implementation 'org.pragmatica-lite:core:0.8.3'
}
// my-app-application/build.gradle
dependencies {
implementation project(':my-app-domain')
implementation 'org.pragmatica-lite:core:0.8.3'
}
// my-app-adapters/build.gradle
dependencies {
implementation project(':my-app-domain')
implementation project(':my-app-application')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.jooq:jooq'
}
// my-app-bootstrap/build.gradle
dependencies {
implementation project(':my-app-domain')
implementation project(':my-app-application')
implementation project(':my-app-adapters')
implementation 'org.springframework.boot:spring-boot-starter'
}
Maven example:
<!-- Root pom.xml -->
<modules>
<module>my-app-domain</module>
<module>my-app-application</module>
<module>my-app-adapters</module>
<module>my-app-bootstrap</module>
</modules>
<!-- my-app-application/pom.xml -->
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>my-app-domain</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
Where Types Go
| Type | Module | Rationale |
|---|---|---|
| Shared value objects | domain |
Used across multiple use cases |
| Use case-specific value objects | application (inside use case package) |
Used by single use case |
| Use case interfaces | application |
Business logic orchestration |
| Step interfaces | application (inside use case) |
Part of use case |
| Adapter interfaces | application (inside use case) |
Contract for adapters |
| Adapter implementations | adapters |
Infrastructure concerns |
| Controllers/REST | adapters |
HTTP inbound |
| Repositories | adapters |
Database outbound |
| Configuration/wiring | bootstrap |
Assembly |
Benefits of Multi-Module
Compile-time safety:
// β This won't compile - adapters can't depend on each other
// (assuming proper module boundaries)
package com.example.app.adapter.rest;
import com.example.app.adapter.persistence.UserRepositoryImpl; // COMPILE ERROR
Incremental builds:
- Change in
domainβ rebuild application, adapters, bootstrap - Change in
adaptersβ rebuild only bootstrap (faster)
Deployment flexibility:
- Package
bootstrapas executable JAR - Reuse
domainin multiple applications - Deploy adapters separately (different databases per environment)
When NOT to Use Modules
Donβt use modules if:
- Single developer or very small team
- Build time already fast
- Package conventions working well
- Additional complexity not justified
Alternative: Package structure with ArchUnit tests to enforce boundaries:
// Single module with ArchUnit enforcement
@Test
void adapters_shouldNotDependOnEachOther() {
noClasses().that().resideInAPackage("..adapter.rest..")
.should().dependOnClassesThat().resideInAPackage("..adapter.persistence..")
.check(classes);
}
Module organization is a scaling strategy - adopt when needed, not prematurely.
Framework Integration
This technology is framework-agnostic, but you still need to connect it to the real world. Hereβs how to bridge the functional core to Spring Boot and JOOQ.
Complete Example: Spring REST β Use Case β JOOQ
Use Case: GetUserProfile - fetch a user profile by ID.
Layers:
- REST controller (adapter in)
- Use case (functional core)
- JOOQ repository (adapter out)
1. Use Case (Functional Core)
package com.example.app.usecase.getuserprofile;
import org.pragmatica.lang.*;
public interface GetUserProfile {
record Request(String userId) {}
record Response(String userId, String email, String displayName) {
static Response fromUser(User user) {
return new Response(user.id().value(), user.email().value(), user.displayName());
}
}
Promise<Response> execute(Request request);
interface FetchUser {
Promise<User> apply(UserId userId);
}
static GetUserProfile getUserProfile(FetchUser fetchUser) {
return request -> UserId.userId(request.userId())
.async()
.flatMap(fetchUser::apply)
.map(Response::fromUser);
}
}
Pure business logic. No framework dependencies.
2. REST Controller (Adapter In)
package com.example.app.adapter.rest;
import com.example.app.usecase.getuserprofile.*;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final GetUserProfile getUserProfile;
public UserController(GetUserProfile getUserProfile) {
this.getUserProfile = getUserProfile;
}
@GetMapping("/{userId}")
public ResponseEntity<?> getProfile(@PathVariable String userId) {
var request = new GetUserProfile.Request(userId);
return getUserProfile.execute(request)
.await() // Block (or use reactive types in real Spring WebFlux)
.fold(cause -> toErrorResponse(cause),
response -> ResponseEntity.ok(response));
}
private ResponseEntity<?> toErrorResponse(Cause cause) {
return switch (cause) {
case ProfileError.UserNotFound _ -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", cause.message()));
case ProfileError.InvalidUserId _ -> ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", cause.message()));
default -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
};
}
}
Thin adapter: extract path variable β create Request β call use case β map Response/Cause to HTTP.
3. JOOQ Repository (Adapter Out)
Production example: This uses the exception handling patterns introduced in Parts 2-3, demonstrating Promise.lift with JOOQ.
package com.example.app.adapter.persistence;
import com.example.app.usecase.getuserprofile.*;
import org.jooq.*;
import org.pragmatica.lang.*;
import org.springframework.stereotype.Repository;
import static com.example.db.tables.Users.USERS;
@Repository
public class JooqUserRepository implements GetUserProfile.FetchUser {
private final DSLContext dsl;
public JooqUserRepository(DSLContext dsl) {
this.dsl = dsl;
}
public Promise<User> apply(UserId userId) {
return Promise.lift(ProfileError.DatabaseFailure::cause,
() -> dsl.selectFrom(USERS)
.where(USERS.ID.eq(userId.value()))
.fetchOptional()
.flatMap(optRecord -> optRecord.map(this::toDomain)
.orElse(ProfileError.UserNotFound.INSTANCE.promise())));
}
private Promise<User> toDomain(Record record) {
return Result.all(UserId.userId(record.get(USERS.ID)),
Email.email(record.get(USERS.EMAIL)),
Result.success(record.get(USERS.DISPLAY_NAME)))
.map(User::new)
.async();
}
}
Wraps JOOQ exceptions in domain Causes. Business logic never sees DataAccessException.
4. Wiring (Spring Config)
package com.example.app.config;
import com.example.app.usecase.getuserprofile.*;
import com.example.app.adapter.persistence.JooqUserRepository;
import org.springframework.context.annotation.*;
@Configuration
public class UseCaseConfig {
@Bean
public GetUserProfile getUserProfile(JooqUserRepository repository) {
return GetUserProfile.getUserProfile(repository);
}
}
Spring autowires the repository into the use case factory.
Summary
- Controller: Imperative, thin adapter. Converts HTTP β Request, Response/Cause β HTTP.
- Use case: Functional, pure business logic. No framework dependencies.
- Repository: Imperative, thin adapter. Converts JOOQ β domain types, exceptions β Cause.
The functional core (use case + domain types) is framework-independent. You could swap Spring for Micronaut, Ktor, or plain Servlets - just rewrite the adapters, not the business logic.
Conclusion
This technology isnβt about learning new tools or frameworks. Itβs about reducing the number of decisions you make so you can focus on the decisions that matter - the business logic.
By constraining return types to exactly four kinds, enforcing parse-donβt-validate, eliminating business exceptions, and mandating one pattern per function, we compress the design space. Thereβs essentially one good way to structure a use case, one good way to validate input, one good way to handle errors, one good way to compose async operations.
The Impact
This compression has compound benefits:
Code becomes predictable - you recognize patterns at a glance.
Refactoring becomes mechanical - the rules tell you when and how to split functions.
Technical debt becomes rare - prevention is built into the structure.
Business logic becomes clear - domain concepts arenβt buried in framework ceremony or mixed abstraction levels.
Why This Matters in the AI Era
When AI generates code, it needs a well-defined target structure. When humans read AI-generated code, they need to recognize patterns instantly. When teams collaborate across humans and AI, they need a shared vocabulary that both understand without translation overhead.
The technology is simple: four return types, parse-donβt-validate, no business exceptions, one pattern per function, clear package layout, mechanical refactoring. The impact compounds: unified structure, minimal debt, close business modeling, deterministic generation, tooling-friendly code.
Getting Started
Start small. Pick one use case. Apply the rules. See how it feels. Then expand. The rules stay the same whether youβre building a monolith or a microservice, a synchronous API or an event-driven system, a greenfield project or refactoring legacy code.
The goal isnβt perfect code. Itβs code thatβs easy to understand, easy to change, easy to test, and easy to generate. Code that humans and AI can collaborate on without friction.
Write code that explains itself. Let structure carry intent. Focus on business logic, not technical ceremony.
Thatβs the technology.
Where to Go Next
Reference Materials
- Complete Technical Guide: Full reference with all patterns, rules, and examples
- Management Perspective: Business case for structural standardization
- Pragmatica Lite Core Library: The foundational library for Option, Result, Promise
Practice Exercises
- Convert an existing use case: Take a simple REST endpoint and refactor it following the patterns
- Build RegisterUser: Implement the complete example from this guide
- Add Aspects: Wrap a use case with retry, timeout, or metrics decorators
- Test functionally: Replace traditional assertions with onSuccess/onFailure patterns
Community & Support
- GitHub Issues: Report issues or ask questions
- Share your experience: Document your adoption journey to help others
Next Level
Once comfortable with the basics:
- Experiment with complex Fork-Join scenarios
- Build custom Aspects for your domain
- Explore vertical slice architecture at scale
- Contribute patterns youβve discovered
Final Thoughts
You now have a complete toolkit:
- Four return types that handle every scenario
- Parse-donβt-validate for iron-clad domain models
- Six patterns that cover 95% of backend code
- Testing approaches that match functional style
- Package organization for vertical slicing
- Framework integration strategies
The journey from here is practice. Build use cases. Make mistakes. Refactor mechanically. Watch patterns emerge. See how deterministic structure accelerates both human and AI development.
Welcome to the future of backend development. Code thatβs predictable, testable, and ready for AI collaboration.
Series Navigation
β Part 8: Testing in Practice | Series Index | Complete Guide β
Version: 2.0.0 (2025-11-13) | Part of: Java Backend Coding Technology Series
Series Complete! Return to Index to review any part or see CODING_GUIDE.md for the complete reference.