Spring Boot
A modular monolith with Spring Modulith, hexagonal inside every module, one deployable — and architecture tests as the load-bearing part.
Verified 2026-07-31 · Against Spring Boot 4.1, Spring Modulith, springdoc-openapi, Flyway, ShedLock, Testcontainers.
Modular monolith with Spring Modulith, hexagonal inside every module, one deployable. Start here for any non-trivial Spring service: module boundaries become compiler- and test-enforced without paying the distributed-systems tax up front. Extract a service later only when a module needs its own scaling or release cadence — the boundary is already drawn, so extraction is mechanical.
Never start from the layered controller/service/repository/dto/mapper
default. It scales in file count, not comprehension, and it puts every business
rule behind a database.
Module layout
One Maven module per business capability; the root package annotated
@ApplicationModule, with no classes in the module root package.
<module>/
core/ model/ usecase/ port/ ← three packages, no others
inbound/ web|messaging|scheduling|mcp
outbound/ persistence|external
api/ api/event/ ← the cross-module contract, @NamedInterface
config/- A module depends on another only through that module's
apipackage. Enforce with Modulithverify()andspring.modulith.detection-strategy=explicitly-annotated; it rejects cycles in the same test. - Integration events belong in
api/event, outsidecore. An event incoreis module-internal and therefore unreachable by the consumer it exists for. Guard it with an ArchUnit rule on the event marker interface —verify()alone stays green while a contract is merely unreachable, because no module has yet committed the violation. - No
service,mapper,repository,queryordtopackage. Those name mechanisms, not roles, and they drain logic out of the model into procedure bags.
Web layer
- A handler receives its caller; it never looks it up. A
RequestContextrecord resolved by aHandlerMethodArgumentResolver, declared as the handler's first argument, always. New request facts — tenant, locale, channel — then join the record and the resolver, and no controller signature changes. Enforce the position with ArchUnit. - Errors as RFC-7807
ProblemDetailfrom a single@RestControllerAdvice. - A command holds strings; a port holds types. The request record is the only
place
valueOfruns, after validation — otherwise a bad filter value returns 500 instead of 400.
springdoc
- Annotate only what is true of that endpoint:
@Operation(summary)and the business409s it can actually produce — and a 409 must name what conflicts. Do not annotate the success status; springdoc derives it from@ResponseStatusand the return type. - Never write
401/403/404/422/500on a handler. They are the platform's error contract. Add them from oneGlobalOperationCustomizerthat reads the code the runtime uses —401/403only where@PreAuthorizeis present,404only where a@PathVariableis (a collection endpoint cannot fail to find one resource). The document then cannot claim behaviour the code does not have. - Declare accepted values once — a custom annotation on the record component,
carried into request schemas and query parameters by property and parameter
customizers.
@Schema(allowableValues=…)never gets written, so it cannot drift from the validator. - Bind query parameters as one
@ParameterObjectrecord, not a list of@RequestParams. - Do not nest
Input/Outputinside the use-case class. Every nested command then shares the simple nameInput, springdoc collides the schemas, and each needs a@Schema(name=…)workaround. Use top-level<UseCase>Input/<UseCase>Outputfiles instead. - Assert the generated document in an integration test. A controller annotation is only safe to delete if something demonstrably replaces it.
Leak trap: springdoc documents any handler argument it does not recognise as a
query parameter. Register argument-resolver-supplied types with
SpringDocUtils.addRequestWrapperToIgnore — otherwise the request context is
published as a required query parameter and drags the current-user schema, roles
and permissions included, into the public document.
Persistence and migrations
- Flyway, per-module ownership. Migrations under
db/migration/<domain>; the application lists the locations. FilenamevYYMMddHHmm_description.sql. - Migrations are immutable once shared. No exceptions.
- A conditional UPDATE is a concurrency guard, never the rule. The write port
takes the state the decision was made from, and returns
falsewhen another transaction won the race. - Reads never round-trip through the domain model. Project rows straight to a DTO — the model exists to decide, and a response is not a decision.
- JPA
@Entityis the one standing exception to records-everywhere.
Messaging
Never publish ad hoc from a business module. One EventPublisher wraps events in
a versioned envelope — event id, correlation and causation ids, producer — and
hands delivery to the Spring Modulith event publication registry: an
event_publication table, externalised to Kafka after commit, republished on
restart. Topic routing lives in one EventExternalizationConfiguration.
This buys transactional-outbox semantics without writing an outbox. Delivery is at-least-once — consumer idempotency remains the consumer's job.
Scheduled jobs
The application scales horizontally, so a bare @Scheduled fires once per
instance. Every scheduled method also carries
@SchedulerLock(name="<module>.<job>", lockAtMostFor, lockAtLeastFor) — ShedLock,
DB-timed so pod clock skew cannot double-fire.
lockAtMostFor must outlive the job's slowest run; a run that exceeds it
executes twice. Enforce with ArchUnit.
Observability
- Do not hand-write logger fields and entry/exit messages. One
@Tracedannotation on a type or method logs arguments, result, duration and failure. Meta-annotate the@UseCasestereotype with it, so an operation is traced by being one. - Tracing is silent by default, and that is the point. A value contributes
only its type name; it prints in full only if it is an identifier, a scalar that
cannot carry personal data, or it implements a
LogSafeopt-in. Never make a DTO print itself to "improve" a trace — adding a field must never be able to leak it. Opt in with a summary (Page[count=3, total=57]), not contents. See secrets. - What survives per class is
LOG.warnon rejection paths: they name why something was refused, which no generic aspect can know.
Architecture tests are the load-bearing part
Every convention above is worth an ArchUnit or Modulith rule — package set, core
purity, model-framework purity, port shape, the execute entry point, the
object-return rule, command and result placement, handler argument order, entity
placement, scheduler locks, the event contract.
A convention in a document decays; a convention in a test cannot. Add the rule in the same commit as the convention.
Repository hygiene that paid off
Per-module living documents — shipped.md as the canonical inventory (an absent
capability is not shipped, even if code exists), backlog.md, domain.md — plus
a hand-curated module changelog, enforced by a pre-commit hook the build
installs itself (git-build-hook-maven-plugin at mvn initialize), so there is
no manual setup step.
Conventional Commits and semantic-release for versions; never hand-edit the generated root changelog.
Trap: when CI pushes chore(release) commits to main, a local main goes
stale within minutes. Fetch and rebase before every push.