Photo by Yann Allegre on Unsplash
Entity Control Boundary in Spring Boot Apps
An architectural pattern for robust transaction management in Spring Boot applications
Introduction
In a previous article, we looked at an architectural pattern named entity-control-boundary (ECB) mapped to Spring meta-annotations for transaction management and retries. That post didn't go very deep into this architectural pattern, which is the purpose of this article.
ECB is an architecture pattern originally coined in Ivar Jacobson's use-case-driven object-oriented software engineering (OOSE) method published in 1992. In other words, it dates way back in time yet it's not super well-known.
This pattern fits really well into organising transaction boundaries in application code and you don't need to go all-in on all the fun stuff like UML, waterfall or unified process to use it. It's really straightforward and mainly serves a documentative and declarative purpose.
The ECB pattern is centred around defining clear responsibilities and interactions between different categories of classes. It can be broken down into four elements of a robustness diagram: Actor, Boundary, Control and Entity.
The following robustness constraints apply:
Actors may only know and communicate with boundaries.
Boundaries may communicate with actors and controls only.
Controls may know and communicate with boundaries and entities, and if needed other controls.
Entities may only know about other entities but could communicate also with controls.
Source: en.wikipedia.org/wiki/Entity-control-boundary
In other words, there could be dependencies and interactions like this in a single service:
ECB offers structure and low-effort consistency to transaction boundary demarcating, simply by using transaction attributes or preferably dedicated meta- or stereotype annotations. Without this level of structure, the chances are that the boundaries become unclear and blurry which may result in hard-to-find errors and system inconsistencies.
Definitions
Let's map the ECB concept to concrete architectural elements (namespaces and annotations) that you typically see in a Spring Boot application.
Boundary
A boundary is coarse-grained and exposes functionality towards users or other systems (actors). It is typically implemented as a web controller or business service facade. It should be thin and delegate business processing to more fine-grained control services, if applicable. It acts both as a remoting and transaction boundary.
A boundary should never be invoked from within a transaction context. It means that only a boundary is allowed to create new transactions. To that end, boundaries must have the REQUIRES_NEW
transaction attribute on their public, transactional methods. This propagation attribute will always create a new transaction and suspend any existing one.
Transaction suspension via REQUIRES_NEW
and nested transactions via NESTED
are different things. Nested transactions allow for a rollback to the beginning of the sub-transaction while keeping the transactional state of the outer transaction. Nested transactions are expressed using savepoints. Unfortunately, savepoints are not fully supported in all Java application frameworks but it's available in Spring Boot, although not in JPA. If you are using something else than JPA then savepoints opens up a few more opportunities. For the transaction boundary type discussed here, however, we don't use nested transactions (via savepoints) but just regular unnested local transactions.
Characteristics
Key characteristics for a transaction and remoting boundary:
Independent of other service facades or web controllers.
Granularity is more coarse-grained than a service.
The layer that exposes functionality outside of the business tier.
The only layer that is accessible from an external client (typically via a web API).
Methods are preferably idempotent for client convenience.
Never invoked within a transaction context.
Solution
Typical implementation elements in Spring Boot:
Can be a Business Facade, Web Controller or Service Activator (Message Listener) where:
A business facade uses
@Service
A controller uses
@RestController
A service activator uses
@Service
Implements simple business logic or delegates to services (Control) or even repositories (Entity).
Always uses transaction demarcation
REQUIRES_NEW
since it's a boundary.@Transactional(propagation = Propagation.REQUIRES_NEW)
Conventions
Represents the remoting entry point (when it's a web
@Controller
).An interface or class with thin, coarse-grained methods.
Should be located in a dedicated
boundary
orservice
namespace.Should use the documentative meta-annotation to emphasise its architectural role.
The business interface should be named after business concepts.
Example of a boundary meta-annotation (annotation describing or grouping other annotations). Notice that it incorporates the Spring @Transactional
annotation with propagation REQUIRES_NEW
.
@Inherited
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Transactional(propagation = Propagation.REQUIRES_NEW)
public @interface Boundary {
}
Boundary service facade example:
@Service
public class TransactionServiceImpl implements TransactionService {
@Autowired
private AccountRepository accountRepository;
@Autowired
private TransactionRepository transactionRepository;
@Override
@Boundary
public Transaction submitTransferRequest(TransferRequest request) {
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException("No transaction context");
}
}
}
Boundary web controller example:
@RestController
@RequestMapping(value = "/api/transaction")
public class TransactionController {
@GetMapping
@Boundary
public PagedModel<TransactionModel> listTransactions(@PageableDefault(size = 5) Pageable page) {
return pagedTransactionResourceAssembler
.toModel(bankService.find(page), transactionResourceAssembler);
}
}
Boundary service activator example:
@Service
public class KafkaChangeFeedConsumer {
@KafkaListener(topics = TOPIC_ACCOUNTS, containerFactory = "accountListenerContainerFactory")
@Boundary
public void accountChanged(@Payload AccountPayload event,
@Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
@Header(KafkaHeaders.OFFSET) int offset) {
}
}
Control
A control service is a fine-grained realization of activities or sub-processes. It's where business functionality is implemented. It must always be invoked within the context of a transaction and is not allowed to create new transactions. To that end, it must have the MANDATORY
transaction attribute. The same policy applies to repository interfaces or classes that perform persistence logic. A repository is not allowed to create a new transaction.
A control service that is just a thin delegation layer between a boundary and repository contract (like Spring Data repository) adds no real value. In that case, to reduce boilerplate code, consider accessing repository resources directly from boundaries, effectively collapsing the boundary and service into one artefact.
Characteristics
Services should be independent of other services.
The granularity is finer than a boundary.
Services are not available or visible outside of the business tier.
Methods should be idempotent and always be invoked from a transactional context.
Solution
Can be a business service that implements business logic.
Not allowed to start new transactions.
Use
MANDATORY
transaction propagation attribute.@Transactional(propagation = Propagation.MANDATORY)
Conventions
Interface or class with fine-grained methods and PDOs.
Should be located in a dedicated
..service
package.Should use a documentative meta-annotation to emphasise its architectural role.
The business interface should be named after business concepts.
Example of a control service meta-annotation. Notice that it incorporates the Spring @Transactional
annotation with propagation MANDATORY
.
@Inherited
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Transactional(propagation = Propagation.MANDATORY)
public @interface Control {
}
A control service example:
@Service
public class DefaultTransactionService implements TransactionService {
@Autowired
private AccountRepository accountRepository;
@Override
@Control
public Transaction createTransaction(UUID id, TransactionForm transactionForm) { Assert.isTrue(TransactionSynchronizationManager.isActualTransactionActive(), "Expected transaction");
}
}
Entity
Entities are a static model representation of the application state mapped against a database. Usually through some ORM technology such as JPA and Hibernate. Entities must never be visible outside the system boundaries or JVM, but can optionally have DTO or value object/model representations. In most cases, DTOs add little value to hide implementation detail and protect internal entities. One exception could be representation models in Spring HATEOAS that add hypermedia controls on top of domain entities (you can use EntityModel
also).
In terms of ECB, the entity element is simply represented by JPA entities and Spring Data repositories with the @Repository
annotation. There's not much more to it than emphasising the architectural role.
Transaction Retries
Now that we are familiar with ECB, let's wrap things up by also adding the capability to retry transient SQL errors. When a SQL error with the state code 40001
encountered, it's typically safe to retry the local transaction from a database point of view. If the retried business facade method and its descendants are nonidempotent, then some precautions may be needed to avoid multiple side effects (again strive for idempotency).
The simplest approach is to use Spring Retry with a custom exception classifier and exponential backoff. How this is done is outlined in more detail in this post.
A brief example of a retriable boundary for completeness (notice the @Retryable
):
@Service
public class OrderService {
@Boundary
@Retryable
public Order updateOrderStatus(Long orderId, ShipmentStatus status, BigDecimal amount) {
// Call DB and maybe do other idempotent stuff
return order;
}
}
You can also push the @Retryable
annotation to @Boundary
which then automatically adds the retry capability to all annotated methods.
@Inherited
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Retryable(exceptionExpression = "@cockroachExceptionClassifier.shouldRetry(#root)", maxAttempts = 5, backoff = @Backoff(maxDelay = 15_000, multiplier = 1.5))
public @interface Boundary {
}
The exception classifier for completeness:
@Component
public class CockroachExceptionClassifier {
private final Logger logger = LoggerFactory.getLogger(getClass());
private static final String SERIALIZATION_FAILURE = "40001";
public boolean shouldRetry(Throwable ex) {
if (ex == null) {
return false;
}
Throwable throwable = NestedExceptionUtils.getMostSpecificCause(ex);
if (throwable instanceof SQLException) {
return shouldRetry((SQLException) throwable);
}
logger.warn("Non-transient exception {}", ex.getClass());
return false;
}
public boolean shouldRetry(SQLException ex) {
if (SERIALIZATION_FAILURE.equals(ex.getSQLState())) {
logger.warn("Transient SQL exception detected : sql state [{}], message [{}]",
ex.getSQLState(), ex.toString());
return true;
}
return false;
}
}
General Guidelines
A few general guidelines for transaction management.
Avoid Remote Calls
Avoid remote calls to external resources from within a database transaction context. You may end up locking up resources for a long time in case of network communication problems or issues with the target endpoint. You are also exposed to the challenge of dual writes, where one part succeeds and the other part fails leaving the system in an inconsistent state (typically addressed with the outbox pattern).
Read-Only Implicit Transactions
If you are not performing any writes, then consider using read-only, implicit transactions. The readOnly
attribute in @Transactional
gives a clue to the transaction management that it's a read-only operation. The JPA provider may then perform certain optimizations.
Non-transactional read-only (implicit transactions) methods can use SUPPORTS
propagation. This works as long as the default autoCommit
flag is not enabled in the data source.
HikariDataSource ds = properties
.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
ds.setAutoCommit(false); // false is the default, setting it to true makes all transactions explicit
Conclusion
This article describes the ECB architecture pattern to enhance transaction robustness in Spring Boot apps. Database transactions must always be started by boundaries and nowhere else. A boundary is typically a web controller or business service facade.
Boundaries use
REQUIRES_NEW
propagation.Control services and repositories use
MANDATORY
propagation.Non-transactional read-only methods can use
SUPPORTS
propagation.