Refactoring a monolith is less about making it "better" and more about making it "possible to change without breaking everything."

Here’s a monolith, let’s call it "LegacyApp," that handles user accounts and basic order processing. It’s a single Java WAR file, deployed to Tomcat, with a PostgreSQL database.

// User.java
public class User {
    private Long id;
    private String username;
    private String passwordHash;
    private String email;
    // ... getters and setters
}

// Order.java
public class Order {
    private Long id;
    private Long userId;
    private BigDecimal totalAmount;
    private String status; // e.g., "PENDING", "SHIPPED", "DELIVERED"
    // ... getters and setters
}

// UserController.java
@RestController
@RequestMapping("/users")
public class UserController {
    @Autowired
    private UserRepository userRepository; // JPA Repository

    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        return userRepository.findById(id).orElseThrow(() -> new UserNotFoundException(id));
    }

    @PostMapping
    public User createUser(@RequestBody User user) {
        // Password hashing omitted for brevity
        return userRepository.save(user);
    }
}

// OrderService.java
@Service
public class OrderService {
    @Autowired
    private OrderRepository orderRepository;
    @Autowired
    private UserRepository userRepository; // Still coupling to User data

    public Order createOrder(Long userId, BigDecimal amount) {
        User user = userRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId));
        Order order = new Order();
        order.setUserId(user.getId()); // Explicitly setting userId
        order.setTotalAmount(amount);
        order.setStatus("PENDING");
        return orderRepository.save(order);
    }

    public List<Order> getOrdersForUser(Long userId) {
        return orderRepository.findByUserId(userId);
    }
}

// application.properties (Spring Boot)
spring.datasource.url=jdbc:postgresql://localhost:5432/legacyappdb
spring.datasource.username=appuser
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=update

The problem LegacyApp solves is basic CRUD for users and order creation/retrieval. Internally, it’s a tangled mess of service calls and direct database access. For instance, OrderService still fetches User objects to get the user ID, even though it only needs the ID. This is a smell: it’s fetching more data than it needs and creates a tight coupling.

The core idea of refactoring without rewrites is to make small, verifiable changes that reduce the coupling between different parts of the system. We want to move towards a state where changing the User domain doesn’t immediately require changes in the Order domain, and vice-versa, beyond the necessary data transfer.

Imagine we want to extract the User functionality into its own service, perhaps as a precursor to a microservice. We can’t just delete UserController and UserRepository. Instead, we start by decoupling.

First, let’s fix that OrderService coupling.

// OrderService.java (Refactored)
@Service
public class OrderService {
    @Autowired
    private OrderRepository orderRepository;
    // No longer need UserRepository here for basic order creation

    public Order createOrder(Long userId, BigDecimal amount) {
        // We assume userId is valid. If validation is needed, it should be
        // a separate concern or handled by an upstream service that *does*
        // know about users.
        Order order = new Order();
        order.setUserId(userId); // Directly use the provided userId
        order.setTotalAmount(amount);
        order.setStatus("PENDING");
        return orderRepository.save(order);
    }

    public List<Order> getOrdersForUser(Long userId) {
        // This method is fine as it only needs the userId for querying.
        return orderRepository.findByUserId(userId);
    }
}

This change is small: we removed an @Autowired and a line of code. The mechanical benefit is that OrderService no longer needs to know how to fetch a User or even what a User object looks like, only that it needs a userId. If we later change User to be Customer or add more fields to User that aren’t relevant to Order, OrderService remains unaffected.

Next, we can make the userId in Order more robust. Currently, it’s just a Long. What if we want to ensure it refers to a valid user at the time of order creation? We can introduce a dedicated "User ID Service" within the monolith, even if it’s just a class that delegates to the UserRepository.

// UserIdService.java (New class within LegacyApp)
@Service
public class UserIdService {
    @Autowired
    private UserRepository userRepository;

    public void validateUserId(Long userId) {
        if (!userRepository.existsById(userId)) {
            throw new UserNotFoundException(userId);
        }
    }
}

// OrderService.java (Further Refactored)
@Service
public class OrderService {
    @Autowired
    private OrderRepository orderRepository;
    @Autowired
    private UserIdService userIdService; // Inject the new service

    public Order createOrder(Long userId, BigDecimal amount) {
        userIdService.validateUserId(userId); // Delegate validation

        Order order = new Order();
        order.setUserId(userId);
        order.setTotalAmount(amount);
        order.setStatus("PENDING");
        return orderRepository.save(order);
    }

    public List<Order> getOrdersForUser(Long userId) {
        // Validation might be needed here too, depending on requirements.
        // For now, assume the caller handles it or it's not strictly required.
        return orderRepository.findByUserId(userId);
    }
}

Now, OrderService delegates the responsibility of knowing about user existence to UserIdService. If we later decide to move user management out of the monolith, we can replace UserIdService’s implementation with a call to an external user service, and OrderService won’t change at all. This is the essence of isolating concerns.

Another common monolith problem is shared database tables where unrelated data is mixed. Suppose User and Order tables are in the same PostgreSQL database. If we want to move Order out, we can’t just move the orders table if other parts of the monolith (like an InvoiceService) still directly query it.

The refactoring strategy is to introduce an abstraction layer between the consumers of the data and the data itself. For Order, this means creating an OrderRepository interface and providing an implementation that talks to the orders table. If we later want to split, we can create a new implementation of OrderRepository that talks to a separate database or an external API, while the rest of the monolith continues to use the interface.

To achieve this, ensure your repositories are already abstracted. If you have direct SQL queries in your service classes, that’s the first thing to extract into a repository.

// OrderRepository.java (Interface)
public interface OrderRepository extends JpaRepository<Order, Long> {
    List<Order> findByUserId(Long userId);
    boolean existsById(Long id); // Example
}

// UserRepository.java (Interface)
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findById(Long id);
    boolean existsById(Long id); // Important for UserIdService
}

The most powerful technique here is the "strangler fig" pattern applied internally. You introduce new code that mirrors existing functionality but is more independent. For instance, create a new OrderApi interface and a MonolithOrderApi implementation that uses OrderService and OrderRepository. Then, gradually change other parts of the monolith that need order data to use OrderApi instead of calling OrderService directly. Once all consumers use OrderApi, you can replace MonolithOrderApi with an ExternalOrderApi implementation without affecting the consumers.

The secret sauce, the part that feels like magic but is just disciplined engineering, is that you’re not just moving code; you’re extracting responsibilities. Each time you create a new service or interface, you’re defining a boundary. The monolith’s "state" is no longer one giant, amorphous blob of interconnected logic and data. It becomes a collection of well-defined, albeit co-located, services. This means that when you eventually do extract a service, the boundary is already drawn, and the dependencies are explicit, not implicit "magical" knowledge shared between unrelated code blocks. You’re building the exit strategy from the inside out, one small, testable change at a time.

The next hurdle you’ll encounter is managing shared data models across these increasingly distinct functional areas.

Want structured learning?

Take the full Monolith course →