We have a habit of calling things non-functional requirements when we don’t want to think too hard about them.
Transactions. Retries. Rollbacks. These are often delegated to frameworks through annotations, configuration, or other runtime mechanisms. We treat them as infrastructure concerns, separate from the application itself.
I think this is a mistake.
Not because frameworks are bad, or because annotations are inherently wrong. The problem is that, by using them, we can unknowingly delegate decisions that belong to our application to a framework that has no business making them.
A transaction is not just an infrastructure concern
Consider a Spring application:
@Transactional
public void processOrder(Order order) {
inventory.reserve(order);
payment.charge(order);
shipping.schedule(order);
}This looks reasonable. The method performs a business operation, and Spring takes care of the transaction.
But what does taking care of the transaction actually mean?
- Which operations must succeed or fail together for the order to remain consistent?
- What happens if inventory is reserved but the payment fails?
- Can we roll back the payment? Can we release the inventory?
- What happens if shipping has already been scheduled when a later operation fails?
- Which failures should trigger a rollback, and which should not?
These are not merely database questions. They are business questions.
A transaction defines a consistency boundary. It determines which state changes must be treated as one logical operation and what happens when that operation cannot be completed.
Putting @Transactional on a method does not answer these questions. It delegates the execution of a transaction to Spring, while the actual consistency requirements remain the application’s responsibility.
Worse, the annotation can make us believe that we have handled consistency when we have only configured one particular mechanism for it.
A database rollback, for example, cannot undo a payment processed by an external provider. The framework cannot infer the correct business response to that situation.
The application must own the decision. The framework should only help implement it.
The same problem applies to retries
Consider another familiar annotation:
@Retryable(maxAttempts = 3)
public void processPayment(Order order) {
paymentGateway.charge(order);
}Three attempts. Sounds reasonable.
But should we retry every failure? What if the payment provider processed the charge but the response was lost? Could another attempt charge the customer twice? Is the operation idempotent? Should a timeout be treated differently from a declined payment?
The correct retry policy depends on the operation’s semantics, its failure modes, and the guarantees provided by the external system.
Those are application-level concerns.
A retry annotation can execute a policy, but it cannot determine whether that policy is correct for the business operation. Even when the annotation is configured carefully, the retry mechanism can obscure the actual control flow and make it harder to reason about the application’s behavior.
This does not mean that retry mechanisms should be implemented from scratch. It means that the decision to retry, the conditions under which retries are allowed, and the consequences of repeated execution should be explicit and testable application behavior.
The framework may provide the mechanism. It should not silently become the owner of the policy.
Why do we call these non-functional requirements?
I suspect the terminology contributes to the problem.
We call performance, reliability, consistency, transaction management, and retry behavior non-functional because they do not describe the primary business outcome of an operation.
But this classification can be misleading.
Some concerns that are commonly grouped under non-functional requirements have direct consequences for functional correctness.
A payment processed twice is not merely a reliability issue. An order whose inventory has been reserved but whose payment has failed is not merely a transaction-management issue. A retry that repeats an irreversible operation is not merely an infrastructure issue.
These are failures of application behavior.
Of course, not every non-functional requirement is business logic. HTTP transport, database drivers, memory consumption, and deployment configuration are genuine infrastructure or operational concerns. But we should not use the label non-functional as an excuse to remove important decisions from the application code.
If a decision changes what the application does when something succeeds, fails, or is repeated, it deserves the same scrutiny as any other business rule.
Make the application testable without its framework
The application should be testable using plain Java tests, without starting Spring, a CDI container, or a database just to verify its decisions.
Consider a payment operation that should be retried only when a temporary failure occurs. Instead of delegating the entire policy to a @Retryable annotation, we can express it through a separate, reusable retry operation:
public static <T> Either<RuntimeException, T> retry(
Supplier<T> operation,
Class<? extends RuntimeException> retryOn,
int maxAttempts) {
RuntimeException lastError =
new IllegalArgumentException("maxAttempts must be positive");
for (int attempt = 0; maxAttempts > 0 && attempt < maxAttempts; attempt++) {
Try<T> result = Try.of(operation::get);
if (result.isSuccess()) {
return Either.right(result.get());
}
Throwable cause = result.getCause();
lastError = cause instanceof RuntimeException
? (RuntimeException) cause
: new RuntimeException(cause);
if (!retryOn.isInstance(lastError)) {
break;
}
}
return Either.left(lastError);
}The business operation remains independent of the retry mechanism:
public Either<RuntimeException, PaymentResult> charge(Order order) {
return retry(
() -> paymentGateway.charge(order),
TemporaryFailure.class,
3
);
}Now we can test the retry policy independently, using a plain unit test and a fake payment gateway. We can verify that a temporary failure is retried, that success stops further attempts, and that the final failure is returned.
No Spring context. No proxies. No application server.
The example is deliberately simple; a real implementation must also consider idempotency, timeouts, backoff, and which failures are safe to retry. Those decisions depend on the operation and belong in its policy, not in a generic annotation applied without considering its semantics.
The point is that we should be able to test the application’s decisions independently of the mechanism that executes them.
Testing through the execution mechanism adds complexity on both sides: tests become harder to write and maintain, while the application inherits additional runtime behavior that can introduce overhead, hidden interactions, and bugs.
That complexity has a cost: development time, maintenance effort, and, ultimately, correctness. We should pay that cost only when the mechanism provides real value, not simply because our framework makes it convenient.
The same principle applies to transaction boundaries. We should be able to test which operations must succeed together and how failures are handled, while using integration tests to verify that the real database adapter correctly implements commits and rollbacks.
Does this mean we should eliminate frameworks?
Absolutely not.
An application does not run in a vacuum. It needs an environment, and that environment provides essential capabilities.
We are not going to write our own HTTP server just to receive user requests. We are not going to write a database driver. We do not need to implement dependency injection ourselves when a suitable container already exists.
Using Spring Boot, CDI, an application server, or another runtime is perfectly reasonable. So is using framework-specific adapters to connect the application to databases, message brokers, HTTP endpoints, and external services.
The distinction is where we draw the boundary.
I want the application to depend on abstractions that express the capabilities it needs, while the runtime supplies the implementations.
For example:
- An HTTP adapter translates incoming requests into application calls.
- A persistence adapter translates application operations into database queries.
- A transaction adapter implements the transaction boundaries requested by the application.
- A retry mechanism executes a retry policy that the application has explicitly defined.
The application should not need to know how an HTTP request is received or how a database connection is managed. But it should know which operations must be consistent, which failures are recoverable, and what should happen when an operation cannot be completed.
This is not about eliminating all framework dependencies from every line of code. It is about keeping framework-specific concerns at the edges, rather than allowing them to define the application’s behavior.
Framework independence is also about avoiding unnecessary complexity
There is another reason to care about this distinction: frameworks can introduce behavior that is not immediately visible in the code.
A method annotated with @Transactional may be intercepted by a proxy. Its behavior can depend on how the object was created, how the method was invoked, whether the invocation crossed the relevant proxy boundary, and how transaction propagation and rollback rules are configured.
Self-invocation, for example, can bypass proxy-based transaction interception in common Spring configurations. A method may look transactional in the source code while its actual execution does not behave as expected.
The issue is not that Spring is defective. These mechanisms have legitimate uses, and the framework documents their constraints.
The issue is that an implicit runtime mechanism introduces another layer of behavior that developers must understand and verify.
Sometimes that layer is justified. Sometimes a small, explicit piece of application code would be easier to reason about, easier to test, and less likely to conceal a mistake.
The more important a decision is to correctness, the less comfortable I am leaving it implicit.
Portability is a consequence, not the main goal
An application whose core behavior can be tested independently of its runtime is generally easier to move to another environment.
Perhaps today we use Spring Boot and tomorrow we need an application server. Perhaps we replace a database, change a message broker, or introduce a different external service.
If our application is tightly coupled to framework-specific annotations and runtime behavior, these changes can affect far more code than they should.
If the application expresses its requirements through ordinary code and clear interfaces, we can often replace the relevant adapters without rewriting the business logic.
But portability is not the primary goal.
I am not suggesting that every application should be designed to support five different frameworks or databases. That can become abstraction for abstraction’s sake.
The primary goal is to make application behavior explicit, understandable, and independently testable. Portability follows naturally from that discipline.
A practical rule
When deciding whether something belongs in the application or in a framework, I ask a simple question:
Can I verify that this behavior is correct using ordinary tests of the application itself, without starting its runtime?
If the answer is no, I investigate why.
Sometimes the behavior genuinely belongs to infrastructure, and an integration test is the appropriate way to verify it.
But sometimes the application has delegated a decision it should own to an annotation, a proxy, a container, or a framework default.
In that case, I try to bring the decision back into explicit application code and leave the framework responsible for providing the underlying mechanism.
This is not a ban on annotations. It is not a demand for hand-written infrastructure. And it is certainly not an argument for implementing everything ourselves.
It is a way of keeping responsibility where it belongs.
Your framework should help your application do its job. It should not quietly decide what that job is.