Spring Annotations in Use Cases: Purity Fetish or Production Reality?

Spring Annotations in Use Cases: Purity Fetish or Production Reality?

The debate over using @Transactional and @Service in your application layer gets to the core tension between architectural ideals and shipping real software.

Spring Annotations in Use Cases: Purity Fetish or Production Reality?

A developer building medical software with Java and Spring Boot posts a familiar dilemma. They’ve embraced Clean and Hexagonal Architecture principles. Their domain and application layers are supposed to be independent of any technology. Yet, peering into other projects, they see @Transactional and @Service annotations slapped right on the UseCase implementations in the Application layer.

“I know this violates the principles”, they confess, seeking validation. The response from the trenches is less about purity and more about practicality: “What would you do instead? You still need transactions, right?”

This isn’t a theoretical debate. It’s the daily grind where venerable rules like the Dependency Inversion Principle meet the cold code of a Friday afternoon deployment. Does @Transactional on your RegisterPatientUseCaseImpl mean you’ve failed? Or does it mean you’re not wasting time on architectural contortions that offer no tangible benefit?

The Dogma: Framework Annotations Corrupt Your Core

The rule is clear. In Clean Architecture, as articulated by Uncle Bob, dependencies should point inward, toward the center. High-level policies should not depend on low-level details. Your business logic, the why and what of your application, should remain blissfully unaware of how it’s delivered, whether that’s via HTTP, gRPC, a message queue, or carrier pigeon.

Technically, slapping a Spring-specific @Service annotation on a class in your Application layer violates this. Your UseCase now has a direct compile-time dependency on spring-context. If you ever wanted to port your business logic to Quarkus, Micronaut, or vanilla Java, you’d have to surgically remove these annotations. It’s a dependency that points outward, from your core logic to a specific framework. Case closed… in theory.

As one developer noted on a forum, this is where the “dogma begins.” The purist take is that your business code should compile in isolation, devoid of framework JARs. It’s an appealing ideal: a perfectly portable core, untethered from the fickle winds of framework popularity.

The Pragmatic Bind: You Still Need Transactions

The retort from engineers who ship code is a shrug followed by a question: “Let’s say we all agree it violates them: What would you do instead? Spring boot or not, you still need transactions.”

This is the crux of the debate. Transactions aren’t optional for reliable systems, especially for medical software where data integrity is non-negotiable. The abstraction must appear somewhere. Clean Architecture rightly pushes infrastructure concerns like persistence and HTTP handling to outer layers. But transaction management sits uncomfortably between infrastructure and business logic. It’s an infrastructure concern (database connections, rollbacks) that directly orchestrates the behavior of your business logic.

The Medium article offers a crucial, technical insight into why this gets messy: Spring’s AOP annotations (like @Transactional) only work when methods are invoked through the proxy, not through this.

Consider this problematic, yet common, scenario:

public class PatientService {
  @Transactional
  public Patient register(PatientDto dto) {
     // operations
  }

  @Transactional
  public Patient updateStatus(Long id, Status status) {
     // operations
  }

  public Patient handleComplexAdmission(AdmissionData data) {
    Patient p = this.register(data.toPatientDto()), // Proxy bypassed!
    this.updateStatus(p.getId(), Status.ADMITTED), // Proxy bypassed!
    return p;
  }
}

In handleComplexAdmission, the internal this.register() call doesn’t go through Spring’s AOP proxy. The @Transactional annotation is ignored, potentially leaving your database operations in an inconsistent state. The “clean” solution, per the article, is to split responsibilities into separate collaborating services, forcing calls through the proxy.

This isn’t just a Spring quirk, it’s a manifestation of the proxy pattern’s limitations when mixed with internal method calls. It highlights that simply avoiding annotations doesn’t solve the problem, you must also architect your object interactions correctly.

The Jakarta Escape Hatch: Is it Truly Agnostic?

A nuanced middle ground exists, and it’s rooted in the history of Java EE (now Jakarta EE). The suggestion is to use standard specifications instead of vendor-specific annotations.

  • Instead of @Service (from spring-context): Use @Named from the jakarta.inject-api JAR. Spring understands it.
  • Instead of @Transactional (Spring): Use @Transactional from jakarta.transaction.Transactional.

Your business class would then depend only on Jakarta API JARs, specifications, not implementations. It would theoretically work on Spring, Quarkus, Payara, or any other Jakarta-compliant runtime. Theclean architecture diagramslook perfect again. Your application layer’s compile-time dependency remains on stable, standardized APIs.

But is this truly technology-agnostic? You’re still choosing Java as your ecosystem. The dependency is just one level higher: from “Spring” to “The Java Enterprise Landscape.” For many teams locked into the JVM and its conventions, this is a perfectly acceptable compromise and achieves significant decoupling from any single framework vendor.

Decoupling Through Configuration: The “Original Spirit of Spring”

There’s another, often-overlooked approach that harkens back to Spring’s XML configuration roots: don’t annotate the UseCase class at all. Keep it a plain Java class.

// In Application Layer - Pure Java
public class RegisterPatientUseCaseImpl implements RegisterPatientUseCase {
    private final PatientRepository repository;
    public RegisterPatientUseCaseImpl(PatientRepository repository) {
        this.repository = repository;
    }
    @Override
    public Patient execute(RegisterPatientCommand command) {
        // Business logic, NO @Transactional
        Patient patient = new Patient(command);
        return repository.save(patient);
    }
}

Then, in your Infrastructure/Configuration layer (where Spring dependencies belong), you define a @Bean:

@Configuration
public class UseCaseConfiguration {
    @Bean
    @Transactional // Transaction boundary defined HERE
    public RegisterPatientUseCase registerPatientUseCase(PatientRepository repo) {
        return new RegisterPatientUseCaseImpl(repo);
    }
}

Now your UseCase implementation is completely devoid of framework touchpoints. It can be instantiated in a unit test without Spring, compiled in a module with zero Spring dependencies, and the transaction boundary is declared externally, where infrastructure decisions should live.

This pattern, while more verbose, is arguably the purest alignment with Clean Architecture. It pushes all configuration and cross-cutting concerns to the outer “plugin” layer. It’s also friendly to IDEs, which can see a clear new invocation in the config.

But it’s more code. More files. More indirection. The question becomes: does this extra ceremony deliver proportional value for your team and your project’s expected lifespan? The pursuit of architectural purity often forces you into precisely these kinds of architectural contortionsthat feel clever on a diagram and heavy in the editor.

The Verdict: It Depends (On What You’re Building)

So, are annotations in the Application layer a violation? Yes, strictly speaking. Are they sometimes the right choice anyway? Also yes.

The decision hinges on your project’s context, not an abstract scorecard:

For a Long-Lived, Complex Core Domain (like financial trading, medical systems): The extra effort to maintain pure ports and adapters, using configuration-based bean definition or Jakarta standard annotations, pays significant dividends. The core becomes a reusable asset, and swapping out frameworks or scaling becomes less painful. It prevents the slow creep of framework imports contaminating business logic.

For a Fast-Moving Business Application or API: Putting @Transactional and @Service directly on your UseCase class is a pragmatic, justifiable shortcut. The coupling to Spring is high, but so is the velocity it provides. The “framework risk”is often lower than the “business obsolescence risk.”

The most critical failure isn’t choosing one approach or the other. It’s blindly applying dogma without understanding the why. If you sprinkle @Transactional everywhere, you must understand the proxy mechanism and internal method call pitfalls. If you zealously avoid all framework contact, you must ensure the resulting complexity doesn’t grind development to a halt.

In the end, architecture is about managing trade-offs. The debate over annotations isn’t about right or wrong. It’s a litmus test for your team’s values: how much do you prioritize long-term flexibility and theoretical purity versus immediate development speed and clarity? The answer will, and should, be different for a greenfield startup API than for the core transaction engine of a hospital.

Your UseCase class, sitting there on your screen, is just a class. Whether you adorn it with @Service or wrap it in a @Beanmethod is less important than the shared understanding on your team of what that choice means, and what you’re trading for it. The true test of your architecture isn’t in a code review sniffing for forbidden imports, it’s what happens when a production incident challengesyour decisions, and whether your choices make the system easier or harder to fix while it’s on fire.

Share:

Related Articles