Chapter 18: Migration Strategies
This chapter provides a practical playbook for adopting JBCT in existing codebases. Whether youâre working with a legacy monolith or a modern Spring Boot application, youâll learn how to migrate incrementally without disrupting ongoing development.
Assessment: Is Your Codebase Ready?
Before migrating, evaluate your starting point.
Readiness Checklist
| Factor | Ready | Needs Work |
|---|---|---|
| Java version | 17+ (records, sealed interfaces) | Upgrade first |
| Build tool | Maven or Gradle | Any modern build tool |
| Test coverage | Some tests exist | Add tests before refactoring |
| Team size | Any | Larger teams need more coordination |
| Release cycle | Regular releases | Establish before major changes |
Code Smell Indicators
These patterns indicate high-value migration targets:
// 1. Validation scattered across layers
@PostMapping("/users")
public ResponseEntity<?> createUser(@RequestBody UserDto dto) {
if (dto.getEmail() == null || dto.getEmail().isEmpty()) {
return ResponseEntity.badRequest().body("Email required");
}
if (!dto.getEmail().contains("@")) {
return ResponseEntity.badRequest().body("Invalid email");
}
// ... more validation
userService.create(dto); // Service also validates!
}
// 2. Null checks everywhere
public Order processOrder(Order order) {
if (order == null) return null;
if (order.getCustomer() == null) return null;
if (order.getCustomer().getAddress() == null) {
throw new ValidationException("Address required");
}
// ... actual logic buried under null checks
}
// 3. Exception-based control flow
public User findUser(Long id) {
try {
return repository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
} catch (DataAccessException e) {
throw new ServiceException("Database error", e);
}
}
// 4. Mixed concerns in services
@Service
public class UserService {
public User register(UserDto dto) {
// Validation
validateEmail(dto.getEmail());
validatePassword(dto.getPassword());
// Business logic
if (userRepository.existsByEmail(dto.getEmail())) {
throw new EmailExistsException();
}
// Infrastructure
String hashedPassword = passwordEncoder.encode(dto.getPassword());
User user = new User(dto.getEmail(), hashedPassword);
return userRepository.save(user);
}
}
Migration Phases
Phase 1: Value Objects Only (Week 1-2)
Goal: Introduce parse-donât-validate without changing existing code structure.
What to do:
- Add Pragmatica Core dependency
- Create value objects for commonly validated fields
- Use value objects in new code
- Gradually replace primitives in existing code
Start with these value objects:
- Password
- UserId / CustomerId / OrderId
- Money / Currency
- Phone number
Example: Adding Email value object
// NEW: Value object
public record Email(String value) {
private static final Pattern PATTERN = Pattern.compile("^[a-z0-9+_.-]+@[a-z0-9.-]+{{CONTENT}}quot;);
private static final Cause INVALID = Causes.cause("Invalid email format");
public static Result<Email> email(String raw) {
return Verify.ensure(raw, Verify.Is::notBlank)
.map(String::trim)
.map(String::toLowerCase)
.filter(INVALID, PATTERN.asMatchPredicate())
.map(Email::new);
}
}
// EXISTING: Service method (unchanged initially)
@Service
public class UserService {
public User register(String email, String password) {
// Existing validation still here
if (email == null || !email.contains("@")) {
throw new ValidationException("Invalid email");
}
// ... rest of method
}
}
// NEW: Parallel method using value object
public Result<User> registerValidated(Email email, Password password) {
// No validation needed - email and password are already valid
return checkEmailUnique(email)
.flatMap(e -> hashPassword(password))
.map(hashed -> createUser(email, hashed));
}
Migration pattern:
- Create value object
- Add parallel method accepting value object
- Update callers one by one
- Delete old method when all callers migrated
Phase 2: Result in New Code (Week 3-4)
Goal: Stop adding new exception-based code.
Team agreement:
- All new methods return
Result<T>orPromise<T>for fallible operations - New validation uses value object factories
- Exceptions only at adapter boundaries (wrapping external libraries)
Example: New feature with Result
// NEW FEATURE: Apply discount code
public interface ApplyDiscount {
Result<DiscountedPrice> apply(OrderId orderId, DiscountCode code);
}
// Implementation
public class ApplyDiscountImpl implements ApplyDiscount {
@Override
public Result<DiscountedPrice> apply(OrderId orderId, DiscountCode code) {
return findOrder(orderId)
.flatMap(order -> validateCode(code, order))
.map(discount -> calculateDiscountedPrice(order, discount));
}
private Result<Order> findOrder(OrderId id) {
return Option.option(orderRepository.findById(id.value()))
.toResult(OrderError.NOT_FOUND);
}
private Result<Discount> validateCode(DiscountCode code, Order order) {
return discountRepository.findByCode(code.value())
.toResult(DiscountError.INVALID_CODE)
.filter(DiscountError.EXPIRED, d -> !d.isExpired())
.filter(DiscountError.MIN_ORDER, d -> order.total().isGreaterThan(d.minOrder()));
}
}
Bridging old and new:
// Controller bridges exception world to Result world
@PostMapping("/orders/{orderId}/discount")
public ResponseEntity<?> applyDiscount(
@PathVariable String orderId,
@RequestBody DiscountRequest request
) {
return Result.all(
OrderId.orderId(orderId),
DiscountCode.discountCode(request.code())
)
.flatMap((oid, code) -> applyDiscount.apply(oid, code))
.fold(
cause -> toErrorResponse(cause),
price -> ResponseEntity.ok(price)
);
}
Phase 3: Extract Use Cases (Week 5-8)
Goal: Separate business logic from framework code.
Process:
- Identify a service method with clear business logic
- Extract to use case interface
- Move implementation to use case factory
- Convert service to thin adapter
Before: Fat service
@Service
public class OrderService {
@Autowired private OrderRepository orderRepository;
@Autowired private PaymentGateway paymentGateway;
@Autowired private NotificationService notificationService;
@Transactional
public Order placeOrder(OrderDto dto) {
// Validation
if (dto.getItems().isEmpty()) {
throw new ValidationException("Order must have items");
}
// Check inventory
for (ItemDto item : dto.getItems()) {
if (!inventoryService.hasStock(item.getProductId(), item.getQuantity())) {
throw new InsufficientStockException(item.getProductId());
}
}
// Process payment
PaymentResult payment;
try {
payment = paymentGateway.charge(dto.getPaymentMethod(), calculateTotal(dto));
} catch (PaymentException e) {
throw new OrderException("Payment failed", e);
}
// Create order
Order order = new Order(dto.getCustomerId(), dto.getItems(), payment.getTransactionId());
orderRepository.save(order);
// Send notification (best effort)
try {
notificationService.sendOrderConfirmation(order);
} catch (Exception e) {
log.warn("Failed to send notification", e);
}
return order;
}
}
After: Use case + thin service
// Use case interface
public interface PlaceOrder {
Promise<OrderConfirmation> execute(OrderRequest request);
interface CheckInventory {
Promise<ValidOrder> apply(ValidOrder order);
}
interface ProcessPayment {
Promise<PaymentConfirmation> apply(ValidOrder order);
}
interface SaveOrder {
Promise<OrderId> apply(ValidOrder order, PaymentConfirmation payment);
}
interface SendNotification {
Promise<Unit> apply(OrderId orderId);
}
static PlaceOrder placeOrder(
CheckInventory checkInventory,
ProcessPayment processPayment,
SaveOrder saveOrder,
SendNotification sendNotification
) {
return request -> ValidOrder.validOrder(request)
.async()
.flatMap(checkInventory::apply)
.flatMap(order -> processPayment.apply(order)
.map(payment -> Pair.of(order, payment)))
.flatMap(pair -> saveOrder.apply(pair.first(), pair.second()))
.onSuccess(orderId -> sendNotification.apply(orderId)
.onFailure(e -> { /* log but don't fail */ }))
.map(OrderConfirmation::new);
}
}
// Thin service (adapter)
@Service
public class OrderService {
private final PlaceOrder placeOrder;
public OrderService(
InventoryChecker inventoryChecker,
PaymentProcessor paymentProcessor,
JooqOrderRepository orderRepository,
EmailNotificationSender notificationSender
) {
this.placeOrder = PlaceOrder.placeOrder(
inventoryChecker,
paymentProcessor,
orderRepository,
notificationSender
);
}
public Order placeOrder(OrderDto dto) {
return placeOrder.execute(toRequest(dto))
.await()
.fold(
cause -> { throw toException(cause); },
confirmation -> toOrder(confirmation)
);
}
}
Phase 4: Adapter Isolation (Week 9-12)
Goal: All I/O wrapped in adapter leaves with Promise.lift.
Identify adapters:
- Database repositories
- HTTP clients
- Message queue producers/consumers
- File system access
- Cache clients
Example: Repository adapter
// Step interface
public interface SaveUser {
Promise<UserId> apply(ValidUser user);
}
// Adapter implementation
@Repository
public class JooqUserRepository implements SaveUser {
private final DSLContext dsl;
@Override
public Promise<UserId> apply(ValidUser user) {
return Promise.lift(
RepositoryError.DatabaseFailure::new,
() -> {
String id = dsl.insertInto(USERS)
.set(USERS.EMAIL, user.email().value())
.set(USERS.PASSWORD_HASH, user.passwordHash().value())
.returningResult(USERS.ID)
.fetchSingle()
.value1();
return new UserId(id);
}
);
}
}
Interoperability with Java Standard Library
Optional<T> â Option<T>
// Direct conversion
Optional<User> javaOptional = repository.findById(id);
Option<User> option = Option.from(javaOptional);
// In adapter methods
public Option<User> findById(UserId id) {
return Option.from(jpaRepository.findById(id.value()));
}
// Back to Optional (for framework integration)
Optional<User> back = option.toOptional();
CompletableFuture<T> â Promise<T>
// Wrapping CompletableFuture in adapter
public Promise<User> fetchUser(UserId id) {
var promise = Promise.<User>promise();
CompletableFuture<User> cf = httpClient.getUser(id.value());
cf.whenComplete((result, error) -> {
if (error != null) {
promise.fail(Causes.fromThrowable(error));
} else {
promise.succeed(result);
}
});
return promise;
}
// Helper method for common pattern
public static <T> Promise<T> fromCompletableFuture(
CompletableFuture<T> cf,
Fn1<Cause, Throwable> errorMapper) {
var promise = Promise.<T>promise();
cf.whenComplete((result, error) -> {
if (error != null) {
promise.fail(errorMapper.apply(error));
} else {
promise.succeed(result);
}
});
return promise;
}
Exceptions â Cause Mapping
In adapters - use Promise.lift:
public Promise<User> findUser(UserId id) {
return Promise.lift(
DatabaseError::new, // Exception â Cause
() -> jdbcTemplate.queryForObject(SQL, mapper, id.value())
);
}
Custom exception mapping:
public Promise<Payment> processPayment(PaymentRequest request) {
return Promise.lift(
this::mapPaymentException, // Custom mapper
() -> paymentGateway.charge(request)
);
}
private Cause mapPaymentException(Throwable t) {
return switch (t) {
case InsufficientFundsException e -> new PaymentError.InsufficientFunds(e.getMessage());
case CardDeclinedException e -> new PaymentError.CardDeclined(e.getMessage());
case NetworkException e -> new PaymentError.ServiceUnavailable(e.getMessage());
default -> Causes.fromThrowable(t);
};
}
Transitional Adapter Layer
During migration, create adapter methods that bridge old and new code:
// Legacy service (throws exceptions)
public class LegacyUserService {
public User findUser(String id) throws UserNotFoundException {
// ... legacy implementation
}
}
// JBCT adapter wrapping legacy service
public class UserServiceAdapter implements FindUser {
private final LegacyUserService legacy;
@Override
public Promise<User> apply(UserId id) {
return Promise.lift(
this::mapLegacyError,
() -> legacy.findUser(id.value())
);
}
private Cause mapLegacyError(Throwable t) {
return switch (t) {
case UserNotFoundException e -> UserError.NotFound.INSTANCE;
default -> Causes.fromThrowable(t);
};
}
}
Gradual replacement:
- Create adapter wrapping legacy service
- Use adapter in new JBCT code
- Eventually replace legacy service with JBCT implementation
- Remove adapter when migration complete
Team Adoption Strategies
Strategy 1: Champion-Led
One developer becomes JBCT expert, writes initial examples, and reviews othersâ code.
Pros: Fast start, consistent patterns Cons: Bottleneck, bus factor
Strategy 2: New Feature First
All new features use JBCT. Existing code migrated only when touched.
Pros: No big-bang rewrite, natural adoption Cons: Mixed codebase for longer, context switching
Strategy 3: Vertical Slice
Pick one use case, migrate completely from controller to database.
Pros: Complete example to learn from, proves value end-to-end Cons: Initial investment, may find integration issues late
Recommendation
Combine strategies:
- Champion writes first vertical slice (Strategy 3)
- Team reviews and learns from example
- All new features use JBCT (Strategy 2)
- Gradual migration of existing code when touched
Common Resistance and Responses
âItâs too different from what we knowâ
Response: The patterns are simpler than exception handling. Compare:
// Exception-based (hidden control flow)
try {
User user = findUser(id);
if (user == null) throw new UserNotFoundException(id);
Order order = createOrder(user, items);
if (!paymentService.charge(order)) {
throw new PaymentException("Failed");
}
return order;
} catch (UserNotFoundException e) {
return handleUserNotFound(e);
} catch (PaymentException e) {
return handlePaymentFailed(e);
} catch (Exception e) {
return handleUnknown(e);
}
// JBCT (explicit flow)
return findUser(id)
.flatMap(user -> createOrder(user, items))
.flatMap(order -> chargePayment(order))
.fold(this::handleError, identity());
âWe donât have time to rewrite everythingâ
Response: You donât have to. Start with value objects only (Phase 1). Thatâs one week for immediate benefits. Each phase is incremental.
âOur team doesnât know functional programmingâ
Response: JBCT isnât about FP. Itâs about making code predictable. The functional patterns are just the mechanism. Focus on:
- Every method declares its failure modes in the return type
- Invalid data cannot be constructed
- No hidden control flow
âWhat about debugging?â
Response: Debugging is easier, not harder:
- Stack traces show the actual call chain (no exception jumps)
- Error causes are typed and carry context
- IDE breakpoints work normally in lambdas
- Logging can be added at any point in the chain
âPerformance overhead?â
Response: Negligible for business logic. Pragmatica Core types are lightweight wrappers. The overhead is comparable to Optional. If youâre writing code where object allocation matters (tight loops, real-time systems), JBCT isnât the right fit - but neither is typical Java web development.
Migration Checklist
Phase 1 Complete When:
- [ ] Pragmatica Core added to project
- [ ] At least 5 value objects created
- [ ] One service method uses value objects
- [ ] Team has reviewed value object patterns
Phase 2 Complete When:
- [ ] Team agreement: new code uses Result/Promise
- [ ] At least one new feature built with JBCT
- [ ] Controller bridges exception/Result boundary
- [ ] Error types defined for new feature
Phase 3 Complete When:
- [ ] At least one use case extracted
- [ ] Use case has step interfaces
- [ ] Service is thin adapter
- [ ] Integration tests pass
Phase 4 Complete When:
- [ ] All new adapters use Promise.lift
- [ ] Database access wrapped
- [ ] External HTTP calls wrapped
- [ ] Error mapping consistent across adapters
Exercises
-
Audit your codebase: Find three methods with scattered validation. Which value objects would consolidate them?
-
Plan Phase 1: List the top 5 value objects your codebase needs. Prioritize by how many places validate the same data.
-
Identify extraction candidates: Find a service method with more than 50 lines. Can you identify the Sequencer pattern in it?
-
Adapter inventory: List all external dependencies (database, HTTP, message queue). Which ones throw checked exceptions?
Summary
Migration to JBCT is incremental:
- Phase 1: Value objects only - immediate validation benefits
- Phase 2: Result in new code - stop adding exceptions
- Phase 3: Extract use cases - separate concerns
- Phase 4: Adapter isolation - complete the boundary
Key principles:
- Never big-bang rewrite
- Each phase delivers value independently
- Mixed codebase is acceptable during transition
- Team buy-in through working examples, not mandates