What modularizing the build means and why it comes before splitting the deployment

A modular monolith is still one deployable: one artifact, one process, one release. The difference from an ordinary monolith is that the code inside it is split into build modules, and the compiler enforces their boundaries. A module can only see the modules it declares as dependencies. If billing reaches into an internal class in inventory, the build fails.

That failure is the reason to modularize the build before you split the deployment. A module boundary breaks at compile time, in front of the developer who crossed it, and fixing it is a refactor inside one repository. A network boundary is different. Once two services talk over HTTP or a queue, the coupling between them doesn't go away. It reappears as timeouts, versioning problems, partial failures and data that no longer agrees. Moving that boundary later means coordinated deploys and migrations. Mistakes in the build graph are cheap to fix. Mistakes in the network topology are expensive.

So the first test of whether a piece of code could ever become a service is simple: can this module compile on its own? If it can't build without half the codebase on its classpath, it won't run on its own either.

This approach suits teams with a single large JVM codebase. Some of them are weighing microservices. Others just keep running into coupling. Start before any extraction is planned, or as soon as you notice that changes to one feature keep touching unrelated areas.

Finding package boundaries from evidence, not org charts

The team chart tells you who owns code today, not how the code actually depends on itself. Three kinds of evidence give a more reliable picture.

Static dependencies. Run a package-level dependency analysis over the compiled classes. jdeps, which ships with the JDK, is enough to start, and IDE dependency matrices work too. Look for clusters with dense internal edges and few external ones. Note every cycle, because a cycle between two packages means they cannot yet live in separate modules.

Change coupling. Mine version-control history. Parse git log --name-only into pairs of files that appear in the same commits. Packages that keep changing together are already one unit in practice, whatever their names suggest.

Data ownership. For each candidate area, record which tables it reads and which it writes. A table written from three areas is a boundary problem that the compiler will never report.

Draw boundaries around business capabilities such as billing, catalog and fulfillment, not around technical layers such as repositories and services. A feature change touches every layer, so layer modules force every change across module lines and enforce almost nothing.

Start coarse, with a handful of modules. Split a module further only when its internal seam holds up against all three kinds of evidence. Many tiny modules add build configuration, slow IDE sync and make you think about wiring for every change. Too few modules leave most dependencies unchecked, so you get little enforcement for the effort.

#!/usr/bin/env bash
# Gather two kinds of boundary evidence for a Java monolith.
# Paths and the com.example prefix are placeholders; adjust to your build.
set -euo pipefail

CLASSES=build/classes/java/main
mkdir -p evidence/dot

# 1. Static dependencies: package-to-package edges, limited to our own code.
#    The DOT files can be rendered with Graphviz to spot clusters and cycles.
jdeps -verbose:package -e 'com\.example\..*' \
      --dot-output evidence/dot "$CLASSES" > evidence/package-deps.txt

# 2. Change coupling: count how often two source directories (packages)
#    are modified in the same commit over the last year.
git log --since='12 months ago' --no-merges --name-only \
        --pretty=format:'@@' -- 'src/main/java/' |
awk -v max=8 '
  function flush(   a, b, n) {
    n = 0; for (a in pkgs) n++
    # Skip sweeping commits (renames, reformatting) that touch everything.
    if (n <= max)
      for (a in pkgs) for (b in pkgs) if (a < b) pairs[a " <-> " b]++
    delete pkgs
  }
  /^@@$/ { flush(); next }
  NF     { sub(/\/[^\/]+$/, ""); pkgs[$0] = 1 }   # file path -> its directory
  END    { flush(); for (p in pairs) print pairs[p], p }
' | sort -rn | head -n 30 > evidence/change-coupling.txt

# Pairs at the top of change-coupling.txt that sit in different candidate
# modules are seams to question before you draw a boundary there.
echo "Wrote evidence/package-deps.txt, evidence/dot/, evidence/change-coupling.txt"

A script that uses jdeps to record package-level dependencies and mines git history to rank package pairs that change in the same commits, both inputs for choosing module boundaries.

How it works: a Gradle multi-module layout with api and impl modules

A concrete layout makes those trade-offs visible. Start with settings.gradle.kts, which includes every module so Gradle treats each as a separate project with its own dependency declarations. One module, app, is the only deployable. It applies the application or framework plugin, depends on every impl module, and does the wiring: dependency injection configuration, HTTP routing, and startup. Nothing else depends on app.

Each capability, such as billing or catalog, is split in two. billing-api holds the interfaces, DTOs and event types that other capabilities may call or consume. billing-impl holds the code behind them: services, repositories, persistence entities and framework annotations. Other capabilities depend on billing-api and never on billing-impl. The compiler then rejects any reach into internals.

The java-library plugin enforces the rest. A dependency declared with api appears on the compile classpath of every consumer, so it becomes part of your module's contract. A dependency declared with implementation is available only inside the module. Consumers still get it at runtime but cannot compile against it. Make implementation the default inside impl modules. Then the ORM library, the entity classes and helper types stay internal, and another team cannot quietly start passing your entities around. Reserve api for types that appear in your public signatures.

The cost is that api/impl splitting doubles the module count, with more build files and more IDE sync time. For a small or purely internal capability, a single module may be enough if it scopes dependencies with implementation and exposes a narrow set of public types.

// settings.gradle.kts: every module is a separate Gradle project
rootProject.name = "shop"
include("app", "shared-kernel", "billing-api", "billing-impl", "catalog-api", "catalog-impl")

// billing-api/build.gradle.kts: the contract other capabilities compile against
plugins { `java-library` }
dependencies {
    // Money/Currency types appear in public signatures, so consumers need them
    api(project(":shared-kernel"))
}

// billing-impl/build.gradle.kts: internals, never depended on by other capabilities
plugins { `java-library` }
dependencies {
    // Implements the billing-api interfaces
    implementation(project(":billing-api"))
    // Calls catalog only through its contract
    implementation(project(":catalog-api"))
    // ORM stays internal: consumers cannot compile against it or the entities
    implementation(libs.hibernate.core) // entry in gradle/libs.versions.toml
}

// catalog-impl/build.gradle.kts
plugins { `java-library` }
dependencies {
    implementation(project(":catalog-api"))
    implementation(project(":billing-api")) // not :billing-impl
}

// app/build.gradle.kts: the only deployable, wires every impl together
plugins { application }
dependencies {
    implementation(project(":billing-impl"))
    implementation(project(":catalog-impl"))
}
application { mainClass.set("com.example.shop.App") }

A Gradle Kotlin DSL layout in which capabilities depend only on each other's api modules, impl modules keep the ORM and entities internal with 'implementation', and the single app module wires the impl modules together.

Implementing it incrementally: moving packages, fixing compile errors, breaking cycles

Start by moving one existing package into a new module exactly as it is. Don't rename classes, split interfaces, or clean anything up in the same change. Keeping the move mechanical makes it reviewable, and it means any failure comes from the new boundary rather than from your edits.

Then compile. Each error is a dependency the old single source tree let you ignore. Record them before fixing anything. A dozen errors pointing at one utility class is a quick fix. Errors pointing both ways between two modules show you a cycle, which Gradle will refuse to build.

Break cycles by inverting the dependency. If billing calls into accounts and accounts calls back into billing, move the interface accounts needs into its own api module and let billing implement it. Another option is to replace the direct call with a domain event or a registered callback. Either way, one direction stops compiling against the other.

Merge each step to the main branch as a small change that can ship on its own. A long-lived refactoring branch falls behind daily work and eventually can't be merged.

The costs are real. Some cycles can't be fixed by moving an interface. They need actual design work, such as introducing events or keeping a duplicated read model in sync. An effort abandoned halfway, with some code moved and some not, can be harder to follow than the original tangle. Large moves also cause merge conflicts and break git blame for anyone who doesn't follow renames. Schedule them in short windows announced in advance, and do them with IDE or scripted refactors rather than by hand.

// BEFORE: accounts.AccountService called billing.BillingService directly,
// while billing already depended on accounts -> cycle, Gradle refuses to build.

// --- module :accounts-api  (owned by accounts, the consumer) ---
package com.example.accounts.api;

// Declares only what accounts needs to know. No billing types leak in.
public interface OutstandingBalanceCheck {
    boolean hasUnpaidInvoices(String accountId);
}

// --- module :accounts  (depends on :accounts-api only, not :billing) ---
package com.example.accounts;

import com.example.accounts.api.OutstandingBalanceCheck;

public class AccountService {
    private final OutstandingBalanceCheck balanceCheck;

    // Implementation is injected at startup; accounts never compiles against billing.
    public AccountService(OutstandingBalanceCheck balanceCheck) {
        this.balanceCheck = balanceCheck;
    }

    public void close(String accountId) {
        if (balanceCheck.hasUnpaidInvoices(accountId)) {
            throw new IllegalStateException("Account has unpaid invoices: " + accountId);
        }
        // ... close the account
    }
}

// --- module :billing  (depends on :accounts-api and :accounts; arrow points one way) ---
package com.example.billing;

import com.example.accounts.api.OutstandingBalanceCheck;

public class BillingBalanceCheck implements OutstandingBalanceCheck {
    @Override
    public boolean hasUnpaidInvoices(String accountId) {
        return false; // placeholder: query the invoice repository here
    }
}

Breaking a billing–accounts cycle by moving the interface accounts needs into an accounts-api module that billing implements, so accounts no longer compiles against billing.

Shared databases, the 'common' module trap, and runtime coupling the compiler cannot see

The compiler only enforces boundaries it can see. A module boundary means little if two modules still read and write the same tables.

Assign every table to exactly one owning module. Other modules must not join against it or map its entities. They call the owner's api module instead. This rule matters because a cross-module join is a compile-time-invisible dependency. It will block any later extraction, since the joined tables would have to move together. You can't fix every violation at once, so record the existing ones in a checked-in allowlist, fail the build on any new entry, and delete entries as each violation is fixed. The allowlist should only shrink.

The common module erodes in a similar way. It starts with IDs and grows into a dumping ground for Order, Customer, and half the domain. Keep it limited to value types such as IDs, money, and time, plus cross-cutting infrastructure such as logging and tracing. Move domain types back to the module that owns them. A change to common recompiles every module and couples them all, so a large common cancels much of the benefit of the split.

Some coupling happens only at runtime:

Coupling Why the compiler misses it Fix
Shared tables SQL strings aren't type-checked across modules Table ownership plus allowlist
Reflection Class names resolved from strings Replace with explicit api interfaces
Spring component scanning A broad base package picks up other modules' internal beans Scan only each module's own package; expose beans through explicit configuration
Shared caches Modules read each other's keys and serialized types Namespace keys per module; only the owner writes

Keeping boundaries from eroding: architecture tests, ownership, and build logic

Module boundaries enforced by the compiler catch only one kind of violation: a class that cannot see another class. Many rules that matter are finer than that. Examples include "controllers in orders-impl must not call repositories directly," "nothing outside billing may use types annotated as persistence entities," and "no module may depend on a package named internal, even inside its own Gradle project." These need architecture tests.

Architecture tests in CI

On the JVM, ArchUnit runs these rules as ordinary unit tests. A typical rule says that no class residing in a package matching ..orders.. should depend on classes residing in ..billing.internal... Put shared rules in a test fixture that every module applies, and run them in the same CI job as the module's other tests. A pull request that crosses a boundary then fails before review, with a message naming the offending class. If a team needs an exception, it has to change the rule in the diff, which makes the exception visible and discussable.

Ownership

Assign each module an owning team in a code-owners file. When a change touches another team's api module, that team is pulled into the review automatically. Without this, interfaces drift because whoever needs a new method adds it.

Build logic needs an owner too

With dozens of modules, copy-pasted build scripts diverge in compiler flags, test setup and dependency versions. Move shared configuration into convention plugins in an included build-logic build, so each module declares only its own dependencies. Build code is now a real codebase. It needs a named owner, review and tests, or it erodes the way the application code did.

package com.example.architecture;

import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

// Shared rules, runs as a normal JUnit 5 test in each module's CI job.
// Production classes only, so test helpers don't trigger violations.
@AnalyzeClasses(
        packages = "com.example",
        importOptions = ImportOption.DoNotIncludeTests.class)
class BoundaryRulesTest {

    // Module visibility can't express this: orders may see billing's
    // Gradle project, but must stay out of billing's internal packages.
    @ArchTest
    static final ArchRule ordersDoesNotReachIntoBillingInternals =
            noClasses().that().resideInAPackage("..orders..")
                    .should().dependOnClassesThat().resideInAPackage("..billing.internal..")
                    .because("orders must use billing's public api package");

    // A rule within a single module: controllers go through services.
    @ArchTest
    static final ArchRule controllersDoNotCallRepositories =
            noClasses().that().haveSimpleNameEndingWith("Controller")
                    .should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
                    .because("data access belongs behind the service layer");

    // Exceptions are made by editing these rules in the pull request,
    // so reviewers and code owners see every exception.
}

An ArchUnit JUnit 5 test that runs in CI and fails the build when orders code depends on billing's internal packages or when a controller uses a repository directly.

Extract a service, stay a modular monolith, or choose an alternative

A module is a reasonable extraction candidate when it passes all of these:

  • Narrow api module. Few types, and none of them are entities or framework objects leaking out.
  • No cycles. Nothing in its dependency graph points back at it.
  • Owns its data. No other module reads or writes its tables.
  • Replaceable calls. Its outbound calls go through interfaces that a remote client could implement without changing callers.
  • Independent history. Version control shows its files usually change in commits that touch no other module.

Passing the checklist is necessary, but it does not prove the boundary will hold as a network boundary. In-process calls are synchronous, never time out, and can share a database transaction. Once the module becomes a service, every call can be slow or fail halfway, and a write that spans both sides loses its atomicity. Before extracting, find each call site that relies on immediate consistency or a shared transaction. Each one needs a compensating design, such as idempotent retries or an outbox.

If you have no evidence that the module needs its own scaling profile, release cadence or failure isolation, stop at the modular monolith. It keeps one deployable and one transaction model while still enforcing boundaries. For many systems that is the right end state.

Option Boundary enforcement Operational cost Main risk
Package conventions + architecture tests Test-time rules Lowest Rules drift or get suppressed
Modular monolith Compiler and build graph Low Shared database coupling remains
Extract services directly Network High Distributed monolith with tangled data
Full rewrite Whatever the new design achieves Highest Lost behaviour, long freeze on features

Impact on development cost and engineering time, and how to measure it in your repo

Modularization costs engineering time up front and keeps costing a little afterward. The costs are setting up modules, maintaining build logic and convention plugins, coordinating refactors that touch several modules, and the design work needed to break cycles. The savings depend on how your team works. Incremental compilation and the build cache skip modules that have not changed. Tests can run per module instead of across the whole suite. Ownership becomes clearer. Coupling fails at compile time rather than surfacing in a production incident. Only the build-time savings are easy to measure, so measure them before and after the change.

Pick a few representative tasks, such as a clean build, a one-line change in a leaf module, and a change in a widely used API module. Run each one several times with --profile, which writes a local HTML report of task timings. Run them again with --build-cache to see how many tasks are reused. --scan gives a more detailed breakdown, but it publishes build data to a remote service, so check your organization's policy first.

Illustrative estimate (assumed numbers, not a benchmark)

Monthly hours saved = developers × builds per day × working days × (baseline seconds − modular seconds) ÷ 3600

Assumed inputs: 20 developers, 15 builds per day, 20 working days, and a local build that drops from 240 s to 150 s.

20 × 15 × 20 × 90 ÷ 3600 = 150 hours per month

Now subtract the assumed costs: 120 hours of one-time setup and 8 hours per month of build maintenance. Break-even comes at roughly 120 ÷ (150 − 8) ≈ 0.85 months. This overstates the benefit, because developers do not lose every second they spend waiting on a build. Discount the savings accordingly.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to modularize monolith into packages, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.