Spring Integration
The fun-spring module adds first-class Spring transaction support for Result<V,E>,
Try<V>, and Validated<E,A>. It is an optional dependency — the core fun library
has no runtime dependency on Spring.
The problem
Spring’s @Transactional rolls back only when an unchecked exception escapes the
annotated method. Result, Try, and Validated capture failure as return values, so no
exception escapes — and the transaction commits even when the operation failed, silently
persisting partial writes.
TxResult, TxTry, and TxValidated solve this by inspecting the return value: an
error/failure/invalid outcome marks the transaction rollback-only before Spring commits.
Adding the dependency
Two artifacts are available depending on whether you use Spring Boot:
| Artifact | Use when |
|---|---|
fun-spring | Plain Spring Framework, or you want manual wiring |
fun-spring-boot | Spring Boot — auto-configures all beans for you |
fun-spring-boot depends on fun-spring transitively, so you only need one dependency.
fun-spring declares spring-tx and spring-context as compileOnly so your Spring
Framework version is not forced — typically they arrive via spring-webmvc or
spring-boot-starter-data-jpa.
Spring Framework releases 6.0.x, 6.1.x, 6.2.x, and 7.0.x are supported (see version compatibility below).
With Spring Boot (fun-spring-boot)
<!-- Maven --><dependency> <groupId>codes.domix</groupId> <artifactId>fun-spring-boot</artifactId> <version>LATEST_VERSION</version></dependency>// Gradleimplementation("codes.domix:fun-spring-boot:LATEST_VERSION")Spring Boot picks up DmxFunSpringAutoConfiguration from the JAR’s
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports file
and registers the following beans automatically — no @EnableAspectJAutoProxy or
explicit @Bean declarations required:
| Bean | Registered when |
|---|---|
TxResult | spring-tx on classpath and a PlatformTransactionManager bean is present |
TxTry | spring-tx on classpath and a PlatformTransactionManager bean is present |
TxValidated | spring-tx on classpath and a PlatformTransactionManager bean is present |
DmxTransactionalAspect | Same as above and aspectjweaver is also on the classpath |
DmxFunModule | jackson-databind and fun-jackson are both on the classpath |
DmxTracing | micrometer-tracing and fun-tracing on classpath and a Tracer bean is present |
optionReturnValueHandlerPostProcessor | spring-webmvc on classpath (enabled by default, see MVC handlers) |
resultReturnValueHandlerPostProcessor | spring-webmvc on classpath (enabled by default, see MVC handlers) |
PlatformTransactionManager is auto-configured by Spring Boot for every registered DataSource; if you add a DataSource (e.g. via spring-boot-starter-data-jpa or spring-boot-starter-jdbc), no explicit transaction manager declaration is needed.
DmxFunModule is the Jackson serializer/deserializer for all dmx-fun types. When it is registered as a bean, Spring Boot’s JacksonAutoConfiguration applies it to the ObjectMapper automatically — Result, Option, Try, and the other types serialize to JSON without any extra wiring. To opt out, set dmx.fun.jackson.enabled=false. See the Jackson Integration guide for the full reference.
All beans are guarded by @ConditionalOnMissingBean, so you can override any of them
with your own @Bean declaration (see Customizing auto-configured beans below).
With plain Spring Framework (fun-spring)
<!-- Maven --><dependency> <groupId>codes.domix</groupId> <artifactId>fun-spring</artifactId> <version>LATEST_VERSION</version></dependency>// Gradleimplementation("codes.domix:fun-spring:LATEST_VERSION")Setup
TxResult, TxTry, and TxValidated are Spring @Component beans. You need one
PlatformTransactionManager in your context — Spring Boot auto-configures one for every
registered DataSource.
Spring Boot with fun-spring-boot — no extra wiring needed: the auto-configuration
registers TxResult, TxTry, TxValidated, and (when aspectjweaver is present)
DmxTransactionalAspect automatically.
Plain Spring — declare the beans explicitly or add the dmx.fun.spring package to
your @ComponentScan.
// Plain Spring — explicit registration (only needed without component scanning)@Bean public TxResult txResult(PlatformTransactionManager tm) { return new TxResult(tm); }@Bean public TxTry txTry(PlatformTransactionManager tm) { return new TxTry(tm); }@Bean public TxValidated txValidated(PlatformTransactionManager tm) { return new TxValidated(tm); }Customizing auto-configured beans
Disabling beans via properties
Each auto-configured bean can be disabled individually in application.properties (or
application.yml):
dmx.fun.tx-result.enabled=false # disable TxResultdmx.fun.tx-try.enabled=false # disable TxTrydmx.fun.tx-validated.enabled=false # disable TxValidateddmx.fun.aspect.enabled=false # disable DmxTransactionalAspectdmx.fun.tracing.enabled=false # disable DmxTracing (requires fun-tracing on classpath)dmx.fun.mvc.option-handler.enabled=false # disable Option MVC handlerdmx.fun.mvc.result-handler.enabled=false # disable Result/Try/Validated MVC handlerAll properties default to true, so existing applications are not affected.
IDE autocompletion is available because the JAR ships
META-INF/additional-spring-configuration-metadata.json.
Replacing a bean with your own
Every auto-configured bean is also guarded by @ConditionalOnMissingBean. Declare your
own @Bean of the same type and the auto-configuration backs off, leaving yours in place.
The most common reason to replace is to bind a specific (non-primary) transaction manager:
import dmx.fun.spring.TxResult;import dmx.fun.spring.TxTry;import dmx.fun.spring.TxValidated;import org.springframework.beans.factory.annotation.Qualifier;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.transaction.PlatformTransactionManager;
@Configurationpublic class MyTxConfig {
// Override TxResult to use a specific (non-primary) transaction manager. // @ConditionalOnMissingBean in the auto-config backs off when this bean is present. @Bean public TxResult txResult( @Qualifier("myTxManager") PlatformTransactionManager txManager) { return new TxResult(txManager); }
// TxTry and TxValidated are left to the auto-config — no override needed.}You can replace any subset — any bean not replaced is still registered by the auto-configuration.
TxResult
Wraps a Result<V,E>-returning action in a managed transaction. Rolls back when the
result is isError().
@Servicepublic class OrderService { private final TxResult tx; private final OrderRepository repo; private final InventoryClient inventory;
public OrderService(TxResult tx, OrderRepository repo, InventoryClient inventory) { this.tx = tx; this.repo = repo; this.inventory = inventory; }
public Result<Order, OrderError> createOrder(OrderRequest req) { return tx.execute(() -> validate(req) // Result<ValidOrder, OrderError> .flatMap(repo::save) // Result<Order, OrderError> .flatMap(inventory::reserve) // Result<Order, OrderError> ); // • Ok(order) → transaction commits // • Err(orderError) → transaction rolls back; partial writes undone }}Rollback contract:
| Outcome | Transaction |
|---|---|
Action returns Result.isOk() | Commits |
Action returns Result.isError() | Rolls back |
| Action throws an unchecked exception | Rolls back, rethrows |
TxTry
Wraps a Try<V>-returning action in a managed transaction. Rolls back when the result
is isFailure().
@Servicepublic class ReportService { private final TxTry tx; private final ReportRepository repo; private final AuditLog audit;
public ReportService(TxTry tx, ReportRepository repo, AuditLog audit) { this.tx = tx; this.repo = repo; this.audit = audit; }
public Try<Report> generate(ReportRequest req) { return tx.execute(() -> Try.of(() -> repo.save(build(req))) // Try<Report> .map(r -> { audit.record(r); return r; }) // Try<Report> ); // • Success(report) → transaction commits // • Failure(cause) → transaction rolls back; partial writes undone }}Rollback contract:
| Outcome | Transaction |
|---|---|
Action returns Try.isSuccess() | Commits |
Action returns Try.isFailure() | Rolls back |
| Action throws an unchecked exception | Rolls back, rethrows |
TxValidated
Wraps a Validated<E,A>-returning action in a managed transaction. Rolls back when the
result is isInvalid().
@Servicepublic class RegistrationService { private final TxValidated tx; private final UserRepository repo;
public RegistrationService(TxValidated tx, UserRepository repo) { this.tx = tx; this.repo = repo; }
public Validated<NonEmptyList<String>, User> register(RegistrationRequest req) { return tx.execute(() -> validateName(req) .combine(validateEmail(req), (name, email) -> new UserDraft(name, email)) .map(repo::save) ); // • Valid(user) → transaction commits // • Invalid(errors) → transaction rolls back; no partial writes persisted }}Rollback contract:
| Outcome | Transaction |
|---|---|
Action returns Validated.isValid() | Commits |
Action returns Validated.isInvalid() | Rolls back |
| Action throws an unchecked exception | Rolls back, rethrows |
Custom TransactionDefinition
All three components accept an explicit TransactionDefinition to control propagation,
isolation level, timeout, and read-only flag:
// Read-only transaction — hint to the JDBC driver and database optimizervar readOnly = new DefaultTransactionDefinition();readOnly.setReadOnly(true);
Result<List<Order>, String> orders = tx.execute(readOnly, orderRepo::findAll);
// Explicit isolation level — e.g. SERIALIZABLE for critical financial operationsvar serializable = new DefaultTransactionDefinition();serializable.setIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE);
Result<Transfer, TransferError> transfer = tx.execute(serializable, () -> debit(source, amount).flatMap(__ -> credit(target, amount)));
// Custom timeout — rolls back automatically if the action exceeds 5 secondsvar withTimeout = new DefaultTransactionDefinition();withTimeout.setTimeout(5);
Result<Report, String> report = tx.execute(withTimeout, () -> Result.ok(reportEngine.generate(params)));TxResult vs TxTry vs @Transactional
// ---- @Transactional — commits even when Result is an error ----
@Transactionalpublic Result<Order, OrderError> createOrder(OrderRequest req) { return validate(req) .flatMap(repo::save); // ❌ If flatMap returns Err(orderError), the method returns normally — // no exception escapes, so Spring commits the transaction. // In a longer pipeline (preAuth → save → ledger.record), writes // from steps that succeeded before the Err are silently persisted.}
// ---- TxResult — rolls back when Result is an error ----
public Result<Order, OrderError> createOrder(OrderRequest req) { return tx.execute(() -> validate(req) .flatMap(repo::save) ); // ✓ If flatMap returns Err(orderError), TxResult marks the transaction // rollback-only before Spring commits. Partial writes are undone.}
// ---- When @Transactional is still the right choice ----
@Transactionalpublic void deleteOrder(String id) { // No return value, rollback happens naturally on RuntimeException. // @Transactional is simpler and perfectly correct here. repo.delete(id);}Rule of thumb:
- Prefer
TxResult/@TransactionalResultwhen your pipeline returnsResult<V,E>for typed domain errors. - Choose
TxTry/@TransactionalTrywhen your pipeline usesTry<V>to wrap exception-throwing code. - Opt for
TxValidated/@TransactionalValidatedwhen your pipeline accumulates multiple validation errors and you want atomicity across all steps. - Fall back to
@Transactionalwhen the method has no dmx-fun return type and rollback on unchecked exception is sufficient (void methods, simple queries).
Declarative style with @TransactionalResult, @TransactionalTry, and @TransactionalValidated
If you prefer annotation-driven transaction demarcation — the same style as Spring’s own
@Transactional — fun-spring ships three AOP annotations backed by
DmxTransactionalAspect. They carry the same rollback contract as their programmatic
counterparts: commit on success, roll back on failure, roll back and rethrow on unchecked exception.
Setup
Add aspectjweaver to your classpath and register the aspect bean with
@EnableAspectJAutoProxy:
import dmx.fun.spring.DmxTransactionalAspect;import org.springframework.beans.factory.BeanFactory;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.context.annotation.EnableAspectJAutoProxy;import org.springframework.transaction.PlatformTransactionManager;
@Configuration@EnableAspectJAutoProxypublic class AppConfig {
@Bean public DmxTransactionalAspect dmxTransactionalAspect( PlatformTransactionManager txManager, BeanFactory beanFactory) { return new DmxTransactionalAspect(txManager, beanFactory); }}With fun-spring-boot — DmxFunSpringAutoConfiguration registers
DmxTransactionalAspect automatically when aspectjweaver is on the classpath. No
@EnableAspectJAutoProxy or @Bean declaration is needed.
With fun-spring in a Spring Boot application — DmxTransactionalAspect is
annotated @Component but lives in the dmx.fun.spring package, which Spring Boot does
not scan by default. Either add @ComponentScan("dmx.fun.spring") to your configuration
or register the aspect explicitly with the @Bean declaration shown above.
@TransactionalResult
@Servicepublic class OrderService { private final OrderRepository repo; private final InventoryClient inventory;
@TransactionalResult public Result<Order, OrderError> createOrder(OrderRequest req) { return validate(req) // Result<ValidOrder, OrderError> .flatMap(repo::save) // Result<Order, OrderError> .flatMap(inventory::reserve); // Result<Order, OrderError> // • Ok(order) → transaction commits // • Err(orderError) → transaction rolls back; partial writes undone }}| Outcome | Transaction |
|---|---|
Returns Result.isOk() | Commits |
Returns Result.isError() | Rolls back |
| Throws unchecked exception | Rolls back, rethrows |
@TransactionalTry
@Servicepublic class ReportService { private final AuditRepository repo;
@TransactionalTry(propagation = Propagation.REQUIRES_NEW) public Try<AuditLog> auditEvent(Event event) { return Try.of(() -> repo.save(event)); // • Success(log) → transaction commits // • Failure(ex) → transaction rolls back; the Try is returned to the caller }}| Outcome | Transaction |
|---|---|
Returns Try.isSuccess() | Commits |
Returns Try.isFailure() | Rolls back |
| Throws unchecked exception | Rolls back, rethrows |
@TransactionalValidated
@Servicepublic class RegistrationService { private final UserRepository repo;
@TransactionalValidated public Validated<NonEmptyList<String>, User> register(RegistrationRequest req) { return validateName(req) .combine(validateEmail(req), UserDraft::new) .map(repo::save); // • Valid(user) → transaction commits // • Invalid(errors) → transaction rolls back; all writes undone }}| Outcome | Transaction |
|---|---|
Returns Validated.isValid() | Commits |
Returns Validated.isInvalid() | Rolls back |
| Throws unchecked exception | Rolls back, rethrows |
Transaction attributes
All three annotations accept the same attributes as Spring’s @Transactional:
| Attribute | Type | Default | Notes |
|---|---|---|---|
propagation | Propagation | REQUIRED | |
isolation | Isolation | DEFAULT | |
timeout | int (seconds) | TIMEOUT_DEFAULT (-1) | |
readOnly | boolean | false | Hint to the driver/ORM; enforcement depends on the transaction manager |
transactionManager | String (bean name) | primary PlatformTransactionManager |
@Servicepublic class PaymentService {
// Custom propagation, isolation, and timeout @TransactionalResult( propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE, timeout = 10 ) public Result<Receipt, PaymentError> charge(PaymentRequest req) { ... }
// Read-only hint — lets the driver/ORM skip dirty checks or route to a replica @TransactionalResult(readOnly = true) public Result<List<Receipt>, String> listReceipts(CustomerId id) { ... }
// Named transaction manager (multiple DataSources) @TransactionalResult(transactionManager = "ledgerTxManager") public Result<LedgerEntry, LedgerError> record(Receipt receipt) { ... }}Programmatic vs declarative
// ---- Programmatic (TxResult) ----// Explicit control, no AOP, works with JPMS without aspectjweaver at runtime.
@Servicepublic class OrderServiceProgrammatic { private final TxResult tx;
public Result<Order, OrderError> createOrder(OrderRequest req) { return tx.execute(() -> validate(req).flatMap(repo::save)); }}// ---- Declarative (@TransactionalResult) ----// Less boilerplate, familiar @Transactional style.// Requires DmxTransactionalAspect bean + @EnableAspectJAutoProxy.
@Servicepublic class OrderServiceDeclarative {
@TransactionalResult public Result<Order, OrderError> createOrder(OrderRequest req) { return validate(req).flatMap(repo::save); }}Choose programmatic (TxResult / TxTry / TxValidated) when:
- You need the lowest possible overhead (no proxy creation).
- You are using JPMS strict mode and prefer not to add
aspectjweaveras a runtime module. - You want an explicit, testable object rather than advice-on-a-proxy.
Choose declarative (@TransactionalResult / @TransactionalTry / @TransactionalValidated) when:
- You prefer the familiar
@Transactionalannotation style. - The method is already a Spring bean and JDK-proxy or CGLIB overhead is acceptable.
- You want to keep the service class free of
TxResultconstructor injection.
Spring MVC return value handlers
fun-spring-boot registers two Spring MVC
HandlerMethodReturnValueHandlers
that let controller methods return dmx-fun types directly, without manually unwrapping them
or calling fold/getOrElse before responding. Both handlers are inserted ahead of
Spring’s standard body processor so they take precedence over default serialization.
Option — 200 or 404
OptionHandlerMethodReturnValueHandler maps Option<V> return values:
Option.some(v)→ HTTP 200, body isvserialized (via the activeMessageConverter)Option.none()→ HTTP 404, empty body
import dmx.fun.Option;import org.springframework.web.bind.annotation.*;
@RestController@RequestMapping("/items")public class ItemController {
private final ItemService service;
// Option<V> — some maps to 200 with V as body; none maps to 404 with empty body @GetMapping("/{id}") public Option<Item> findById(@PathVariable long id) { return service.findById(id); }}| Return type | Outcome | HTTP status | Body |
|---|---|---|---|
Option<V> | some(v) | 200 | v serialized |
Option<V> | none() | 404 | empty |
Result, Try, and Validated — 200 or 500
ResultHandlerMethodReturnValueHandler maps Result<V,E>, Try<V>, and Validated<E,A>
return values. The success branch produces HTTP 200 with the unwrapped value as the body;
the error branch produces HTTP 500 with the error/cause as the body. Body serialization is
delegated to the existing RequestResponseBodyMethodProcessor, so Jackson, content
negotiation, and message converters all behave as normal.
import dmx.fun.Result;import dmx.fun.Try;import dmx.fun.Validated;import org.springframework.web.bind.annotation.*;
@RestController@RequestMapping("/items")public class ItemController {
private final ItemService service;
// Result<V, E> — ok maps to 200 with V as body; err maps to 500 with E as body @PostMapping public Result<Item, String> create(@RequestBody CreateItemRequest request) { return service.create(request.name(), request.description()); }
// Try<V> — success maps to 200 with V as body; failure maps to 500 with cause as body @GetMapping("/{id}/summary") public Try<ItemSummary> summary(@PathVariable long id) { return service.summarize(id); }
// Validated<E, A> — valid maps to 200 with A as body; invalid maps to 500 with E as body @PutMapping("/{id}/validate") public Validated<String, Item> validate(@PathVariable long id, @RequestBody UpdateItemRequest req) { return service.validate(id, req); }}| Return type | Outcome | HTTP status | Body |
|---|---|---|---|
Result<V, E> | ok(v) | 200 | v serialized |
Result<V, E> | err(e) | 500 | e serialized |
Try<V> | success(v) | 200 | v serialized |
Try<V> | failure(ex) | 500 | ex serialized |
Validated<E, A> | valid(a) | 200 | a serialized |
Validated<E, A> | invalid(e) | 500 | e serialized |
Disabling the handlers
Both handlers are enabled by default and can be toggled independently:
# Disable the Result/Try/Validated MVC handler (enabled by default)dmx.fun.mvc.result-handler.enabled=false
# Disable the Option MVC handler (enabled by default)dmx.fun.mvc.option-handler.enabled=falseEach property is documented in the JAR’s
META-INF/additional-spring-configuration-metadata.json, so IDE autocompletion works
out of the box.
Interaction with fun-jackson
The handlers delegate body serialization to Spring’s existing body processor. When
fun-jackson is on the classpath and DmxFunModule is registered (which fun-spring-boot
does automatically), the unwrapped value or error is serialized — not the wrapper type.
This means Result.ok(item) produces {"id":1,"name":"Widget"}, not {"ok":{"id":1,...}}.
If you want the wrapped JSON shape ({"ok":{...}} / {"err":"..."}), disable the Result
handler (dmx.fun.mvc.result-handler.enabled=false) and let fun-jackson serialize the
Result directly.
Version compatibility
Spring Framework (fun-spring)
Tested in CI against the following versions on every pull request that touches
the spring/ module:
| Spring version | Status |
|---|---|
| 6.0.x | tested |
| 6.1.x | tested |
| 6.2.x | tested |
| 7.0.x | tested |
To test locally against a specific version:
./gradlew :spring:test -PspringVersion=7.0.7Spring Boot (fun-spring-boot)
Spring Boot 3.3.x and later are supported. Tested in CI against the
following versions on every pull request that touches the spring-boot/,
spring/, or gradle/libs.versions.toml files:
| Spring Boot version | Status |
|---|---|
| 3.3.x | tested |
| 3.4.x | tested |
| 3.5.x | tested |
| 4.0.x | tested |
To test locally against a specific version:
./gradlew :spring-boot:test -PspringBootVersion=3.4.13