Skip to content

Independent R&D project · Cologne

Java and Spring

Sealed types, Bean Validation, Micrometer, Spring Modulith and ArchUnit, and where a declared property stops applying.

Non-normative

Companion version
1.0
Maps to COADF Core
2.2
Status
Current
Last reviewed
Profile version
1.0
Code examples
Code examples follow in a later revision.

Code examples follow in a later revision.

Architectural properties addressed

  • P-1A sealed hierarchy that separates proposals from verified values, validation at the web boundary, and module rules that the build verifies.
  • P-4Micrometer Observation and Tracing for execution context, the audit identity in every contract, and audit entries written inside the business transaction.
  • P-5Provenance as fields of the value types, serialised explicitly.
  • P-6Architecture verification in the test phase, and required checks that cannot pass by being skipped.
  • P-7Ports in the domain module and adapters in modules of their own.
  • P-8A policy interface with an engine adapter, and decisions recorded with their revision.
  • P-2, P-3Only at the depth COADF publishes.

Architectural intent

Spring's strength for these properties is declaration: constraints on types, transactions and validation applied by the container, observations configured once, module rules checked by a test. Its characteristic failure is the other side of the same design. A declaration holds on the paths the container intercepts, through a proxy, at a web binding or on an executor it configured, and nowhere else. A property written as an annotation is only as strong as the set of calls that actually go through Spring.

This profile maps each property onto Spring Boot 4 and Spring Framework 7, the current generation when it was written, and names the places where a declared property quietly stops applying.

Technology mapping

Java and Spring: Technology mapping
Architectural propertyJava and Spring
Boundary typeA sealed interface with record implementations: a proposal type and a verified type, constructed in different modules. Records are shallowly immutable carriers of data: a component that is a mutable list stays mutable, so copy it in the constructor. Sealed classes are a standard feature since Java 17.
Runtime validationJakarta Validation constraints on request types, triggered with @Valid. A failed @RequestBody validation raises MethodArgumentNotValidException, answered with 400 by default.
Unknown fieldsAn explicit, tested decision on boundary types about properties the type does not declare, rather than whatever the JSON mapper's configuration currently is.
Architecture ruleSpring Modulith's ApplicationModules.verify(), or ArchUnit rules written as unit tests.
Execution contextMicrometer Observation and Micrometer Tracing, bridged to OpenTelemetry.
Asynchronous contextContextPropagatingTaskDecorator with the Micrometer context-propagation library; for the auto-configured executor, the Boot property spring.task.execution.propagate-context.
Audit trailEntries written in the business transaction; database grants and triggers as in the PostgreSQL example; Spring Modulith's event publication registry for events that must not be lost.
Standards isolationPort interfaces in the domain module; one adapter module per external system; the vendor client never visible to the domain.
PolicyA domain-owned policy interface with an Open Policy Agent adapter, or rule tables; the revision stored on the decision.
Integration testsA real PostgreSQL through Testcontainers, connected with @ServiceConnection.

Reference pattern

Code examples follow in a later revision. They are deferred until a reviewer working in this ecosystem has read them, rather than written to make the four profiles look alike. The structure below is the reference pattern those examples will implement.

Modules

  • inference calls the model and returns a Proposal, a record carrying the value, the method and the provenance. It has no dependency on records or audit, and a Modulith verification or an ArchUnit rule says so in a test.
  • records is the only module that can create a VerifiedValue. The type is not public outside the module, or it is a class whose constructor is not: a public record cannot hide its constructor, because the canonical constructor of a public record must itself be public. Creation goes through a factory that requires the verification of a named person.
  • audit appends entries with the published fields, inside the transaction of the change it records, through a repository that offers no update or delete.
  • classification declares the port; classification.vendor implements it, and is the only module that can see the vendor's client or its types.
  • policy declares the policy interface and the decision record; an adapter module talks to the engine.

Boundaries at run time

Validation happens at the web binding, where Spring evaluates constraints on the request body, and again inside the domain factory, because the web binding is one entry point among several: a message listener, a batch job and a test all reach the domain without passing it.

Observations wrap the boundary and the external calls. Every executor that runs work for a request is configured to propagate context, and the audit identity is a field of every message and job argument, never recovered from a thread-local after the fact.

Failure modes

  1. Self-invocation bypasses the proxy

    Spring AOP is proxy-based: a call from one method of a bean to another method of the same bean does not pass through the proxy, so method validation and @Transactional do not apply to it. A constraint that holds for callers outside the bean does not hold for the bean itself, which is often where the batch path starts.

  2. Constraints declared, never evaluated

    Jakarta Validation constraints on a type do nothing by themselves. They are evaluated where something triggers validation: @Valid or @Validated at a web binding, or method validation through the proxy. A message listener that receives the same type validates nothing unless it asks.

  3. Undeclared properties left to the mapper's configuration

    Whether an incoming JSON document may carry properties the type does not declare is a setting of the JSON mapper. For a boundary type, the right answer is usually to refuse them, and that decision belongs on the type and in a test, not in an application property somebody else can change.

  4. ORM immutability taken for an audit safeguard

    Hibernate ignores in-memory changes to a managed @Immutable entity, with no update and no exception. Bulk updates against it throw by default in Hibernate 7 and only warned in 6.6. None of this stops a second client, a script or a migration from updating the table; the protection belongs in the database.

  5. Audit events lost after commit

    An event handled after the transaction commits is lost if the handler fails, unless something recorded it. Spring Modulith's event publication registry writes an entry in the publishing transaction and keeps failed publications for resubmission; republishing them automatically on restart is a setting you have to switch on.

  6. A public record as the verified type

    A public record's canonical constructor must be public, so any code that can see the type can build a verified value without a verification. Keep the type out of reach, or use a class with a private constructor and a factory.

  7. Two tracing bridges

  8. Integration tests against a different database

    Grants, triggers and constraint behaviour are properties of the real database. Tests against an embedded substitute verify the code and not the property.

Verification

  • Unit test

    Passes when: The verified type can be created only through the factory, and only with a verification by a named person; a rejection leaves the attribute empty.

    Proof of teeth: Make the verified type public in a scratch branch: a test that asserts it is not accessible from the inference module fails.

  • Architecture test

    Passes when: Spring Modulith's verification passes, or the ArchUnit rules do: no dependency from inference to records or audit, no vendor type in the domain. ArchUnit checks dependencies between packages and classes as plain unit tests, and Modulith's verification rejects cycles between modules and references into another module's internal packages, except into modules declared open. Either tool checks the rules written down; whether they are the right rules is the team's design.

    Proof of teeth: Plant the forbidden dependency: the verification fails.

  • Integration test

    Passes when: Against PostgreSQL from Testcontainers: the application's role cannot update or delete the trail, and entries are written in the same transaction as the change.

    Proof of teeth: Roll back the business transaction after writing: the audit entry must not survive it.

  • Integration test

    Passes when: Context survives the executors the application uses: an in-memory exporter shows the asynchronous work as part of the same trace, and the audit identity is present in its record.

    Proof of teeth: Remove the task decorator: the test fails.

  • Contract test

    Passes when: Producer and consumer agree on the proposal and decision contracts, with the provenance and the revision as required fields.

  • End-to-end test

    Passes when: An output whose attribute has not been verified cannot be published, whatever entry point the request came through.

Alternative realizations

  • Other Java frameworks (Quarkus, Micronaut, Jakarta EE) express the same boundary with different interception models; the self-invocation question has to be asked of each.
  • Explicit SQL (jOOQ or plain JDBC) for the audit trail instead of an ORM, where the exact statements matter more than mapping convenience.
  • Event sourcing frameworks where the history is the model.
  • Rule tables owned by the application instead of an external policy engine, with the same revision requirement.

Trade-offs

  • Declarative is concise and invisible. A reader cannot see from the call site whether a constraint or a transaction applies; tests have to show it.
  • Spring Modulith encodes conventions, which is fast to adopt and opinionated; ArchUnit is general and needs the rules written out.
  • JPA is convenient and puts a layer between the code and the database's own protections. For the audit trail, the layer is the part you have to see through.
  • Module boundaries inside one deployable keep the operational model simple, and make the boundary depend entirely on the build's verification.

Limitations

  • No code is published for this profile in Companion 1.0, and no example was executed. The behaviour described is taken from the current first-party documentation, checked on 11 September 2026, for Spring Boot 4.1, Spring Framework 7.0, Spring Modulith 2.1, Hibernate ORM 7.4 and Java SE 25.
  • Nothing here shows how confidence is represented, when review is required, or how review is organised; COADF does not publish those parts of P-2 and P-3.
  • Everything cited is language or framework behaviour. The architectural property, that a proposal cannot become a record without a verification, is a design the team still has to make and test: no annotation, record or module rule provides it by itself.

What this profile does not establish

Following this profile does not establish regulatory compliance, certification or conformity assessment, and Spring is not required by COADF.

Tested reference environment

No example was executed for this profile.

Sources

COADF Engineering Companion 1.0 · non-normative · maps to COADF Core 2.2

Publication rights reserved. No public licence is granted for the COADF Engineering Companion 1.0 or its reference examples at this time.

IP and publication status