In a Spring Boot service organized by technical layers (controller, service, repository, entity…), the business rule ends up next to JPA, Kafka and Jackson code. To test it, you start Spring, or you mock a large part of the framework.

This post shows another way to organize the code, the hexagonal architecture, on a small and complete example: a service that reads sensor readings from Kafka, checks them and computes statistics. The domain and the use cases are plain Java, Spring, Kafka and JPA stay in adapters, and a test makes the build fail if this rule is broken. The code is in the tag phase-1 of the public repository iot-monitoring-platform, presented in the post Kafka and Spring Boot: a complete local project.

The idea in short

The hexagonal architecture comes from an article by Alistair Cockburn (2005), who also calls it « Ports and Adapters ». The idea fits in three words:

  • the core (the domain and the use cases) holds the rules of the application, in simple code, without a framework;
  • the ports are interfaces: what the core offers (input ports) and what it needs (output ports);
  • the adapters connect the ports to the real world: a REST controller, a Kafka listener, a JPA repository.

Dependencies always point to the inside: an adapter knows the core, the core knows no adapter.

The hexagonal architecture of the service iot-backend-processing: in the middle, the domain and the use cases in plain Java; at the bottom of the hexagon, the input and output ports; around it, the Kafka, REST and JPA adapters. All dependencies point to the inside.Use casesStoreMessageServiceListStatisticsService…DomainSensorReadingParseResult (sealed)SensorStatisticsinput portsStoreMessageUseCase…output portsMessageRepositoryStatisticsRepositoryInput adaptersSensorMessageListenerreads KafkaMessageController…REST APIcallOutput adaptersJpaMessageRepositoryJpaStatisticsRepositoryPostgreSQLimplementDependencies point to the inside: the core knows no adapter.

The service of the example, iot-backend-processing, is small on purpose: it receives a line of text from Kafka (for example sensor-1 TEMPERATURE 21.5), checks it with a business rule, stores the message, updates the statistics of the sensor, and gives them through a REST API. This is enough to see each part of the architecture, with no noise around it.

Before, after

Before this work, the service was organized by technical layers (public history of the project, commit 82b4054):

processing/
  BkProcessingApplication.java
  config/       CorsConfig, KafkaConsumerConfig
  controller/   StatsController
  dto/          SensorEvent, SensorEventEnvelope, SensorStats
  entity/       SensorReading
  kafka/        SensorEventConsumer
  repository/   SensorReadingRepository
  service/      AggregationService
  util/         PodInfo

In the tag phase-1:

processing/
  ProcessingApplication.java
  domain/                  SensorReading, ParseResult, SensorStatistics,
                           SensorType, ReceivedMessage, MessageStatus
  application/
    port/in/               StoreMessageUseCase, ListLastMessagesUseCase,
                           ListStatisticsUseCase, InvalidLimitException
    port/out/              MessageRepository, StatisticsRepository
    StoreMessageService, ListLastMessagesService, ListStatisticsService
  adapter/
    in/kafka/              SensorMessageListener
    in/web/                MessageController, StatisticsController + responses
    out/persistence/       JPA entities, Spring Data repositories, adapters
  config/                  ProcessingConfig

The two versions do not have the same features: the old one passed pod info and JSON envelopes along, with no business rule; the new one reads and checks each line. So we do not compare the number of classes, only the shape. Before, the name of a package said a technology (kafka, repository); now it says a role.

The domain: the rule, in plain Java

A valid line has three parts: a sensor, a type and a value inside the range of its type. The rule is in SensorReading.parse:

public static ParseResult parse(String text) {
    if (text == null || text.isBlank()) {
        return new ParseResult.Rejected("empty line");
    }
    String[] parts = text.strip().split("\\s+");
    if (parts.length != 3) {
        return new ParseResult.Rejected("a line needs 3 parts: sensor, type and value");
    }
    String sensorId = parts[0];
    if (!SENSOR_ID.matcher(sensorId).matches()) {
        return new ParseResult.Rejected("invalid sensor id " + sensorId);
    }
    Optional<SensorType> type = SensorType.fromName(parts[1]);
    if (type.isEmpty()) {
        return new ParseResult.Rejected("unknown type " + parts[1]);
    }
    if (!DECIMAL.matcher(parts[2]).matches()) {
        return new ParseResult.Rejected("invalid value " + parts[2]);
    }
    double value = Double.parseDouble(parts[2]);
    if (!type.get().accepts(value)) {
        return new ParseResult.Rejected(type.get().name() + " must be between "
                + type.get().min() + " and " + type.get().max());
    }
    return new ParseResult.Valid(new SensorReading(sensorId, type.get(), value));
}

For a service that receives sensor data, a bad line is a normal case, not a bug of the program. So the method does not throw an exception: it returns a result, one of the two cases of a sealed interface (Java 17):

public sealed interface ParseResult {

    record Valid(SensorReading reading) implements ParseResult {
    }

    record Rejected(String reason) implements ParseResult {
    }
}

The compiler knows all the cases: a switch on a ParseResult (pattern matching for switch, Java 21) must handle Valid and Rejected, or it does not compile.

The statistics of a sensor are an immutable record. Each reading gives new statistics, without reading the past readings again:

public SensorStatistics add(SensorReading reading) {
    if (!sensorId.equals(reading.sensorId()) || type != reading.type()) {
        throw new IllegalArgumentException("The reading of " + reading.sensorId() + " " + reading.type()
                + " does not belong to the statistics of " + sensorId + " " + type);
    }
    return new SensorStatistics(sensorId, type, count + 1,
            Math.min(min, reading.value()), Math.max(max, reading.value()), sum + reading.value());
}

Here, the exception makes sense: adding the reading of another sensor would be a bug of the program, not bad data.

In the whole domain package, there is no import of Spring, Kafka, JPA or Jackson.

The ports and the use cases

The input port says what the service offers:

public interface StoreMessageUseCase {
    ReceivedMessage store(String text, int partition, long offset);
}

The output ports say what it needs, without saying how. For example, for the statistics:

public interface StatisticsRepository {
    Optional<SensorStatistics> find(String sensorId, SensorType type);
    void save(SensorStatistics statistics);
    List<SensorStatistics> findAll();
}

The use case connects the rule and the ports:

public class StoreMessageService implements StoreMessageUseCase {

    private final MessageRepository messageRepository;
    private final StatisticsRepository statisticsRepository;
    private final Clock clock;

    // constructor: the three fields above

    @Override
    public ReceivedMessage store(String text, int partition, long offset) {
        ParseResult result = SensorReading.parse(text);
        ReceivedMessage stored = messageRepository.save(
                ReceivedMessage.notStoredYet(text, partition, offset, Instant.now(clock), result));
        if (result instanceof ParseResult.Valid valid) {
            updateStatistics(valid.reading());
        }
        return stored;
    }

    private void updateStatistics(SensorReading reading) {
        SensorStatistics statistics = statisticsRepository.find(reading.sensorId(), reading.type())
                .map(current -> current.add(reading))
                .orElseGet(() -> SensorStatistics.first(reading));
        statisticsRepository.save(statistics);
    }
}

No @Service, no @Autowired: it is a Java class. The time comes from a Clock given to the constructor, so a test can fix it. Spring creates it in the config class, with the adapters that implement the ports:

@Bean
public StoreMessageUseCase storeMessageUseCase(MessageRepository messageRepository,
                                               StatisticsRepository statisticsRepository, Clock clock) {
    return new StoreMessageService(messageRepository, statisticsRepository, clock);
}

All the Spring wiring is in config/. The rest of the core does not know that Spring exists.

The adapters

The Kafka listener is an input adapter: it turns a Kafka message into a call of the input port.

@KafkaListener(topics = "${iot.kafka.topic}")
@Transactional
public void onMessage(ConsumerRecord<String, String> consumerRecord) {
    ReceivedMessage stored = storeMessage.store(
            consumerRecord.value(), consumerRecord.partition(), consumerRecord.offset());
    // … log of the result
}

The transaction is opened here, in the adapter: the message and the statistics are saved together or not at all, and the use case stays plain Java. This works because the Spring Kafka container calls the listener through the Spring bean: in proxy mode (the default mode), Spring intercepts only the calls that go through the proxy.

The JPA adapters are output adapters. The JPA entity (StatisticsEntity, with its @Entity, @Column… annotations) is a different class from the domain record. The adapter translates:

@Override
public void save(SensorStatistics statistics) {
    StatisticsEntity entity = springDataRepository
            .findBySensorIdAndSensorType(statistics.sensorId(), statistics.type())
            .orElseGet(() -> new StatisticsEntity(statistics.sensorId(), statistics.type()));
    entity.update(statistics.count(), statistics.min(), statistics.max(), statistics.sum());
    springDataRepository.save(entity);
}

In the same way, the REST API returns response records (StatisticsResponse, MessageResponse), not the domain objects: the shape of the JSON can change without touching the domain.

The tests this allows

The domain, with nothing to start

One test line per kind of bad line (excerpt):

@ParameterizedTest(name = "[{0}] -> {1}")
@CsvSource(delimiter = '|', value = {
        "sensor-1 WIND 12                    | unknown type WIND",
        "sensor-1 TEMPERATURE 21,5           | invalid value 21,5",
        "sensor-1 HUMIDITY -1                | HUMIDITY must be between 0 and 100"})
void parse_badLine_returnsRejectionWithReason(String text, String reason) {
    assertThat(SensorReading.parse(text)).isEqualTo(new ParseResult.Rejected(reason));
}

The use case, with fake ports

The ports are interfaces: a test gives them an in-memory version, and a fixed clock:

private final FakeMessageRepository messageRepository = new FakeMessageRepository();
private final FakeStatisticsRepository statisticsRepository = new FakeStatisticsRepository();
private final StoreMessageService service =
        new StoreMessageService(messageRepository, statisticsRepository, Clock.fixed(NOW, ZoneOffset.UTC));

@Test
void store_rejectedLine_savesReasonAndKeepsStatistics() {
    service.store("sensor-1 TEMPERATURE 20", 0, 0L);

    ReceivedMessage stored = service.store("sensor-1 WIND 12", 0, 1L);

    assertThat(stored.status()).isEqualTo(MessageStatus.REJECTED);
    assertThat(stored.rejectionReason()).isEqualTo("unknown type WIND");
    assertThat(statisticsRepository.findAll())
            .containsExactly(new SensorStatistics("sensor-1", SensorType.TEMPERATURE, 1, 20, 20, 20));
}

No Spring, no Kafka, no database, no Mockito. The tests of the domain and of the use cases:

mvn -pl iot-backend-processing test -Dtest='SensorReadingTest,SensorStatisticsTest,StoreMessageServiceTest,ListLastMessagesServiceTest'
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.053 s -- in dev.aymenjamaaoui.iot.processing.application.StoreMessageServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.007 s -- in dev.aymenjamaaoui.iot.processing.application.ListLastMessagesServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.006 s -- in dev.aymenjamaaoui.iot.processing.domain.SensorStatisticsTest
[INFO] Tests run: 24, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.058 s -- in dev.aymenjamaaoui.iot.processing.domain.SensorReadingTest
[INFO] Tests run: 35, Failures: 0, Errors: 0, Skipped: 0

35 tests, each one in a few milliseconds. The adapters have their own tests: a @WebMvcTest for the REST API, and an integration test with a real Kafka and a real PostgreSQL (Testcontainers).

The rule, checked by the build

An architecture rule written only in a README ends up forgotten. The project checks it with ArchUnit:

@AnalyzeClasses(packages = "dev.aymenjamaaoui.iot.processing", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {

    @ArchTest
    static final ArchRule DOMAIN_AND_APPLICATION_HAVE_NO_FRAMEWORK = noClasses()
            .that().resideInAnyPackage("..domain..", "..application..")
            .should().dependOnClassesThat().resideInAnyPackage(
                    "org.springframework..", "org.apache.kafka..", "jakarta..", "org.hibernate..",
                    "com.fasterxml.jackson..", "tools.jackson..");
}

To see it fail, I add @Component (Spring) to the record SensorStatistics on purpose, then I run mvn -pl iot-backend-processing test -Dtest=ArchitectureTest (short output):

Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in any package ['..domain..', '..application..'] should depend on classes that reside in any package ['org.springframework..', 'org.apache.kafka..', 'jakarta..', 'org.hibernate..', 'com.fasterxml.jackson..', 'tools.jackson..']' was violated (1 times):
Class <dev.aymenjamaaoui.iot.processing.domain.SensorStatistics> is annotated with <org.springframework.stereotype.Component> in (SensorStatistics.java:0)
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0

The message gives the rule, the class and the cause. The build fails, so nobody can break the rule without seeing it.

The cost, honestly

This way of organizing the code has a price:

  • more classes: the service has 28 classes in src/main, with 6 ports (interfaces and one exception) and 2 JPA entities next to the domain records;
  • translation code: JPA entity ↔ domain record ↔ REST response;
  • more files to open to follow a call, from the listener to the database.

On such a small example, the structure can look heavy: here it is on purpose, so that each part is visible. It pays when the service grows: when the rules grow, when a technology changes (another database, another way to receive the messages), or when you want to test the rule fast and often. For a service that only copies data from one system to another, with no real rule, a simpler structure is enough.

Tested with

Java 25 (Temurin 25.0.4), Spring Boot 4.1.2, ArchUnit 1.5.1, JUnit 5, AssertJ, Maven 3.10.0. Code: tag phase-1, service iot-backend-processing.

Sources

Further reading