Modular Monolith Structure in Spring Boot Backends
Plenty of backend groups rush into microservices and later run into harder deployment, debugging, and data consistency problems. With a modular monolith, the group keeps a single deployable Spring Boot application while the codebase is split into modules with strict boundaries, shared domain concepts, and internal APIs. This is a good style for product groups that want structure and stability today while still keeping the door open for microservices in the future. From the outside it remains one monolith, yet inside it behaves like a set of cooperating components with well defined responsibilities.
I publish free articles like this daily, if you want to support my work and get access to exclusive content and weekly recaps, consider subscribing to my Substack.
What a Modular Monolith Means in Spring Boot
In this setting, a modular monolith is a Spring Boot backend packaged and deployed as one artifact, where internal code is grouped by business capability instead of technical layers like controllers, services, and repositories. Each capability forms a module with its own API entry points, application services, and persistence logic. The modules live in the same process and share a database instance, but they interact through well defined contracts instead of reaching into other modules’ internals.
Single Deployment With Internal Modules
The first property to understand is deployment. In a modular monolith, the team builds and releases a single Spring Boot application, usually as one container image or one executable jar. There is one Spring context, one process, and one set of operational concerns such as logging, tracing, metrics, and configuration, which keeps day to day administration closer to a traditional monolith.
Inside the codebase, the story changes. Instead of one large flat package tree with controller, service, and repository packages at the top, directories follow the business map of the system. Retail backends can have top level packages such as customer, order, billing, and catalog. Each of these packages represents one module and holds everything for that part of the domain, from REST controllers to JPA entities. Other modules treat that top level package as the public entry point and stay away from its deep internal types.
One small package layout for a shop service can look like this:
com.example.shop
├─ customer
│ ├─ api
│ └─ internal
├─ order
│ ├─ api
│ └─ internal
├─ billing
│ ├─ api
│ └─ internal
└─ catalog
├─ api
└─ internal
com.example.shop
├─ customer
│ ├─ api
│ └─ internal
├─ order
│ ├─ api
│ └─ internal
├─ billing
│ ├─ api
│ └─ internal
└─ catalog
├─ api
└─ internal
This tree matches the idea that modules talk through public APIs and keep implementation details hidden in deeper packages. Public types sit in api packages, while internal packages hold entities and services that other modules should not touch directly. If you use Spring Modulith verification, other modules cannot depend on subpackages like order.api unless that package is explicitly exposed as a named interface.
You can expose the order.api package to other modules with a small package-info.java file in that package.
// src/main/java/com/example/shop/order/api/package-info.java
@org.springframework.modulith.NamedInterface("api")
package com.example.shop.order.api;
// src/main/java/com/example/shop/order/api/package-info.java
@org.springframework.modulith.NamedInterface("api")
package com.example.shop.order.api;
Spring Modulith, an official Spring project, gives some structure to this idea. It treats each top-level package under the application root as an application module, and subpackages inside that module are treated as internal by default. Module tests can verify that only the module’s public API is referenced from outside, so accidental cross references are caught early in the build.
Modular Monoliths Versus Early Microservices
Microservices split a system into separate deployable units with network boundaries. Each service has its own process and usually its own database or schema. Communication flows through HTTP or messaging and every call across service boundaries carries network cost, retries, timeouts, and failure handling. The group also needs shared infrastructure such as discovery, centralized configuration, and some form of gateway or edge service in front of the fleet.
The modular monolith style keeps all of that inside a single Spring Boot process. Instead of REST calls between order and billing, one module calls that module by invoking interfaces in the same JVM. Data access stays local as well, so transactions across modules can rely on the same relational database and the same transaction manager. That leads to shorter feedback cycles for developers, because module integration tests run in one process and do not need a full microservice environment.
Lets take a look at a short snippet that contrasts a microservice call with a modular monolith call. In a microservice layout, an order service that needs billing data would typically reach out with an HTTP client:
<button data-code="class BillingClient {
private final WebClient webClient;
BillingClient(WebClient webClient) {
this.webClient = webClient;
}
Mono fetchBillingSummary(String customerId) {
return webClient.get()
.uri("http://billing-service/api/billing/{id}", customerId)
.retrieve()
.bodyToMono(BillingSummary.class);
}
}” data-toggle-ms=”1200″ aria-label=”Copy code” data-nosnippet=”” class=”code-copy-btn absolute right-3 top-3 size-8 p-1.5 flex items-center justify-center bg-black/50 text-white rounded-md transition-colors duration-200 cursor-pointer hover:bg-black/70″>
class BillingClient {
private final WebClient webClient;
BillingClient(WebClient webClient) {
this.webClient = webClient;
}
Mono<BillingSummary> fetchBillingSummary(String customerId) {
return webClient.get()
.uri("http://billing-service/api/billing/{id}", customerId)
.retrieve()
.bodyToMono(BillingSummary.class);
}
}
class BillingClient {
private final WebClient webClient;
BillingClient(WebClient webClient) {
this.webClient = webClient;
}
Mono<BillingSummary> fetchBillingSummary(String customerId) {
return webClient.get()
.uri("http://billing-service/api/billing/{id}", customerId)
.retrieve()
.bodyToMono(BillingSummary.class);
}
}
This client depends on network reachability, service discovery, and error handling for remote calls.
In a modular monolith, the order module would call an internal billing interface instead of making an HTTP call:
class OrderPriceCalculator {
private final BillingAccess billingAccess;
OrderPriceCalculator(BillingAccess billingAccess) {
this.billingAccess = billingAccess;
}
Money calculatePrice(String customerId, OrderLines lines) {
BillingSummary summary = billingAccess.loadSummary(customerId);
// pricing logic that uses summary and lines
return null; // placeholder for actual Money computation
}
}
class OrderPriceCalculator {
private final BillingAccess billingAccess;
OrderPriceCalculator(BillingAccess billingAccess) {
this.billingAccess = billingAccess;
}
Money calculatePrice(String customerId, OrderLines lines) {
BillingSummary summary = billingAccess.loadSummary(customerId);
// pricing logic that uses summary and lines
return null; // placeholder for actual Money computation
}
}
The second example uses a normal method call between modules inside the same process, so no network, no serialization, and no HTTP timeouts are involved. Testing also changes, because the calculator can be tested with a stub or mock of BillingAccess without starting any separate service.
Even with a single database, modular monoliths still encourage well defined ownership. The order module owns order tables, the billing module owns billing tables, and other modules go through module APIs instead of writing directly to tables from a different module. That habit makes a later move to separate services far easier because the natural seams are already present in the codebase. As the system grows, many groups move from a shared schema to separate schemas for each module, which makes a later split into independent services less painful.
Practical Modular Structure For A Spring Boot Backend
Practical work on a modular monolith in Spring Boot starts with how the project is laid out, how modules depend on one another, and how domain concepts move across those modules. The application is still one deployable unit, but the code is sliced so that features around customers, orders, billing, and catalog sit in their own places with explicit dependencies. Modules need a boundary in the build, a shared language for cross cutting concepts, and a way to talk to one another without falling back to ad hoc calls or shared tables.
Defining Module Boundaries In One Codebase
Lots of groups rely on a multi module build in Maven or Gradle so that the overall project has one parent and several child modules. Business capabilities then map to child modules such as customer, order, and billing. The application module holds the Spring Boot entry point and depends on all feature modules, so the final jar or container still comes from one place.
This parent pom.xml defines the feature modules that make up the shop backend:
<button data-code="
4.0.0
com.example.shop
shop-backend
1.0.0
pom
customer
order
billing
catalog
shared-kernel
application
” data-toggle-ms=”1200″ aria-label=”Copy code” data-nosnippet=”” class=”code-copy-btn absolute right-3 top-3 size-8 p-1.5 flex items-center justify-center bg-black/50 text-white rounded-md transition-colors duration-200 cursor-pointer hover:bg-black/70″>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.shop</groupId>
<artifactId>shop-backend</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<modules>
<module>customer</module>
<module>order</module>
<module>billing</module>
<module>catalog</module>
<module>shared-kernel</module>
<module>application</module>
</modules>
</project>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.shop</groupId>
<artifactId>shop-backend</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<modules>
<module>customer</module>
<module>order</module>
<module>billing</module>
<module>catalog</module>
<module>shared-kernel</module>
<module>application</module>
</modules>
</project>
This parent lives at the root of the repository, and child modules such as customer and order sit in directories with matching names next to it.
The application module depends on the feature modules and provides the Spring Boot entry point:
<button data-code="
com.example.shop
shop-backend
1.0.0
application
com.example.shop
customer
com.example.shop
order
com.example.shop
billing
com.example.shop
catalog
com.example.shop
shared-kernel
” data-toggle-ms=”1200″ aria-label=”Copy code” data-nosnippet=”” class=”code-copy-btn absolute right-3 top-3 size-8 p-1.5 flex items-center justify-center bg-black/50 text-white rounded-md transition-colors duration-200 cursor-pointer hover:bg-black/70″>
<project>
<parent>
<groupId>com.example.shop</groupId>
<artifactId>shop-backend</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>application</artifactId>
<dependencies>
<dependency>
<groupId>com.example.shop</groupId>
<artifactId>customer</artifactId>
</dependency>
<dependency>
<groupId>com.example.shop</groupId>
<artifactId>order</artifactId>
</dependency>
<dependency>
<groupId>com.example.shop</groupId>
<artifactId>billing</artifactId>
</dependency>
<dependency>
<groupId>com.example.shop</groupId>
<artifactId>catalog</artifactId>
</dependency>
<dependency>
<groupId>com.example.shop</groupId>
<artifactId>shared-kernel</artifactId>
</dependency>
</dependencies>
</project>
<project>
<parent>
<groupId>com.example.shop</groupId>
<artifactId>shop-backend</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>application</artifactId>
<dependencies>
<dependency>
<groupId>com.example.shop</groupId>
<artifactId>customer</artifactId>
</dependency>
<dependency>
<groupId>com.example.shop</groupId>
<artifactId>order</artifactId>
</dependency>
<dependency>
<groupId>com.example.shop</groupId>
<artifactId>billing</artifactId>
</dependency>
<dependency>
<groupId>com.example.shop</groupId>
<artifactId>catalog</artifactId>
</dependency>
<dependency>
<groupId>com.example.shop</groupId>
<artifactId>shared-kernel</artifactId>
</dependency>
</dependencies>
</project>
That dependency list means the application module sees all feature modules, while feature modules do not depend back on application.
The main class usually sits in that application module and starts the runtime:
package com.example.shop.application;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = "com.example.shop")
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
package com.example.shop.application;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = "com.example.shop")
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
This class pulls in the rest of the modules through component scanning and configuration.
Module layout in code then follows the idea that each feature has an API and internal details. Small layouts for modules make that intent easier to see when someone opens the project for the first time. The order module can use a layout like this:
order
├─ api
│ ├─ OrderPublicController.java
│ └─ OrderSummaryDto.java
└─ internal
├─ OrderService.java
├─ OrderRepository.java
└─ OrderEntity.java
order
├─ api
│ ├─ OrderPublicController.java
│ └─ OrderSummaryDto.java
└─ internal
├─ OrderService.java
├─ OrderRepository.java
└─ OrderEntity.java
Public controllers and DTOs sit in api, while entities and internal services stay under internal. Other modules are expected to depend on types from api and leave internal alone, so that internal details can change without forcing changes in other modules.
Spring Modulith helps enforce those decisions. The project registers a package as a module, and tests then check whether modules stay within the rule that only API packages are used from outside. One small JUnit test can drive those checks for the whole application:
package com.example.shop;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
class ModulithStructureTests {
@Test
void modulesStayWithinBoundaries() {
ApplicationModules modules =
ApplicationModules.of(com.example.shop.application.ShopApplication.class);
modules.verify();
}
}
package com.example.shop;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
class ModulithStructureTests {
@Test
void modulesStayWithinBoundaries() {
ApplicationModules modules =
ApplicationModules.of(com.example.shop.application.ShopApplication.class);
modules.verify();
}
}
This test loads all application modules starting from ShopApplication and asks Spring Modulith to verify that no module reaches into another module’s internal packages.
Take this module declaration with Spring Modulith that marks the order module:
@org.springframework.modulith.ApplicationModule(displayName = "Orders")
package com.example.shop.order;
@org.springframework.modulith.ApplicationModule(displayName = "Orders")
package com.example.shop.order;
That package level annotation tells Spring Modulith that com.example.shop.order is one application module. The base package com.example.shop.order then serves as the public surface for that module, while Spring Modulith treats subpackages such as com.example.shop.order.internal as internal for dependency checks. Code in customer, billing, or catalog is expected to depend on the base package or on explicitly exposed API packages, not on internal types.
Shared Domain Model Without Shared Chaos
Shared concepts appear quickly in modular monoliths. Customer identifiers, money values, product SKUs, and error codes show up in more than one module. If every module invents its own types for those ideas, the backend drifts into a state where the same concept has several forms in code, which makes reasoning about it harder for new developers.
One common way to handle this is to create a small shared-kernel module that holds a limited set of domain types used across modules. That module has no knowledge of customer or order rules. It just supplies value objects and basic events that give other modules a common language.
This value type wraps a numeric customer id and lives under shared-kernel:
<button data-code="package com.example.shop.sharedkernel.customer;
import java.util.Objects;
public final class CustomerId {
private final Long value;
public CustomerId(Long value) {
if (value == null || value
package com.example.shop.sharedkernel.customer;
import java.util.Objects;
public final class CustomerId {
private final Long value;
public CustomerId(Long value) {
if (value == null || value <= 0) {
throw new IllegalArgumentException("Customer id must be positive");
}
this.value = value;
}
public Long value() {
return value;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof CustomerId that)) {
return false;
}
return Objects.equals(this.value, that.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
@Override
public String toString() {
return "CustomerId[" + value + "]";
}
}
package com.example.shop.sharedkernel.customer;
import java.util.Objects;
public final class CustomerId {
private final Long value;
public CustomerId(Long value) {
if (value == null || value <= 0) {
throw new IllegalArgumentException("Customer id must be positive");
}
this.value = value;
}
public Long value() {
return value;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof CustomerId that)) {
return false;
}
return Objects.equals(this.value, that.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
@Override
public String toString() {
return "CustomerId[" + value + "]";
}
}
Modules that need to refer to a customer take a CustomerId instead of a raw Long, so when new people read the code, the intent behind a parameter or field is visible from its type.
Domain events fit the shared model as well. The order module can publish an event when a new order is created, and other modules can react to that event without direct coupling to order internals. An event type can sit under order.api so that subscribers do not depend on order.internal:
package com.example.shop.order.api;
import com.example.shop.sharedkernel.customer.CustomerId;
public record OrderPlacedEvent(
Long orderId,
CustomerId customerId,
String currencyCode,
long totalCents
) {
}
package com.example.shop.order.api;
import com.example.shop.sharedkernel.customer.CustomerId;
public record OrderPlacedEvent(
Long orderId,
CustomerId customerId,
String currencyCode,
long totalCents
) {
}
Billing, notification, or reporting modules can listen for OrderPlacedEvent and perform their own work, such as charging a card or logging metrics. The shared value type CustomerId keeps the event tied to the same idea of a customer that the rest of the system uses.
Internal APIs For Cross Module Calls
Modules rarely live in isolation. The order module needs some way to find customer status, billing needs to know about orders, and catalog supplies product data to several places. The goal is to let modules talk without breaking boundaries or reaching straight into repositories that belong to a different area. The usual solution is to define internal APIs as Java interfaces in the api package of the owning module. Other modules call those interfaces, and the owning module provides implementations in its internal package. Spring dependency injection then connects callers and implementations in the same application context.
One customer access API can sit under customer.api so that other modules can ask for a summary of customer information:
package com.example.shop.customer.api;
import com.example.shop.sharedkernel.customer.CustomerId;
public interface CustomerAccess {
CustomerSummary loadCustomer(CustomerId id);
}
package com.example.shop.customer.api;
import com.example.shop.sharedkernel.customer.CustomerId;
public interface CustomerAccess {
CustomerSummary loadCustomer(CustomerId id);
}
Implementation classes under customer.internal handle the actual data access and mapping work. The order module then injects CustomerAccess into its services and calls loadCustomer without opening a direct link to customer entities or repositories.
This structure keeps ownership of tables and business rules inside the module that owns them. Order code does not update billing tables directly, and billing does not touch order tables. When state needs to move across modules, the flow goes through APIs or domain events, which match the module boundaries already present in the build and package layout.
Conclusion
Modular monolith structure in Spring Boot keeps all behavior in one deployable unit while still separating features into modules with their own boundaries, shared domain types, and internal APIs. The build layout, package structure, and use of tools such as Spring Modulith turn those boundaries into rules that can be checked instead of loose naming habits. When developers keep ownership of tables and business rules inside a module and route cross module communication through interfaces or events, the system stays easier to reason about today and remains ready for a gradual move toward microservices if product needs grow in that direction.
Thanks for reading! If you found this helpful, highlighting, clapping, or leaving a comment really helps me out.


