Seu sistema tem 14 serviços, sete repositórios precisam mudar para entregar uma funcionalidade e todos manipulam versões ligeiramente diferentes de Order. Isso não prova que a arquitetura é distribuída por domínio. Prova apenas que o git clone ganhou importância operacional.
O resultado que buscamos é verificável: antes de abrir outro repositório, os limites devem passar por um teste estrutural, cada invariante deve ter um único dono e os módulos devem conseguir mudar sem acessar os detalhes internos uns dos outros. Se isso ainda não acontece dentro de um processo, a rede não vai corrigir o desenho.
A combinação atual para o exemplo
A base usada aqui é Java 25 LTS, Spring Boot 4.1.0 e Spring Modulith 2.1.0. O Boot 4.1 aceita Java de 17 a 26 e gerencia Spring Framework 7.0.8, conforme a matriz oficial de requisitos. Java 26 também é aceito, mas não é LTS; por isso o exemplo fica no Java 25.
Spring Boot 3.5.16 com Java 25 continua sendo uma combinação válida para quem está migrando, mas é a major anterior. O caminho seguro é primeiro levar testes e dependências para Boot 4.1, resolver mudanças incompatíveis e só então introduzir a fronteira modular. Não vale misturar atualização de major, reorganização de domínio e extração de processo no mesmo pull request.
O pom.xml mínimo fica assim:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>shop</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>25</java.version>
<spring-modulith.version>2.1.0</spring-modulith.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>${spring-modulith.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
spring-modulith-starter-core fornece a estrutura para modelar e verificar os módulos, mas não traz a API de eventos. O spring-modulith-starter-jdbc acrescenta spring-modulith-events-api — onde vive @ApplicationModuleListener — e o Event Publication Registry com armazenamento JDBC. Já spring-boot-starter-jdbc e H2 fornecem o DataSource e o gerenciador de transações necessários para o exemplo executar sem um banco externo.
Fronteira de domínio não é divisão de equipe
Um bounded context delimita um modelo e sua linguagem. Order pode significar intenção de compra no contexto de pedidos e uma fonte de itens a separar no estoque. Os dois contextos podem compartilhar um OrderId; eles não precisam compartilhar a classe Order, suas tabelas ou suas regras.
O organograma ajuda a definir responsabilidade, mas não demonstra uma fronteira. A fronteira aparece quando um lado pode mudar sem obrigar o outro a atualizar junto, quando cada lado protege suas invariantes e quando a comunicação acontece por uma API ou evento explícito. Um candidato a microserviço não deve cortar um agregado ao meio: a transação que mantém a invariante é o piso do corte.
Antes de separar qualquer agregado, responda às cinco perguntas abaixo. Se uma regra precisa valer atomicamente nos dois lados, se há transação única ou se ambos escrevem nas mesmas tabelas, mantenha junto. Só avance para uma fronteira quando linguagem, invariantes, dados e ciclo de mudança permitirem independência.
| Pergunta | Sinal para manter junto | Sinal para separar | Remediação e observação segura |
|---|---|---|---|
| O termo tem o mesmo significado e as mesmas regras? | Um único modelo é coerente | Cada lado usa vocabulário e regras próprios | Escreva um glossário por contexto e faça tradução na borda. Em produção, compare contratos OpenAPI e registre falhas de mapeamento, sem registrar dados sensíveis. |
| Existe invariante compartilhada? | A regra precisa valer no mesmo instante | Cada agregado protege sua própria regra | Recoloque a regra em um único agregado. Observe compensações, falhas de saga e chamados que exigem correção coordenada. |
| Os ciclos de mudança são independentes? | O mesmo ticket e release atravessam vários repositórios | Cada módulo muda e é liberado sozinho | Use um monólito modular e um pipeline enquanto houver trem de releases. Meça no histórico de PRs quantos componentes mudam por entrega. |
| É necessário um único commit ACID? | A operação não admite estado intermediário | Consistência eventual é aceitável | Mantenha a transação dentro do agregado e use evento com publicação durável entre contextos. Monitore publicações pendentes, retries e dead letters. |
| Cada lado é dono exclusivo dos dados? | Ambos escrevem nas mesmas tabelas ou dependem de FKs cruzadas | Cada módulo controla seu schema e integra por evento ou API | Separe schemas, remova escrita cruzada e crie projeções de leitura. Audite acessos ao banco e falhas de migração por módulo. |
A publicação durável citada na tabela depende do Event Publication Registry e de um armazenamento configurado, como spring-modulith-starter-jdbc ou spring-modulith-starter-jpa, além do gerenciador de transações correspondente. spring-modulith-starter-core sozinho não registra publicações incompletas.
A decisão é conservadora de propósito. Em caso ambíguo, mantenha no mesmo deployable e crie primeiro uma fronteira lógica. Extrair cedo demais transforma uma dúvida de modelagem em uma dúvida de modelagem com timeout.
Dois módulos, uma aplicação
Comece com pacotes por funcionalidade sob uma única classe @SpringBootApplication:
// src/main/java/com/example/shop/ShopApplication.java
package com.example.shop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
O módulo order publica somente o identificador necessário. A invariante — um pedido sem itens não pode ser concluído — permanece dentro de Order:
// src/main/java/com/example/shop/order/OrderId.java
package com.example.shop.order;
import java.util.UUID;
public record OrderId(UUID value) {}
// src/main/java/com/example/shop/order/OrderCompleted.java
package com.example.shop.order;
public record OrderCompleted(OrderId orderId) {}
// src/main/java/com/example/shop/order/Order.java
package com.example.shop.order;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public final class Order {
private final OrderId id = new OrderId(UUID.randomUUID());
private final List<String> productIds = new ArrayList<>();
public void addLine(String productId) {
if (productId == null || productId.isBlank()) {
throw new IllegalArgumentException("productId is required");
}
productIds.add(productId);
}
OrderCompleted complete() {
if (productIds.isEmpty()) {
throw new IllegalStateException("an order needs at least one line");
}
return new OrderCompleted(id);
}
}
// src/main/java/com/example/shop/order/OrderManagement.java
package com.example.shop.order;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderManagement {
private final ApplicationEventPublisher events;
public OrderManagement(ApplicationEventPublisher events) {
this.events = events;
}
@Transactional
public void complete(Order order) {
events.publishEvent(order.complete());
}
}
A transação no método publicador não é decorativa. Conforme a documentação de eventos do Spring Modulith, @ApplicationModuleListener combina execução assíncrona com uma nova transação e um listener de evento transacional. Como o fallback não é habilitado por padrão, publicar fora de uma transação não dispara esse listener.
inventory conhece o evento público, não o agregado nem order.internal.*. O listener recebe a notificação pela integração oferecida pelo Modulith:
// src/main/java/com/example/shop/inventory/InventoryManagement.java
package com.example.shop.inventory;
import com.example.shop.order.OrderCompleted;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Service;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class InventoryManagement {
private final Set<String> completedOrders = ConcurrentHashMap.newKeySet();
@ApplicationModuleListener
void on(OrderCompleted event) {
completedOrders.add(event.orderId().value().toString());
}
}
Em um sistema real, o listener carregaria os dados necessários a partir de uma projeção ou contrato explícito e atualizaria somente o schema de estoque. Ele não receberia Order para então passear pelo agregado alheio.
O teste que impede o vazamento
Spring Modulith deriva os módulos dos pacotes diretamente abaixo de com.example.shop. O gate estrutural deve entrar no CI:
// src/test/java/com/example/shop/ModularityTests.java
package com.example.shop;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
class ModularityTests {
@Test
void modulesShouldRespectBoundaries() {
ApplicationModules.of(ShopApplication.class).verify();
}
}
verify() rejeita ciclos e acesso aos tipos internos de outro módulo. Por exemplo, se alguém criar com.example.shop.order.internal.OrderLinesMustNotBeEmpty e importar essa classe em inventory, o teste falha. A correção não é tornar a classe pública: é devolver a invariante para Order e fazer estoque reagir apenas ao evento público.
O comportamento do agregado também merece um teste direto:
// src/test/java/com/example/shop/order/OrderTests.java
package com.example.shop.order;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertThrows;
class OrderTests {
@Test
void refusesToCompleteWithoutLines() {
var order = new Order();
assertThrows(IllegalStateException.class, order::complete);
}
}
O primeiro teste protege a arquitetura; o segundo protege a regra de negócio. Um não substitui o outro. Em produção, exponha os módulos pelo Actuator quando isso fizer sentido, acompanhe no Event Publication Registry as publicações incompletas e associe logs por OrderId, sem serializar o agregado inteiro. No CI, mantenha verify() obrigatório e execute testes de módulo com @ApplicationModuleTest à medida que cada fronteira ganhar casos próprios.
Quando times diferentes ainda devem entregar juntos
Use os sinais já consolidados na tabela, não o organograma, para decidir o deploy. Times diferentes podem ter ownership por pacote e CODEOWNERS; isso não obriga a inserir uma chamada de rede no meio da invariante.
Manter o deploy unido não significa aceitar um pacote sem limites. APIs públicas pequenas, internos protegidos, schemas separados e eventos explícitos continuam obrigatórios. Até o mesmo servidor de banco pode ser usado; o problema é compartilhar tabelas e autoridade de escrita, não o endereço IP.
A DevDojo recomendaria a extração quando os sinais da coluna “separar” predominarem e o módulo passar no verify(), puder ser implantado, monitorado e revertido sem coordenação com o outro contexto. Enquanto isso não for verdade, a recomendação é manter um monólito modular e fortalecer a fronteira lógica.
Refatoração incremental do monólito
Não reescreva o sistema. Faça a fronteira crescer ao redor do comportamento existente:
- Reorganize um fluxo por vez em pacotes de funcionalidade, como
ordereinventory, sem trocar o deploy. - Adicione o BOM e os starters do Modulith; coloque
ApplicationModules.verify()no CI. - Mova implementações para pacotes
internale corrija cada acesso cruzado com API pequena, evento ou retorno da regra ao agregado correto. - Substitua chamadas diretas entre módulos por eventos apenas onde consistência eventual for aceita. Configure
spring-modulith-starter-jdbc,spring-modulith-starter-jpaou armazenamento equivalente para a publicação durável e trate retries antes de remover o caminho antigo. - Dê a cada módulo autoridade exclusiva sobre suas tabelas ou schema e crie projeções para leitura externa.
- Adicione
@ApplicationModuleTestaos fluxos críticos e observe, pelo histórico de mudanças, se a suposta independência realmente apareceu. - Extraia um processo pelo padrão Strangler somente quando o módulo puder ser implantado, monitorado e revertido sem coordenar
ordereinventory.
O limite dessa abordagem é simples: Modulith verifica dependências de código; ele não decide se a linguagem está correta nem detecta sozinho uma tabela compartilhada por SQL, migração ou acesso indireto. Por isso o gate estrutural deve andar junto com testes de domínio, ownership de dados e observação das mudanças reais.
Como próximo passo, escolha um agregado que hoje atravessa vários pacotes, responda às cinco perguntas e adicione o teste com ApplicationModules.of(...).verify(). Se o teste ficar verde sem tornar tudo público, você encontrou uma fronteira útil. Se não ficar, encontrou trabalho de modelagem — antes de encontrar uma conta maior de infraestrutura.