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:

ArtifactUse when
fun-springPlain Spring Framework, or you want manual wiring
fun-spring-bootSpring 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>
// Gradle
implementation("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:

BeanRegistered when
TxResultspring-tx on classpath and a PlatformTransactionManager bean is present
TxTryspring-tx on classpath and a PlatformTransactionManager bean is present
TxValidatedspring-tx on classpath and a PlatformTransactionManager bean is present
DmxTransactionalAspectSame as above and aspectjweaver is also on the classpath
DmxFunModulejackson-databind and fun-jackson are both on the classpath
DmxTracingmicrometer-tracing and fun-tracing on classpath and a Tracer bean is present
optionReturnValueHandlerPostProcessorspring-webmvc on classpath (enabled by default, see MVC handlers)
resultReturnValueHandlerPostProcessorspring-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>
// Gradle
implementation("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 TxResult
dmx.fun.tx-try.enabled=false # disable TxTry
dmx.fun.tx-validated.enabled=false # disable TxValidated
dmx.fun.aspect.enabled=false # disable DmxTransactionalAspect
dmx.fun.tracing.enabled=false # disable DmxTracing (requires fun-tracing on classpath)
dmx.fun.mvc.option-handler.enabled=false # disable Option MVC handler
dmx.fun.mvc.result-handler.enabled=false # disable Result/Try/Validated MVC handler

All 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;
@Configuration
public 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().

@Service
public 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:

OutcomeTransaction
Action returns Result.isOk()Commits
Action returns Result.isError()Rolls back
Action throws an unchecked exceptionRolls back, rethrows

TxTry

Wraps a Try<V>-returning action in a managed transaction. Rolls back when the result is isFailure().

@Service
public 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:

OutcomeTransaction
Action returns Try.isSuccess()Commits
Action returns Try.isFailure()Rolls back
Action throws an unchecked exceptionRolls back, rethrows

TxValidated

Wraps a Validated<E,A>-returning action in a managed transaction. Rolls back when the result is isInvalid().

@Service
public 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:

OutcomeTransaction
Action returns Validated.isValid()Commits
Action returns Validated.isInvalid()Rolls back
Action throws an unchecked exceptionRolls 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 optimizer
var 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 operations
var 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 seconds
var 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 ----
@Transactional
public 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 ----
@Transactional
public 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 / @TransactionalResult when your pipeline returns Result<V,E> for typed domain errors.
  • Choose TxTry / @TransactionalTry when your pipeline uses Try<V> to wrap exception-throwing code.
  • Opt for TxValidated / @TransactionalValidated when your pipeline accumulates multiple validation errors and you want atomicity across all steps.
  • Fall back to @Transactional when 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 @Transactionalfun-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
@EnableAspectJAutoProxy
public class AppConfig {
@Bean
public DmxTransactionalAspect dmxTransactionalAspect(
PlatformTransactionManager txManager, BeanFactory beanFactory) {
return new DmxTransactionalAspect(txManager, beanFactory);
}
}

With fun-spring-bootDmxFunSpringAutoConfiguration registers DmxTransactionalAspect automatically when aspectjweaver is on the classpath. No @EnableAspectJAutoProxy or @Bean declaration is needed.

With fun-spring in a Spring Boot applicationDmxTransactionalAspect 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

@Service
public 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
}
}
OutcomeTransaction
Returns Result.isOk()Commits
Returns Result.isError()Rolls back
Throws unchecked exceptionRolls back, rethrows

@TransactionalTry

@Service
public 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
}
}
OutcomeTransaction
Returns Try.isSuccess()Commits
Returns Try.isFailure()Rolls back
Throws unchecked exceptionRolls back, rethrows

@TransactionalValidated

@Service
public 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
}
}
OutcomeTransaction
Returns Validated.isValid()Commits
Returns Validated.isInvalid()Rolls back
Throws unchecked exceptionRolls back, rethrows

Transaction attributes

All three annotations accept the same attributes as Spring’s @Transactional:

AttributeTypeDefaultNotes
propagationPropagationREQUIRED
isolationIsolationDEFAULT
timeoutint (seconds)TIMEOUT_DEFAULT (-1)
readOnlybooleanfalseHint to the driver/ORM; enforcement depends on the transaction manager
transactionManagerString (bean name)primary PlatformTransactionManager
@Service
public 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.
@Service
public 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.
@Service
public 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 aspectjweaver as 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 @Transactional annotation 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 TxResult constructor 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 is v serialized (via the active MessageConverter)
  • 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 typeOutcomeHTTP statusBody
Option<V>some(v)200v serialized
Option<V>none()404empty

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 typeOutcomeHTTP statusBody
Result<V, E>ok(v)200v serialized
Result<V, E>err(e)500e serialized
Try<V>success(v)200v serialized
Try<V>failure(ex)500ex serialized
Validated<E, A>valid(a)200a serialized
Validated<E, A>invalid(e)500e 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=false

Each 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 versionStatus
6.0.xtested
6.1.xtested
6.2.xtested
7.0.xtested

To test locally against a specific version:

Terminal window
./gradlew :spring:test -PspringVersion=7.0.7

Spring 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 versionStatus
3.3.xtested
3.4.xtested
3.5.xtested
4.0.xtested

To test locally against a specific version:

Terminal window
./gradlew :spring-boot:test -PspringBootVersion=3.4.13