In this post, we’ll explore how to enforce Clean Architecture principles in a Spring Boot application using Taikai.
Prerequisites
This is the list of all the prerequisites:
- Spring Boot 4 or later
- Maven 3.9+
- Java 21 or later
- Taikai 1.63.0 (architecture testing)
- IntelliJ IDEA, Visual Studio Code, or another IDE
- H2 (in-memory)
Overview
Why Architectural Enforcement Matters
Architecture decisions are easy to document and hard to maintain. A layered Spring Boot application looks clean at sprint one. Six months later, controllers import repository interfaces directly, services accumulate cross-domain dependencies, and domain objects begin carrying Spring annotations. The diagram in your README no longer reflects the code.
Architecture tests solve this. By encoding structural rules as executable tests, you catch violations in CI before they become permanent technical debt. Taikai extends ArchUnit with Spring-aware, opinionated rules covering naming conventions, dependency constraints, logging standards, and test quality — all expressed through a concise fluent API.
What Is Taikai?
Taikai is an automated architecture testing library built on ArchUnit. Where ArchUnit gives you the primitives to write custom rules, Taikai wraps those primitives into a predefined fluent API that covers the most common Spring Boot conventions out of the box.
It runs as a standard JUnit 5 test, requires no agents or bytecode instrumentation at runtime, and adds zero overhead to production code — the dependency is test scope only.
The key value proposition: instead of writing twenty separate ArchUnit rules from scratch, you configure a single Taikai.builder() chain with all the constraints your architecture requires. The builder covers Java hygiene, naming conventions, import cycles, Spring stereotypes, logging standards, JUnit quality gates, and still allows raw ArchUnit rules for anything custom.
Taikai
└── ArchUnit (foundation)
├── Java rules → hygiene, naming, imports, fields, methods
├── Spring rules → controllers, services, repositories, boot config
├── Logging rules → logger field conventions
├── Test rules → JUnit 5 quality gates
└── Custom rules → raw ArchUnit via TaikaiRule.of(...)
Architecture tests solve this. By encoding structural rules as executable tests, you catch violations in CI before they become permanent technical debt. Taikai extends ArchUnit with Spring-aware, opinionated rules covering naming conventions, dependency constraints, logging standards, and test quality — all expressed through a concise fluent API.
What We’re Building
A Library Management API with a strict four-layer architecture and a comprehensive Taikai test suite that enforces every layer boundary.
Architecture diagram:
+---------------------+
| presentation | @RestController, request/response mapping
+---------------------+
↓
+---------------------+
| application | @Service, business logic, DTOs
+---------------------+
↓
+---------------------+
| domain | Java records, repository interfaces — no framework
+---------------------+
↑
+---------------------+
| infrastructure | @Repository, JPA entities, adapters
+---------------------+
The rules Taikai will enforce:

Let’s code
Project Setup
We’ll start by creating a simple Spring Boot project from start.spring.io, with the following dependencies: Spring Web, Lombok, H2 Database, Spring Data JPA, and Validation.
Add Taikai to pom.xml with test scope:
<dependency>
<groupId>com.enofex</groupId>
<artifactId>taikai</artifactId>
<version>1.63.0</version>
<scope>test</scope>
</dependency>
At the time of writing this article, the current version is 1.63.0.
Package structure:
com.boottechnologies.library
├── domain/
│ ├── exception/
│ ├── model/
│ └── repository/
├── application/
│ ├── dto/
│ └── service/
├── infrastructure/
│ ├── config/
│ └── persistence/
│ ├── adapter/
│ ├── entity/
│ └── jpa/
└── presentation/
├── controller/
└── handler/
This structure is the foundation the architecture tests will reason about. Each package is a well-defined boundary.
Domain Layer
The domain layer holds your core business concepts. It must not depend on any framework — not Spring’s @Component, not JPA’s @Entity, not anything outside the JDK.
Domain models as records:
package com.boottechnologies.library.domain.model;
public record Author(Long id, String firstName, String lastName, String email) {}
package com.boottechnologies.library.domain.model;
public record Book(Long id, String title, String isbn, double price, Long authorId) {}
Records are the right fit here: immutable, final, with equals, hashCode, and toString generated, and no framework requirements.
Repository interfaces — the ports:
package com.boottechnologies.library.domain.repository;
public interface AuthorRepository {
Author save(Author author);
Optional<Author> findById(Long id);
List<Author> findAll();
void deleteById(Long id);
}
These interfaces express what the domain needs from persistence without encoding how persistence works. JPA is an infrastructure detail; the domain never imports it.
Domain exception:
package com.boottechnologies.library.domain.exception;
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
}
}
A custom runtime exception in the domain gives the application layer a framework-free way to signal missing resources.
Application Layer
The application layer coordinates domain objects. Services hold the transaction boundary, validate inputs, and delegate to domain repositories via the interfaces defined above.
@Service
@RequiredArgsConstructor
@Transactional
public class AuthorService {
private static final Logger LOG = LoggerFactory.getLogger(AuthorService.class);
private final AuthorRepository authorRepository;
public AuthorResponse create(AuthorRequest request) {
LOG.info("Creating author: {} {}", request.firstName(), request.lastName());
Author author = new Author(null, request.firstName(), request.lastName(), request.email());
return AuthorResponse.from(authorRepository.save(author));
}
@Transactional(readOnly = true)
public AuthorResponse findById(Long id) {
return authorRepository.findById(id)
.map(AuthorResponse::from)
.orElseThrow(() -> new NotFoundException("Author not found: " + id));
}
@Transactional(readOnly = true)
public List<AuthorResponse> findAll() {
return authorRepository.findAll().stream()
.map(AuthorResponse::from)
.toList();
}
public void deleteById(Long id) {
LOG.info("Deleting author with id: {}", id);
if (!authorRepository.existsById(id)) {
throw new NotFoundException("Author not found: " + id);
}
authorRepository.deleteById(id);
}
}
Notice what is absent: no System.out.println, no throws Exception, no @Autowired field injection. These are exactly the patterns Taikai will enforce.
DTOs live in the application layer as records:
public record AuthorRequest(
@NotBlank String firstName,
@NotBlank String lastName,
@Email String email
) {}
public record AuthorResponse(Long id, String firstName, String lastName, String email) {
public static AuthorResponse from(Author author) {
return new AuthorResponse(author.id(), author.firstName(), author.lastName(), author.email());
}
}
Infrastructure Layer
The infrastructure layer provides concrete implementations of domain interfaces. It is the only layer that knows about JPA or any specific persistence technology.
JPA entities are separate from domain models. The mapping between them is explicit:
package com.boottechnologies.library.infrastructure.persistence.entity;
@Entity
@Table(name = "authors")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class AuthorEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String firstName;
@Column(nullable = false)
private String lastName;
@Column(unique = true)
private String email;
}
The Spring Data JPA repository:
package com.boottechnologies.library.infrastructure.persistence.jpa;
public interface AuthorJpaRepository extends JpaRepository<AuthorEntity, Long> {}
The adapter implements the domain interface — this is where dependency inversion happens:
package com.boottechnologies.library.infrastructure.persistence.adapter;
@Repository
@RequiredArgsConstructor
public class AuthorRepositoryAdapter implements AuthorRepository {
private final AuthorJpaRepository jpaRepository;
@Override
public Author save(Author author) {
return toDomain(jpaRepository.save(toEntity(author)));
}
@Override
public Optional<Author> findById(Long id) {
return jpaRepository.findById(id).map(this::toDomain);
}
@Override
public List<Author> findAll() {
return jpaRepository.findAll().stream().map(this::toDomain).toList();
}
@Override
public void deleteById(Long id) {
jpaRepository.deleteById(id);
}
private AuthorEntity toEntity(Author a) {
return new AuthorEntity(a.id(), a.firstName(), a.lastName(), a.email());
}
private Author toDomain(AuthorEntity e) {
return new Author(e.getId(), e.getFirstName(), e.getLastName(), e.getEmail());
}
}
The adapter pattern isolates JPA entirely within the infrastructure boundary. Switching from JPA to MongoDB tomorrow means only the adapter changes. Domain and application layers are untouched.
Presentation Layer
Controllers are thin. They receive HTTP requests, delegate to application services, and return responses. Nothing else.
package com.boottechnologies.library.presentation.controller;
@RestController
@RequestMapping("/api/v1/authors")
@RequiredArgsConstructor
@Validated
public class AuthorController {
private final AuthorService authorService;
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public AuthorResponse create(@Valid @RequestBody AuthorRequest request) {
return authorService.create(request);
}
@GetMapping("/{id}")
public AuthorResponse findById(@PathVariable Long id) {
return authorService.findById(id);
}
@GetMapping
public List<AuthorResponse> findAll() {
return authorService.findAll();
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteById(@PathVariable Long id) {
authorService.deleteById(id);
}
}
The controller imports AuthorService, AuthorRequest, and AuthorResponse — nothing from infrastructure. That constraint is about to be enforced automatically.
Architecture Tests with Taikai
Create a single test class in a dedicated architecture sub-package:
package com.boottechnologies.library.architecture;
class ArchitectureTest {
@Test
void shouldFulfillArchitectureConstraints() {
Taikai.builder()
.namespace("com.boottechnologies.library")
// ... rules
.build()
.checkAll();
}
}
Use checkAll() instead of check(). It collects every violation before throwing, giving you a complete report in a single CI run rather than stopping at the first failure.
Java Hygiene Rules
.java(java -> java
.noUsageOfDeprecatedAPIs()
.noUsageOfSystemOutOrErr()
.fieldsShouldNotBePublic()
.methodsShouldNotDeclareGenericExceptions()
.methodsShouldNotExceedMaxParameters(5)
.utilityClassesShouldBeFinalAndHavePrivateConstructor())
noUsageOfDeprecatedAPIs()prevents silent coupling to APIs scheduled for removal.methodsShouldNotDeclareGenericExceptions()stops methods from declaringthrows ExceptionorthrowsRuntimeException— which hides real failure modes and forces callers into overly broad catch blocks.fieldsShouldNotBePublic()enforces encapsulation across every class in the codebase.
Naming Conventions
.java(java -> java
// ...
.naming(naming -> naming
.packagesShouldMatchDefault()
.interfacesShouldNotHavePrefixI()
.constantsShouldFollowConventions()))
packagesShouldMatchDefault()checks every package name against^[a-z_]+(\.[a-z_][a-z0-9_]*)*$— no camelCase packages.interfacesShouldNotHavePrefixI()removes theIAuthorRepositoryanti-pattern.constantsShouldFollowConventions()ensuresstatic finalfields followUPPER_SNAKE_CASE.
Import Management
.java(java -> java
// ...
.imports(imports -> imports
.shouldHaveNoCycles()))
Import cycles create tight coupling that cannot be untangled during refactoring. This rule fails the build the moment a cycle appears, before it compounds.
Spring-Specific Rules
.spring(spring -> spring
.noAutowiredFields()
.boot(boot -> boot
.applicationClassShouldResideInPackage("com.boottechnologies.library"))
.configurations(config -> config
.namesShouldEndWithConfiguration())
.controllers(controllers -> controllers
.namesShouldEndWithController()
.shouldBeAnnotatedWithRestController()
.shouldNotDependOnOtherControllers())
.services(services -> services
.namesShouldEndWithService()
.shouldBeAnnotatedWithService()
.shouldNotDependOnControllers())
.repositories(repositories -> repositories
.namesShouldMatch(".*RepositoryAdapter")
.shouldBeAnnotatedWithRepository()
.shouldNotDependOnControllers()
.shouldNotDependOnServices()))
noAutowiredFields()enforces constructor injection everywhere. Constructor-injected dependencies are explicit, testable, and immutable.shouldNotDependOnOtherControllers()prevents the anti-pattern whereBookControllerholds a reference toAuthorController. Controllers must never orchestrate each other — that logic belongs in a service.namesShouldMatch(".*RepositoryAdapter")for repositories: this project uses the adapter pattern, so infrastructure classes are namedAuthorRepositoryAdapter, notAuthorRepository. The defaultnamesShouldEndWithRepository()would collide with the domain interface names. Configuring your own pattern here is the right call.
Logging Conventions
.logging(logging -> logging
.loggersShouldFollowConventions(Logger.class, "LOG",
List.of(PRIVATE, STATIC, FINAL)))
Every SLF4J logger must be named LOG, declared private static final. Inconsistent names (logger, log, LOGGER) and non-static loggers add instance overhead and make log correlation harder across services.
Test Quality Rules
.test(test -> test
.junit(junit -> junit
.classesShouldBePackagePrivate(".*Test")
.methodsShouldBePackagePrivate()
.methodsShouldNotBeAnnotatedWithDisabled()
.methodsShouldContainAssertionsOrVerifications()))
classesShouldBePackagePrivate(".*Test")enforces a JUnit 5 best practice: test classes do not need to bepublic. Making them package-private is the correct default.methodsShouldContainAssertionsOrVerifications()catches empty test methods — tests that call code without verifying any behavior pass silently and give false confidence. Taikai recognizes assertions from JUnit, AssertJ, Mockito, Hamcrest, and Spring MockMvc.
Custom Layer Isolation Rules
The Spring-specific rules above cover stereotype naming and dependency direction within Spring components. The layer isolation invariants require raw ArchUnit, plugged in via TaikaiRule.of():
.addRule(TaikaiRule.of(
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("..infrastructure..", "..presentation..", "..application..")
.as("Domain layer must not depend on any other layer")))
.addRule(TaikaiRule.of(
noClasses().that().resideInAPackage("..application..")
.should().dependOnClassesThat()
.resideInAnyPackage("..infrastructure..", "..presentation..")
.as("Application layer must not depend on infrastructure or presentation")))
.addRule(TaikaiRule.of(
noClasses().that().resideInAPackage("..presentation..")
.should().dependOnClassesThat()
.resideInAPackage("..infrastructure..")
.as("Presentation layer must not directly access infrastructure")))
These rules translate the architecture diagram into executable constraints. When a developer adds import com.boottechnologies.library.infrastructure.persistence.adapter.AuthorRepositoryAdapter to a controller, the build fails immediately with a message pointing to the exact violation.
The Complete Test Class
import com.enofex.taikai.Taikai;
import com.enofex.taikai.TaikaiRule;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import java.util.List;
import static com.tngtech.archunit.core.domain.JavaModifier.FINAL;
import static com.tngtech.archunit.core.domain.JavaModifier.PRIVATE;
import static com.tngtech.archunit.core.domain.JavaModifier.STATIC;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
class ArchitectureTest {
private static final String ROOT = "com.boottechnologies.library";
@Test
void shouldFulfillArchitectureConstraints() {
Taikai.builder()
.namespace(ROOT)
// ── Java hygiene ──────────────────────────────────────────────────────────
.java(java -> java
.noUsageOfDeprecatedAPIs()
.noUsageOfSystemOutOrErr()
.fieldsShouldNotBePublic()
.methodsShouldNotDeclareGenericExceptions()
.methodsShouldNotExceedMaxParameters(5)
.utilityClassesShouldBeFinalAndHavePrivateConstructor()
// No import cycles between packages
.imports(imports -> imports
.shouldHaveNoCycles())
// Naming conventions
.naming(naming -> naming
.packagesShouldMatchDefault()
.interfacesShouldNotHavePrefixI()
.constantsShouldFollowConventions()))
// ── Spring conventions ────────────────────────────────────────────────────
.spring(spring -> spring
.noAutowiredFields()
// @SpringBootApplication must sit at the root package
.boot(boot -> boot
.applicationClassShouldResideInPackage(ROOT))
// @Configuration classes must end with "Configuration"
.configurations(config -> config
.namesShouldEndWithConfiguration())
// @RestController classes: naming, annotation, no cross-controller calls
.controllers(controllers -> controllers
.namesShouldEndWithController()
.shouldBeAnnotatedWithRestController()
.shouldNotDependOnOtherControllers())
// @Service classes: naming, annotation, no dependency on controllers
.services(services -> services
.namesShouldEndWithService()
.shouldBeAnnotatedWithService()
.shouldNotDependOnControllers())
// @Repository classes follow the *RepositoryAdapter pattern used in this project
.repositories(repositories -> repositories
.namesShouldMatch(".*RepositoryAdapter")
.shouldNotDependOnControllers()
.shouldNotDependOnServices())
)
// ── Logging conventions ───────────────────────────────────────────────────
// Every SLF4J Logger must be named "log" and declared private static final
.logging(logging -> logging
.loggersShouldFollowConventions(Logger.class, "LOG",
List.of(PRIVATE, STATIC, FINAL)))
// ── Test quality rules ────────────────────────────────────────────────────
.test(test -> test
.junit(junit -> junit
.classesShouldBePackagePrivate(".*Test")
.methodsShouldBePackagePrivate()
.methodsShouldNotBeAnnotatedWithDisabled()
.methodsShouldContainAssertionsOrVerifications()))
// ── Layer isolation (custom ArchUnit rules) ───────────────────────────────
// Domain: the innermost ring — no outward dependencies allowed
.addRule(TaikaiRule.of(
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("..infrastructure..", "..presentation..", "..application..")
.as("Domain layer must not depend on any other layer")))
// Application: may depend on domain only
.addRule(TaikaiRule.of(
noClasses().that().resideInAPackage("..application..")
.should().dependOnClassesThat()
.resideInAnyPackage("..infrastructure..", "..presentation..")
.as("Application layer must not depend on infrastructure or presentation")))
// Presentation: may call application services, never infrastructure directly
.addRule(TaikaiRule.of(
noClasses().that().resideInAPackage("..presentation..")
.should().dependOnClassesThat()
.resideInAPackage("..infrastructure..")
.as("Presentation layer must not directly access infrastructure")))
.build()
.checkAll();
}
}
Catching Violations in Practice
Before moving to this section, let’s run the test class first.

Everything is working correctly. Now, to understand what architecture violations look like in practice, let’s intentionally introduce one.
Add System.out.println("debug: " + request) inside AuthorService.create() and run the tests.
Taikai reports:
java.lang.AssertionError: Found 1 Taikai violations for 1 rules!
Rule: Classes should not use System.out or System.err
Method com.boottechnologies.library.application.service.AuthorService.create(com.boottechnologies.library.application.dto.AuthorRequest) calls java.lang.System.out
at com.enofex.taikai.Taikai.checkAll(Taikai.java:184)
at com.boottechnologies.library.architecture.ArchitectureTest.shouldFulfillArchitectureConstraints(ArchitectureTest.java:112)
The message includes the exact class, method, and line number. With checkAll(), every violation across every class is aggregated into a single error report — no fix-one-reveal-another cycles during CI runs.
Try a layer violation: inject AuthorRepositoryAdapter directly into BookController instead of going through BookService. The layer isolation rule catches it:
java.lang.AssertionError: Found 2 Taikai violations for 1 rules!
Rule: Presentation layer must not directly access infrastructure
Constructor <com.boottechnologies.library.presentation.controller.BookController.<init>(com.boottechnologies.library.application.service.BookService, com.boottechnologies.library.infrastructure.persistence.adapter.AuthorRepositoryAdapter)> has parameter of type <com.boottechnologies.library.infrastructure.persistence.adapter.AuthorRepositoryAdapter> in (BookController.java:0)
Field <com.boottechnologies.library.presentation.controller.BookController.authorRepositoryAdapter> has type <com.boottechnologies.library.infrastructure.persistence.adapter.AuthorRepositoryAdapter> in (BookController.java:0)
at com.enofex.taikai.Taikai.checkAll(Taikai.java:184)
at com.boottechnologies.library.architecture.ArchitectureTest.shouldFulfillArchitectureConstraints(ArchitectureTest.java:112)
The violation message names the exact rule (as(...) string), the violating class, the dependency type, and the source line. There is no ambiguity about what broke or why.
Advanced: Reusable Rule Profiles
In a multi-module project, duplicating the Taikai configuration across modules is error-prone. Taikai’s Customizer<T> interface extracts shared rules into reusable profiles:
public final class SharedArchitectureProfile {
private SharedArchitectureProfile() {}
public static final Customizer<JavaConfigurer> JAVA_RULES = java -> java
.noUsageOfDeprecatedAPIs()
.noUsageOfSystemOutOrErr()
.fieldsShouldNotBePublic()
.methodsShouldNotDeclareGenericExceptions();
public static final Customizer<SpringConfigurer> SPRING_RULES = spring -> spring
.noAutowiredFields()
.controllers(c -> c
.namesShouldEndWithController()
.shouldBeAnnotatedWithRestController()
.shouldNotDependOnOtherControllers())
.services(s -> s
.namesShouldEndWithService()
.shouldBeAnnotatedWithService()
.shouldNotDependOnControllers());
}
Each module applies the shared profile and layers on its own constraints:
Taikai.builder()
.namespace("com.boottechnologies.inventory")
.java(java -> {
SharedArchitectureProfile.JAVA_RULES.customize(java);
java.classesShouldBeRecords(".*Dto");
})
.spring(spring -> {
SharedArchitectureProfile.SPRING_RULES.customize(spring);
spring.repositories(r -> r
.shouldBeAnnotatedWithRepository()
.shouldNotDependOnControllers());
})
.build()
.checkAll();
The shared profile enforces organization-wide standards. Module-specific rules enforce what is unique to each bounded context. New modules cannot opt out of organization standards; they can only add on top.
Conclusion
🏁 Well done !!. In this post, we implemented architecture testing for a Spring Boot application using Taikai.
Spring Boot makes it easy to build applications quickly.
But speed without architectural discipline eventually creates tightly coupled systems that become difficult to maintain.
Clean Architecture provides the structure. Taikai provides the enforcement.
By combining both, teams can:
- Reduce architectural drift
- Preserve domain integrity
- Improve scalability
- Simplify refactoring
- Maintain clean dependency boundaries over time
The complete source code is available on GitHub.
Support me through GitHub Sponsors.
Thank you for reading!! See you in the next post.