Skip to content

Splitting a monolith along the data, not the code

· 6 min read · Azure · Java · Microservices · Spring Boot

The first attempt at breaking up the monolith failed quietly. We drew the service boundaries around the top-level packages, because that is what the codebase already suggested, and ended up with four Spring Boot applications that could not be deployed independently. Every meaningful change touched at least two of them, and each one still opened a connection to the same schema.

The second attempt started from the tables instead. For each table we asked which part of the system was allowed to write to it, and wherever the answer was more than one, that was the problem to solve before any code moved. The question was slow and unglamorous, and it is the only reason the second attempt worked.

Ownership is the boundary

A service that does not own its data is a library with network latency added. Once two deployables write to the same table they share a schema migration, which means they share a release, which means the split has bought you a serialisation format and nothing else.

  • Exactly one service writes to a given table; everyone else asks it.
  • Read-only copies of reference data are fine, with the staleness stated up front.
  • A shared schema is a shared deployment, whatever the repository layout says.
  • If two services have to change together for most features, they are one service.

The boundary belongs wherever a transaction does not need to cross. Everywhere else you are adding a network call to a join.

Keeping the contracts small

The internal APIs we ended up with are dull on purpose: a handful of endpoints each, no general query interface, and responses shaped by what the caller renders rather than by what the table happens to contain. The temptation to return the entity directly is strong, and it is how you leak your schema into everyone else.

@RestController
@RequestMapping("/internal/v1/positions")
class PositionController {

    private final PositionService positions;

    PositionController(PositionService positions) {
        this.positions = positions;
    }

    @GetMapping("/{accountId}")
    ResponseEntity<PositionView> byAccount(@PathVariable long accountId) {
        return positions.findView(accountId)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }
}

PositionView is a record holding the four fields the caller needs, mapped explicitly. It is more typing than returning the entity, and it means a column rename stays a local change rather than becoming a coordinated one across three teams.

What I would tell the version of me who started this is that the deployment story is the easy half. Getting the services onto managed Kubernetes in Azure was about a week of work with good documentation behind it. Working out who owned the customer record took three months and a great many conversations, and no platform decision would have made that shorter.