All examples use Java 21, Spring Boot 3.x, and a domain roughly based on an order management system — three bounded contexts: Orders, Catalog, and Customers. The patterns apply regardless of your domain.
Why Most "Microservices" Are Actually Distributed Monoliths
Before the architecture, a diagnostic. Here's the most common failure mode I encounter:
OrderService calls CustomerService via REST
→ CustomerService calls AccountService via REST
→ AccountService calls AuthService via REST
PlaceOrder() now has 4 network hops, 4 failure modes,
4 deployment dependencies, and distributed transaction risk.
This is not a microservices architecture. This is a monolith where the method calls were replaced with HTTP calls. The coupling is identical — it's just now expressed as API contracts instead of import statements, and every call can fail with a network error.
The teams I've seen thrive with microservices have two things: genuinely independent business capabilities with separate teams owning them end-to-end, and the organizational maturity to run distributed systems (platform team, service mesh, distributed tracing, on-call rotations per service). That's a real investment. For most teams, it's not the right trade.
The modular monolith gives you the architectural discipline of microservices without the operational overhead.
The Core Principle: Module Boundaries Are Enforced, Not Suggested
A modular monolith is not just "we put things in separate packages." Every Java codebase has packages. The difference is enforcement.
In a modular monolith:
- One module cannot import the internal implementation of another module
- Cross-module communication happens through defined interfaces, not direct class references
- Database access is scoped per module (even if you share one database)
- These rules are tested by the build — not just agreed on in a wiki
Let me show you what this looks like in practice.
Module Structure
src/main/java/com/company/app/
├── orders/
│ ├── api/ ← Public API of this module
│ │ ├── OrderService.java ← Interface (not implementation)
│ │ ├── OrderDto.java ← Data structures safe to share
│ │ └── OrderEvents.java ← Domain events this module publishes
│ ├── domain/ ← Private: domain model
│ │ ├── Order.java
│ │ ├── OrderItem.java
│ │ ├── OrderStatus.java
│ │ └── OrderRepository.java
│ ├── application/ ← Private: use cases / application services
│ │ └── OrderServiceImpl.java
│ ├── infrastructure/ ← Private: DB, messaging, external APIs
│ │ ├── JpaOrderRepository.java
│ │ └── OrderMapper.java
│ └── OrdersConfig.java ← @Configuration for this module's beans
│
├── catalog/
│ ├── api/
│ │ ├── CatalogService.java
│ │ ├── ProductDto.java
│ │ └── CatalogEvents.java
│ ├── domain/
│ ├── application/
│ ├── infrastructure/
│ └── CatalogConfig.java
│
├── customers/
│ ├── api/
│ ├── domain/
│ ├── application/
│ ├── infrastructure/
│ └── CustomersConfig.java
│
└── shared/
├── Money.java ← Value objects used by multiple modules
├── CustomerId.java
└── DomainEvent.java ← Base type for all domain events
The rule is simple: everything in api/ is public. Everything else is private.
The orders module can call CatalogService (which is in catalog/api/). It cannot import catalog/domain/Product.java or catalog/infrastructure/JdbcCatalogRepository.java.
Enforcing Boundaries with ArchUnit
The structure above is meaningless without enforcement. Engineers will break it under deadline pressure. The compiler won't stop them — Java doesn't enforce package-level visibility like a module system does (and JPMS is a separate conversation). ArchUnit does.
@AnalyzeClasses(packages = "com.company.app", importOptions = ImportOption.DoNotIncludeTests.class)
class ModuleBoundaryTest {
private static final List<String> MODULES = List.of("orders", "catalog", "customers");
@ArchTest
static final ArchRule no_module_accesses_internals_of_another =
noClasses()
.that().resideInAPackage("com.company.app.(*)..")
.should().accessClassesThat()
.resideInAPackage("com.company.app.(*).domain..")
.orShould().accessClassesThat()
.resideInAPackage("com.company.app.(*).application..")
.orShould().accessClassesThat()
.resideInAPackage("com.company.app.(*).infrastructure..")
.andShould(beInTheSameModule());
@ArchTest
static final ArchRule infrastructure_does_not_depend_on_other_modules_infrastructure =
noClasses()
.that().resideInAPackage("com.company.app.(*).infrastructure..")
.should().accessClassesThat()
.resideInAPackage("com.company.app.(*).infrastructure..")
.andShould(beInDifferentModules());
@ArchTest
static final ArchRule domain_has_no_spring_annotations =
noClasses()
.that().resideInAPackage("com.company.app.(*).domain..")
.should().beAnnotatedWith(Component.class)
.orShould().beAnnotatedWith(Service.class)
.orShould().beAnnotatedWith(Repository.class);
}
The last rule — no Spring annotations in domain classes — enforces that your domain model is a pure Java object model, not a Spring-managed bean graph. This makes your domain independently testable without starting a Spring context.
This test suite runs in CI. A PR that breaks module boundaries doesn't merge. That's the whole mechanism.
Defining the Module API
Each module exposes its capabilities through a Java interface in api/:
public interface OrderService {
OrderDto placeOrder(PlaceOrderCommand command);
OrderDto getOrder(UUID orderId);
List<OrderDto> getOrdersForCustomer(UUID customerId);
void cancelOrder(UUID orderId);
}
And DTO types — not domain objects:
public record OrderDto(
UUID id,
UUID customerId,
List<OrderLineDto> lines,
Money totalAmount,
String status,
Instant createdAt
) {}
public record OrderLineDto(UUID productId, String productName, int quantity, Money unitPrice) {}
Why DTOs instead of domain objects? Domain objects carry business logic and invariants — they're not safe to expose outside the module. Exposing Order directly creates an implicit coupling: any change to Order (adding a field, changing a type, splitting a class) breaks every caller. DTOs are a stable contract. Changes to domain model don't ripple out.
Cross-Module Communication via Domain Events
The most common question: "Orders needs to know when a product price changes in Catalog. How does that work without Orders depending on Catalog internals?"
Domain events. Catalog publishes an event. Orders subscribes.
public sealed interface CatalogEvent extends DomainEvent permits
CatalogEvent.ProductPriceChanged,
CatalogEvent.ProductDiscontinued,
CatalogEvent.ProductRestocked {
record ProductPriceChanged(
UUID productId,
Money oldPrice,
Money newPrice,
Instant occurredAt
) implements CatalogEvent {}
record ProductDiscontinued(
UUID productId,
String reason,
Instant occurredAt
) implements CatalogEvent {}
record ProductRestocked(
UUID productId,
int newQuantity,
Instant occurredAt
) implements CatalogEvent {}
}
Using Java 21 sealed interfaces here is intentional. The permits clause makes the event hierarchy exhaustive — when you add a new event type, every switch that handles CatalogEvent gets a compile error if it doesn't handle the new case. Your IDE finds every subscriber that needs updating.
Publishing an event:
@Service
@Transactional
class CatalogServiceImpl implements CatalogService {
private final ProductRepository productRepository;
private final ApplicationEventPublisher eventPublisher;
public void updatePrice(UUID productId, Money newPrice) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
Money oldPrice = product.getPrice();
product.setPrice(newPrice);
productRepository.save(product);
eventPublisher.publishEvent(
new CatalogEvent.ProductPriceChanged(productId, oldPrice, newPrice, Instant.now())
);
}
}
Subscribing from Orders:
@Component
class OrderPriceReconciler {
private final OrderRepository orderRepository;
@TransactionalEventListener(phase = AFTER_COMMIT)
void onPriceChanged(CatalogEvent.ProductPriceChanged event) {
List<Order> openOrders = orderRepository
.findOpenOrdersContaining(event.productId());
openOrders.forEach(order -> {
order.updateLinePrice(event.productId(), event.newPrice());
log.info("Updated price for product {} in order {} from {} to {}",
event.productId(), order.getId(), event.oldPrice(), event.newPrice());
});
}
@TransactionalEventListener(phase = AFTER_COMMIT)
void onProductDiscontinued(CatalogEvent.ProductDiscontinued event) {
orderRepository.findPendingOrdersContaining(event.productId())
.forEach(order -> order.cancel("Product discontinued: " + event.reason()));
}
}
The critical detail: @TransactionalEventListener(phase = AFTER_COMMIT).
Without this, the event fires during the catalog transaction, before the price update is committed. If the Orders handler reads the product from the database, it sees the old price. AFTER_COMMIT ensures the event fires only after the catalog transaction commits successfully — if catalog's transaction rolls back, Orders never hears about the price change.
This is the event-driven communication model that prevents half-committed states across modules.
Database Isolation Within One Schema
Sharing one database doesn't mean sharing tables. Each module owns its tables, and that ownership is enforced through a naming convention and an ArchUnit rule.
CREATE TABLE ord_orders (id UUID PRIMARY KEY, customer_id UUID NOT NULL, ...);
CREATE TABLE ord_order_items (id UUID PRIMARY KEY, order_id UUID NOT NULL, ...);
CREATE TABLE cat_products (id UUID PRIMARY KEY, name VARCHAR NOT NULL, ...);
CREATE TABLE cat_categories (id UUID PRIMARY KEY, ...);
CREATE TABLE cus_customers (id UUID PRIMARY KEY, email VARCHAR NOT NULL, ...);
CREATE TABLE cus_addresses (id UUID PRIMARY KEY, customer_id UUID NOT NULL, ...);
The prefix (ord_, cat_, cus_) makes ownership visible. More importantly:
@ArchTest
static final ArchRule orders_module_only_accesses_ord_tables =
noClasses()
.that().resideInAPackage("com.company.app.orders..")
.should().accessField(JdbcTemplate.class, "queryForList")
What about joins? This is the question that causes the most pushback. The answer is: you have two options, and both are valid.
Option A — Application-level join. Call CatalogService.getProducts(productIds) from the Orders module and merge in Java. This works well when the data sets are small and the query is selective.
Option B — Shared read model. For reporting queries that need cross-module joins, create a dedicated read model (a reports/ package or a separate schema) that denormalizes data explicitly for that purpose. This is the CQRS pattern applied at the module level.
What you do not do: write a SQL query that JOINs ord_orders and cat_products from inside the Orders module. That is a module boundary violation expressed in SQL instead of Java imports. ArchUnit won't catch it, but it's the same coupling.
Spring Configuration Per Module
Each module has its own @Configuration class that defines the beans for that module:
@Configuration
@ComponentScan(basePackages = "com.company.app.orders")
@EnableJpaRepositories(
basePackages = "com.company.app.orders.infrastructure",
entityManagerFactoryRef = "ordersEntityManagerFactory",
transactionManagerRef = "ordersTransactionManager"
)
public class OrdersConfig {
@Bean
@Primary
public LocalContainerEntityManagerFactoryBean ordersEntityManagerFactory(
DataSource dataSource, JpaProperties jpaProperties) {
var factory = new LocalContainerEntityManagerFactoryBean();
factory.setDataSource(dataSource);
factory.setPackagesToScan("com.company.app.orders.domain");
factory.setJpaProperties(jpaProperties.getProperties());
return factory;
}
}
Separate EntityManagerFactory per module means JPA entity scanning is scoped. The Orders module's JPA context only knows about Orders domain classes. Catalog domain classes are invisible to it. This prevents accidental lazy-loading of entities across module boundaries and gives you the option to split the module to a separate database later — the JPA configuration change is isolated to one module's config file.
Testing Strategy
The module structure directly shapes your testing strategy. Each module is tested in three layers:
1. Domain tests — no Spring, no database (fast):
class OrderTest {
@Test
void order_with_no_items_cannot_be_placed() {
Order order = Order.create(UUID.randomUUID());
assertThatThrownBy(order::place)
.isInstanceOf(InvalidOrderException.class)
.hasMessageContaining("at least one item");
}
@Test
void cancelled_order_cannot_be_placed() {
Order order = Order.createWithItems(List.of(anItem()));
order.cancel("test");
assertThatThrownBy(order::place)
.isInstanceOf(InvalidOrderException.class);
}
}
These tests start in milliseconds. Run them 10,000 times in the time it takes a Spring context to load.
2. Application service tests — mocked dependencies (medium speed):
@ExtendWith(MockitoExtension.class)
class OrderServiceImplTest {
@Mock CatalogService catalogService;
@Mock OrderRepository orderRepository;
@Mock ApplicationEventPublisher eventPublisher;
@InjectMocks OrderServiceImpl orderService;
@Test
void placeOrder_verifies_product_exists_in_catalog() {
when(catalogService.getProduct(PRODUCT_ID))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> orderService.placeOrder(aPlaceOrderCommand()))
.isInstanceOf(ProductNotFoundException.class);
verify(orderRepository, never()).save(any());
}
}
3. Module integration tests — real database, no other modules:
@SpringBootTest(classes = OrdersConfig.class)
@Testcontainers
class OrdersModuleIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@MockBean CatalogService catalogService;
@MockBean CustomerService customerService;
@Autowired OrderService orderService;
@Test
@Sql("/orders/test-data.sql")
void getOrdersForCustomer_returns_only_their_orders() {
List<OrderDto> orders = orderService.getOrdersForCustomer(CUSTOMER_A_ID);
assertThat(orders).allMatch(o -> o.customerId().equals(CUSTOMER_A_ID));
}
}
Note @SpringBootTest(classes = OrdersConfig.class) — this starts only the Orders module's Spring context. No Catalog beans, no Customer beans. The other modules' @Service interfaces are mocked. This makes the test ~5× faster than a full context load and keeps the test scope honest.
4. Cross-module tests — full context, event integration (slow, few):
@SpringBootTest
@Testcontainers
class OrderCatalogIntegrationTest {
@Autowired CatalogService catalogService;
@Autowired OrderRepository orderRepository;
@Test
void price_change_updates_pending_orders() {
UUID productId = catalogService.createProduct(aProductRequest()).id();
UUID orderId = orderService.placeOrder(PlaceOrderCommand.of(productId, 1)).id();
catalogService.updatePrice(productId, Money.of(99, "USD"));
awaitility().untilAsserted(() -> {
OrderDto order = orderService.getOrder(orderId);
assertThat(order.totalAmount()).isEqualTo(Money.of(99, "USD"));
});
}
}
Keep the count of these tests small — they're slow and test module integration, not module logic.
Module Communication Anti-Patterns
Three patterns that look fine in code review and cause problems six months later:
1. Sharing domain objects across modules
import com.company.app.catalog.domain.Product;
class OrderServiceImpl {
public void placeOrder(PlaceOrderCommand cmd) {
Product product = productRepository.findById(cmd.productId());
}
}
When Catalog adds a field to Product for a new feature, Orders' code and database query break. Use the CatalogService interface and ProductDto instead.
2. Bypassing the API to reach the repository
@Autowired CatalogProductRepository catalogProductRepo;
List<Product> products = catalogProductRepo.findByIds(ids);
The repository access bypasses any business rule in CatalogServiceImpl. If Catalog adds authorization checks or audit logging to getProducts(), Orders won't get them. ArchUnit catches this in CI.
3. Chatty synchronous communication for every operation
order.getItems().forEach(item -> {
ProductDto product = catalogService.getProduct(item.productId());
});
Even in a monolith this is N database queries. Use bulk fetching:
Set<UUID> productIds = order.getItems().stream()
.map(OrderItem::getProductId).collect(toSet());
Map<UUID, ProductDto> products = catalogService.getProducts(productIds);
After working with this architecture on several teams, I've converged on two signals that actually predict when extraction is worth it — not the ones usually cited.
Signal 1: Two teams want to deploy independently at different cadences and their releases are colliding.
If the Catalog team ships 3 times a day and their changes keep blocking Orders deployments (or vice versa), the deployment coupling is hurting real people. Extract at the deployment boundary, not the domain boundary.
Signal 2: One module has dramatically different infrastructure needs that are fighting the rest of the application.
Catalog might need a read-replica for search queries and a CDN-friendly caching strategy. Orders needs ACID transactions and message queues. These are genuinely different operational profiles. If forcing them into one deployment means both get suboptimal infrastructure, extraction might be worth it.
Signals that don't actually predict success:
- "The module is getting big" — 50K lines in one module is fine. Microservices don't reduce code volume.
- "We want to use different technology" — Mixing JVM languages across module boundaries is asking for integration pain. One Java monolith beats a polyglot distributed system for most teams.
- "It will be easier to scale" — Unless you've proven that specific module is the bottleneck, horizontal scaling of the whole monolith is usually easier, faster, and cheaper.
When you do extract, the module boundary you built here makes it clean. The api/ package becomes the service's public API contract. The domain and application packages become the service's implementation. Infrastructure switches from local beans to HTTP clients (or message consumers). The module already behaves like a service — you're just moving it to its own process.
The Numbers
We adopted this architecture at a client engagement: a six-year-old Spring Boot monolith, ~150K lines of Java, 11-person team, 4 bounded contexts.
Before the restructure: engineers regularly broke each other's code, because everything was in com.company.app.service.* and import-anything-from-anywhere was the norm. Build time was the only feedback loop.
After a 3-week restructure (largely automated — this is the kind of transformation where Claude Code genuinely helps with the mechanical moves):
- Cross-module bugs in CI: dropped from 3–5 per week to essentially zero (ArchUnit catches them)
- Build time: unchanged (structural, not a build change)
- Onboarding: new engineers understand the module they're working in within a day, instead of needing to understand the whole codebase
- Time to extract a module to a real microservice when the business needed it: 2 days, not 2 weeks
The two days is the number that justified the investment.
Getting Started
If you're converting an existing codebase, the path is:
- Identify 3–5 natural domain areas in your codebase (what nouns does your business talk about? Orders, Customers, Catalog, Payments, etc.)
- Create the package structure — this is mechanical, Claude Code does it well
- Add the ArchUnit test with your boundaries — it will fail on everything first
- Fix violations module by module, starting with infrastructure (no cross-module repository access), then domain (no imported domain objects), then API (replace direct class imports with interface + DTO)
- For new features, enforce the structure from day one — don't wait until the refactor is complete
The structure pays off immediately: even with 30% of violations still remaining, you have a clear map of where the coupling exists. That's half the battle.
Most teams don't need microservices. They need modules. The modular monolith gives you the architectural clarity of microservices, the operational simplicity of a single deployment, and a realistic migration path to distributed services if you ever genuinely need them.
Build the right thing first.
The package structure and ArchUnit rules in this post are available as a GitHub template — check the footer for the link.
Avaneesh Yadav is Engineering Manager at HashedIn by Deloitte, building enterprise systems with Java and AI. He writes at buildingai.in.