How do you keep a bigger Spring Boot application modular without creating more and more layers?

I was watching Jakub Nabrdalik talks about package scope, modularity and hexagonal architecture and it made me think again about how we structure bigger Spring Boot applications.

A lot of projects start with something like:

text controller/ service/ repository/ entity/ configuration/

It is simple at the beginning.

But after few years you can have 100 services, 80 repositories and a lot of classes which are public only because this is how the project was created.

The package structure tells you almost nothing about the business.

I started to prefer something closer to this:

```text orders/ OrderFacade.java // public CreateOrder.java // public OrderResult.java // public

Order.java // package-private OrderItem.java // package-private OrderService.java // package-private OrderRepository.java // package-private OrderPolicy.java // package-private OrderConfiguration.java // package-private 

```

From another module I want to see mostly:

text OrderFacade CreateOrder OrderResult

and not all implementation classes.

For me this gives public a real meaning.

public means that this is part of the API of my module, not only that Spring can instantiate it.

Java package scope is actually quite powerful for this.

Infrastructure

The same idea can be used for infrastructure.

For example the business module can define the port:

```java interface OrderRepository {

Order find(OrderId id); void save(Order order); 

} ```

and infrastructure can provide the implementation:

```text orders/ OrderRepository.java

infrastructure/ persistence/ HibernateOrderRepository.java JpaOrderEntity.java SpringDataOrderRepository.java ```

The infrastructure classes don't have to become part of the API used by the rest of application.

I think this is one nice thing about Ports and Adapters.

The domain says what it needs.

Hibernate, Kafka, HTTP clients etc. are implementation details.

It also helps to avoid designing the domain around Hibernate only because Hibernate is already there.

For CRUD this probably doesn't matter much.

But when business rules become more complex, I don't really want my whole domain model to be just JPA entities with setters.

Spring configuration

This also makes me question if everything needs to be discovered using component scanning.

Instead of:

java @Service public class OrderService { }

we can keep implementation package-private and wire it inside the module:

```java @Configuration class OrderConfiguration {

@Bean OrderFacade orderFacade(OrderRepository repository) { return new OrderFacade(repository); } 

} ```

So maybe Spring DI should not automatically mean that everything becomes public application API.

But where should module boundaries be?

This is probably the more difficult question.

Package-by-feature alone doesn't solve it.

You can create:

text orders/ payments/ customers/ products/

and still have bad boundaries.

Some heuristics I find useful:

  • concepts that change together probably belong together,
  • concepts with different meaning in different contexts probably should be separated,
  • things changing at very different rate may deserve separate modules,
  • business process steps and transition points can suggest boundaries,
  • if two modules always need one database transaction, maybe the boundary is wrong,
  • if one team cannot understand or own the module, maybe it is too big,
  • a shared module should exist because the domain needs it, not only because we want code reuse.

For example, Customer can mean something different in different places.

In order history it can be mostly a person who placed orders.

In loyalty it can be a person with points and rewards.

In support it can be a person with open tickets.

Trying to make one universal Customer model for all of this can easily create a big coupled model.

Another useful question is:

How often do these two things really change together?

If one part changes every week and another one once per year, maybe they don't need to be in the same module even if today they are technically connected.

I found these domain boundary heuristics interesting in Nick Tune's Architecture Modernization:

https://www.manning.com/books/architecture-modernization

Especially the chapter about identifying domains and subdomains.

ArchUnit

Package scope already gives some protection, but it cannot express every architecture rule.

This is where I think ArchUnit can be useful.

For example, we can verify that modules don't start depending on internal packages by accident:

java noClasses() .that().resideInAPackage("..orders..") .should().dependOnClassesThat() .resideInAPackage("..payments.internal..");

Or verify that only selected packages are allowed to access infrastructure.

The exact rules depend on the project, but I like the idea that architecture is not only documentation.

Some rules can fail the build.

Database boundaries

For a modular monolith I also like the idea that a module owns its data.

Not necessarily a separate database for every module, but at least clear ownership of tables or schemas.

For example:

text orders schema payments schema customers schema

orders should not just read tables owned by payments because it is convenient.

It should go through the module API.

I also try to avoid database transactions spanning many business modules.

If this happens all the time, I would first ask if the boundary is correct.

Of course sometimes there are good reasons for it, but I don't think it should be the default.

Reads are another thing

I also don't think every read has to go through:

text Controller -> Service -> Repository -> Entity -> DTO

For some queries this adds a lot of code without adding much value.

A query can read a projection which is already shaped for the use case.

I would still keep the write side strongly protected by the domain model where business rules matter.

What I am curious about

I am not saying this is the one correct architecture.

I am more interested how these ideas survive in real Spring Boot systems after 3, 5 or 10 years.

Do you use Java package scope seriously?

Do you expose only a small facade/API from each module?

Do you use explicit @Configuration instead of making everything a component?

Do you use ArchUnit?

Spring Modulith?

Multiple Gradle/Maven modules?

Separate database schemas per module?

And maybe the most important question: how do you discover module boundaries before they become another set of artificial technical folders?

Some things that influenced my thinking:

Jakub Nabrdalik talks about package scope, modularity and hexagonal architecture:

https://nabrdalik.dev/talks/

Nick Tune, Architecture Modernization:

https://www.manning.com/books/architecture-modernization

A small project where I experiment with DDD / modular monolith ideas:

https://github.com/CamilYed/readable-tests-by-example

submitted by /u/Hot_Code5129
[link] [留言]