
The advent of AI has revolutionised the way software engineers approach programming. While we can now leverage code generation in various ways, I firmly believe that structure remains essential! AI excels at recognising patterns and working consistently when given guidance. Moreover, the abstraction level has increased, allowing developers to focus more on business logic as well as overall architecture and less on implementation details. Given this, combining AI-generated code with hexagonal architecture should be an intriguing combination. In this article, we will delve into this concept in the context of a full-stack application, specifically focusing on an ultra-running theme (races longer than 42.2km or 26 miles).
The Application and its Hexagonal Structure
Our application is designed to manage races and related athlete information. The main view provides an overview of upcoming events, as depicted in the image below:

On the detail view of a specific race, users can edit attributes, add segments, and input other relevant details, as shown in the next screenshot:

Now, let’s take a more technical look at our application. At its core lies the principle of hexagonal, onion, or clean architecture, which dictates that everything revolves around the domain. In this context, infrastructural aspects such as storage and API exposure are dependent on the domain, rather than the other way around. Use cases complete the picture by describing what happens within the domain.
The following diagram provides a visual representation of our hexagonal structure, highlighting a single use case for the Race domain as an example:

In the next section, we’ll explore how AI coding agents can be informed about the desired structure and important details, enabling seamless integration with our application.
Defining Guardrails for AI with ArchUnit and Instruction Files
To ensure our architecture remains consistent, we’ll employ ArchUnit, a Java library designed to unit test architectural structures. This approach provides guardrails that help maintain the desired structure. Additionally, we’ll create instruction files for GitHub CoPilot to specify the preferred Java code style.
Describing the Application Architecture with ArchUnit
We will write tests in five areas to ensure our architecture is well-defined:
- Dependency Allowance: Verifying the allowed dependencies between the domain, API, infrastructure, and application layers.
- Boundary Checks: Additional checks for boundaries not covered by the initial architecture test (e.g., library usage).
- Naming Conventions: Enforcing consistent naming conventions throughout the codebase.
- Structural Rules for Classes: Validating class structure rules to maintain a cohesive architecture.
- Circular Dependency Check: Ensuring that dependencies between application parts are respected and follow the desired design without circles.
Below is the most important check that ensures the dependencies rules between the different parts of the application are respected:
@ArchTest
static final ArchRule layerDependenciesAreRespected =
layeredArchitecture()
.consideringAllDependencies()
.layer("Domain").definedBy("com.ultratrail.domain..")
.layer("Application").definedBy("com.ultratrail.application..")
.layer("API").definedBy("com.ultratrail.api..")
.layer("Infrastructure").definedBy("com.ultratrail.infrastructure..")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "API", "Infrastructure")
.whereLayer("Application").mayOnlyBeAccessedByLayers("API")
.whereLayer("Infrastructure").mayNotBeAccessedByAnyLayer()
.whereLayer("API").mayNotBeAccessedByAnyLayer();
To begin, we define the packages for each layer: Domain, Application, API, and Infrastructure. This serves as the foundation for our architecture, allowing us to identify the distinct layers.
Next, we determine which layers may be accessed by others. For instance, the Domain layer should not access other layers directly, ensuring a clear separation of concerns. By defining these boundaries, we establish the guardrails that govern our application’s architecture.
For additional boundaries, we add another test:
@ArchTest
static final ArchRule domainMustBeFrameworkFree =
noClasses().that().resideInAPackage("com.ultratrail.domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"org.springframework..",
"jakarta.persistence..",
"javax.persistence.."
)
.because("The domain layer must not depend on any framework.");
@ArchTest
static final ArchRule applicationMustNotUseJpa =
noClasses().that().resideInAPackage("com.ultratrail.application..")
.should().dependOnClassesThat()
.resideInAnyPackage("jakarta.persistence..", "javax.persistence..")
.because("The application layer must not use JPA directly.");
@ArchTest
static final ArchRule applicationMustNotUseSpringMvc =
noClasses().that().resideInAPackage("com.ultratrail.application..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework.web..", "jakarta.servlet..")
.because("The application layer must not depend on Spring MVC or Servlet API.");
@ArchTest
static final ArchRule apiMustNotUseJpa =
noClasses().that().resideInAPackage("com.ultratrail.api..")
.should().dependOnClassesThat()
.resideInAnyPackage("jakarta.persistence..", "javax.persistence..")
.because("The API layer must not use JPA directly.");
A third test covers naming conventions, ensuring consistency and clarity in our codebase:
@ArchTest
static final ArchRule useCasesMustBeNamedCorrectly =
classes().that().resideInAPackage("com.ultratrail.application..")
.and().areAnnotatedWith(org.springframework.stereotype.Service.class)
.should().haveSimpleNameEndingWith("UseCase")
.because("All @Service classes in the application layer must be named *UseCase.");
@ArchTest
static final ArchRule commandsMustBeNamedCorrectly =
classes().that().resideInAPackage("com.ultratrail.application..")
.and().areNotAnnotatedWith(org.springframework.stereotype.Service.class)
.and().areNotAssignableTo(RuntimeException.class)
.and().areNotInterfaces()
.should().haveSimpleNameEndingWith("Command")
.because("Non-use-case, non-exception classes in the application layer must be named *Command.");
@ArchTest
static final ArchRule exceptionsMustBeNamedCorrectly =
classes().that().resideInAPackage("com.ultratrail.application..")
.and().areAssignableTo(RuntimeException.class)
.should().haveSimpleNameEndingWith("NotFoundException")
.because("Domain exceptions in the application layer must be named *NotFoundException.");
@ArchTest
static final ArchRule controllersMustBeNamedCorrectly =
classes().that().resideInAPackage("com.ultratrail.api..")
.and().areAnnotatedWith(org.springframework.web.bind.annotation.RestController.class)
.should().haveSimpleNameEndingWith("Controller")
.because("All REST controllers must be named *Controller.");
@ArchTest
static final ArchRule repositoryAdaptersMustBeNamedCorrectly =
classes().that().resideInAPackage("com.ultratrail.infrastructure..")
.and().areAnnotatedWith(org.springframework.stereotype.Component.class)
.and().areNotInterfaces()
.should().haveSimpleNameEndingWith("RepositoryAdapter")
.because("Infrastructure adapters must be named *RepositoryAdapter.");
@ArchTest
static final ArchRule jpaEntitiesMustBeNamedCorrectly =
classes().that().resideInAPackage("com.ultratrail.infrastructure..")
.and().areAnnotatedWith(jakarta.persistence.Entity.class)
.should().haveSimpleNameEndingWith("Entity")
.because("JPA entity classes must be named *Entity.");
Additionally, more structural rules are defined below to further refine our architectural design.
@ArchTest
static final ArchRule useCaseFieldsMustBeFinal =
classes().that().resideInAPackage("com.ultratrail.application..")
.and().haveSimpleNameEndingWith("UseCase")
.should().haveOnlyFinalFields()
.because("Use case classes must have only final (injected) fields — no mutable state.");
@ArchTest
static final ArchRule noFieldInjectionAnywhere =
noFields().that().areDeclaredInClassesThat()
.resideInAnyPackage(
"com.ultratrail.domain..",
"com.ultratrail.application..",
"com.ultratrail.api..",
"com.ultratrail.infrastructure.."
)
.should().beAnnotatedWith(org.springframework.beans.factory.annotation.Autowired.class)
.because("Field injection with @Autowired is forbidden. Use constructor injection.");
@ArchTest
static final ArchRule controllersMustNotDependOnRepositories =
noClasses().that().resideInAPackage("com.ultratrail.api..")
.and().areAnnotatedWith(org.springframework.web.bind.annotation.RestController.class)
.should().dependOnClassesThat()
.haveSimpleNameEndingWith("Repository")
.because("Controllers must not directly depend on repository interfaces — inject use cases instead.");
And finally, we ensure that no circular dependencies occur, guaranteeing the stability and maintainability of our architecture.
@ArchTest
static final ArchRule noCircularDependenciesBetweenLayers =
slices().matching("com.ultratrail.(*)..").should().beFreeOfCycles()
.because("There must be no circular dependencies between architecture layers.");
With these ArchUnit tests in place, our AI coding agents are provided with clear boundaries to operate within. Now, let’s explore further steering possibilities through instruction files.
Creating Instruction Files to Guide GitHub CoPilot
Instruction files play a crucial role in providing context and guidance for your AI coding agents. By placing these files in the .github/instructions directory, you can help your agent understand how to build, test, and validate changes.
Let’s begin by creating instructions for the core of our hexagonal architecture – the Domain. This will be defined in a domain.instructions.md file:
---
applyTo: "src/main/java/com/ultratrail/domain/**"
---
# Domain Layer Rules
You are generating code in the **domain layer** (`com.ultratrail.domain`).
This layer is the core of the hexagonal architecture and must remain 100 % framework-free.
## Strict Rules
### Allowed imports
- `java.*`
- `lombok.*`
- Other `com.ultratrail.domain.*` classes
### Forbidden imports – NEVER add these
- `org.springframework.*`
- `jakarta.persistence.*` / `javax.persistence.*`
- `com.ultratrail.application.*`
- `com.ultratrail.api.*`
- `com.ultratrail.infrastructure.*`
### Domain Entities
- Annotate every entity with `@Data`, `@Builder`, `@NoArgsConstructor`, `@AllArgsConstructor` (Lombok).
- All IDs are `String` (UUID format).
- Do **not** add `@Entity`, `@Table`, `@Column`, or any JPA annotation.
- Embed domain-specific enums as static nested enums inside the owning entity.
- Do not add Spring bean annotations (`@Service`, `@Component`, `@Repository`).
- No business logic methods that depend on external services.
### Repository Interfaces (Ports)
- Repository interfaces are pure Java interfaces — they define the port, not the implementation.
- Name them `<Entity>Repository` (e.g., `RaceRepository`).
- Only use domain types as parameter and return types.
- Common methods: `save`, `findById`, `findAll`, `delete`. Add domain-specific finders as needed.
## Example
```java
package com.ultratrail.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Race {
private String id;
private String name;
public enum RaceStatus { PLANNED, REGISTERED, COMPLETED, DNF, DNS }
}
```
```java
package com.ultratrail.domain;
import java.util.List;
import java.util.Optional;
public interface RaceRepository {
Race save(Race race);
Optional<Race> findById(String id);
List<Race> findAll();
void delete(String id);
}
```
The Application part is described in application.instructions.md:
---
applyTo: "src/main/java/com/ultratrail/application/**"
---
# Application Layer Rules
You are generating code in the **application layer** (`com.ultratrail.application`).
This layer orchestrates use cases and acts as the bridge between the API and the domain.
## Strict Rules
### Allowed imports
- `com.ultratrail.domain.*`
- `org.springframework.stereotype.Service` (use cases only)
- `lombok.*`
- `java.*`
### Forbidden imports – NEVER add these
- `com.ultratrail.api.*`
- `com.ultratrail.infrastructure.*`
- `jakarta.persistence.*` / `javax.persistence.*`
- `org.springframework.web.*`
- `jakarta.servlet.*`
- Any Spring MVC annotation (`@RestController`, `@RequestMapping`, etc.)
---
## Use Cases
- Each use case is a **single class with one public method** named `execute(...)`.
- Annotate with `@Service` and `@AllArgsConstructor` — no other Spring annotations.
- Use constructor injection only. Never use `@Autowired` on fields.
- Inject domain repository interfaces (ports), never JPA repositories.
- Generate new IDs with `UUID.randomUUID().toString()`.
- Naming: `<Verb><Entity>UseCase` (e.g., `CreateRaceUseCase`, `UpdateRaceStatusUseCase`).
```java
@Service
@AllArgsConstructor
public class CreateRaceUseCase {
private final RaceRepository raceRepository;
public Race execute(CreateRaceCommand command) {
Race race = Race.builder()
.id(UUID.randomUUID().toString())
.name(command.getName())
// ... map remaining fields
.build();
return raceRepository.save(race);
}
}
```
---
## Commands
- Commands are plain data holders with **no business logic**.
- Annotate with `@Data`, `@NoArgsConstructor`, `@AllArgsConstructor` (Lombok).
- No validation annotations unless explicitly requested.
- Naming: `<Verb><Entity>Command` (e.g., `CreateRaceCommand`, `AddSegmentCommand`).
```java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateRaceCommand {
private String name;
private String location;
}
```
---
## Domain Exceptions
- Extend `RuntimeException`.
- Belong in this package (not in domain).
- Naming: `<Entity>NotFoundException` (e.g., `RaceNotFoundException`, `RacePlanNotFoundException`).
```java
public class RaceNotFoundException extends RuntimeException {
public RaceNotFoundException(String id) {
super("Race not found with id: " + id);
}
}
```
Yet another important aspect is defined in infrastructure.instructions.md.
---
applyTo: "src/main/java/com/ultratrail/infrastructure/**"
---
# Infrastructure Layer Rules
You are generating code in the **infrastructure layer** (`com.ultratrail.infrastructure`).
This layer is the outbound adapter: it implements domain ports using Spring Data JPA.
## Strict Rules
### Allowed imports
- `com.ultratrail.domain.*` (to implement repository ports)
- `org.springframework.data.jpa.repository.*`
- `org.springframework.stereotype.Component`
- `jakarta.persistence.*`
- `lombok.*`
- `java.*`
### Forbidden imports – NEVER add these
- `com.ultratrail.api.*`
- `com.ultratrail.application.*`
- `org.springframework.stereotype.Service`
- `org.springframework.web.*`
---
## Repository Adapters
- Annotate with `@Component` and `@AllArgsConstructor`.
- Each adapter **implements exactly one domain repository interface**.
- Delegate every method to a Spring Data JPA repository.
- Convert between domain objects and JPA entities using `fromDomain()` and `toDomain()`.
- JPA entities must **never** appear in method signatures exposed to other layers.
- Naming: `<Entity>RepositoryAdapter` (e.g., `RaceRepositoryAdapter`).
```java
@Component
@AllArgsConstructor
public class RaceRepositoryAdapter implements RaceRepository {
private final JpaRaceRepository jpaRaceRepository;
@Override
public Race save(Race race) {
return jpaRaceRepository.save(RaceEntity.fromDomain(race)).toDomain();
}
@Override
public Optional<Race> findById(String id) {
return jpaRaceRepository.findById(id).map(RaceEntity::toDomain);
}
@Override
public List<Race> findAll() {
return jpaRaceRepository.findAll().stream()
.map(RaceEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public void delete(String id) {
jpaRaceRepository.deleteById(id);
}
}
```
---
## Spring Data JPA Repositories
- Interface extending `JpaRepository<Entity, String>`.
- Naming: `Jpa<Entity>Repository` (e.g., `JpaRaceRepository`).
- Only add custom query methods as needed.
```java
public interface JpaRaceRepository extends JpaRepository<RaceEntity, String> {
List<RaceEntity> findByStatus(Race.RaceStatus status);
}
```
---
## JPA Entities
- Annotate with `@Entity`, `@Table`, `@Data`, `@Builder`, `@NoArgsConstructor`, `@AllArgsConstructor`.
- Must include `fromDomain(Domain d)` static factory method and `toDomain()` instance method.
- JPA entities stay confined to this package — never returned from use cases or controllers.
- Naming: `<Entity>Entity` (e.g., `RaceEntity`).
```java
@Entity
@Table(name = "races")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RaceEntity {
@Id
private String id;
@Column(nullable = false)
private String name;
public static RaceEntity fromDomain(Race race) {
return RaceEntity.builder()
.id(race.getId())
.name(race.getName())
.build();
}
public Race toDomain() {
return Race.builder()
.id(this.id)
.name(this.name)
.build();
}
}
```
The API exposes our functionality as RESTful HTTP endpoints, described in api.instructions.md.
---
applyTo: "src/main/java/com/ultratrail/api/**"
---
# API Layer Rules
You are generating code in the **API layer** (`com.ultratrail.api`).
This layer is the inbound adapter: it receives HTTP requests and delegates work to use cases.
## Strict Rules
### Allowed imports
- `com.ultratrail.application.*` (use cases and commands)
- `com.ultratrail.domain.*` (domain entities used only as response types)
- `org.springframework.web.*`
- `org.springframework.http.*`
- `lombok.AllArgsConstructor`
- `java.*`
### Forbidden imports – NEVER add these
- `com.ultratrail.infrastructure.*`
- `jakarta.persistence.*` / `javax.persistence.*`
- Any JPA repository interface
- `org.springframework.stereotype.Service`
---
## Controllers
- Annotate with `@RestController`, `@RequestMapping`, `@AllArgsConstructor`.
- Inject **only use cases** — never repositories, domain services, or infrastructure classes.
- Every HTTP method calls exactly one use case via `.execute(...)`.
- Return `ResponseEntity<T>` for every handler method.
- No business logic inside controllers. Mapping between HTTP and domain happens only by passing commands.
- Use `@PathVariable`, `@RequestBody`, `@RequestParam` for input binding.
- Naming: `<Entity>Controller` (e.g., `RaceController`, `RaceSegmentController`).
```java
@RestController
@RequestMapping("/races")
@AllArgsConstructor
public class RaceController {
private final CreateRaceUseCase createRaceUseCase;
private final GetRaceUseCase getRaceUseCase;
private final DeleteRaceUseCase deleteRaceUseCase;
@PostMapping
public ResponseEntity<Race> createRace(@RequestBody CreateRaceCommand command) {
return ResponseEntity.status(HttpStatus.CREATED).body(createRaceUseCase.execute(command));
}
@GetMapping("/{id}")
public ResponseEntity<Race> getRace(@PathVariable String id) {
return getRaceUseCase.execute(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteRace(@PathVariable String id) {
deleteRaceUseCase.execute(id);
return ResponseEntity.noContent().build();
}
}
```
---
## Controller Tests
Use `@WebMvcTest` with mocked use cases. Never load the full application context for controller tests.
```java
@WebMvcTest(RaceController.class)
class RaceControllerTest {
@Autowired MockMvc mockMvc;
@MockBean CreateRaceUseCase createRaceUseCase;
// ...
}
```
Besides all the application layers, we also define a testing aspect, which is described in testing.instructions.md.
---
applyTo: "src/test/java/com/ultratrail/**"
---
# Testing Rules
You are generating test code for the Ultra Trail Manager project.
Follow these rules to ensure tests are focused, fast, and do not violate the hexagonal architecture.
---
## Use Case Tests (Application Layer)
- Use **plain JUnit 5 + Mockito** — no Spring context.
- Annotate with `@ExtendWith(MockitoExtension.class)`.
- Mock domain repository interfaces with `@Mock`. Never mock JPA repositories.
- Instantiate use cases manually via constructor.
- Naming: `<UseCaseClass>Test` (e.g., `CreateRaceUseCaseTest`).
```java
@ExtendWith(MockitoExtension.class)
class CreateRaceUseCaseTest {
@Mock
private RaceRepository raceRepository;
private CreateRaceUseCase useCase;
@BeforeEach
void setUp() {
useCase = new CreateRaceUseCase(raceRepository);
}
@Test
void shouldCreateRaceWithPlannedStatus() {
CreateRaceCommand command = new CreateRaceCommand("UTMB", "Chamonix");
when(raceRepository.save(any(Race.class))).thenAnswer(inv -> inv.getArgument(0));
Race result = useCase.execute(command);
assertNotNull(result.getId());
assertEquals(Race.RaceStatus.PLANNED, result.getStatus());
verify(raceRepository).save(any(Race.class));
}
}
```
---
## Controller Tests (API Layer)
- Use `@WebMvcTest(<Controller>.class)` — loads only the web layer.
- Mock all use cases with `@MockBean`.
- Inject `MockMvc` with `@Autowired`.
- Do **not** use `@SpringBootTest` for controller tests.
```java
@WebMvcTest(RaceController.class)
class RaceControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private CreateRaceUseCase createRaceUseCase;
@Test
void shouldReturnCreatedStatusOnPost() throws Exception {
when(createRaceUseCase.execute(any())).thenReturn(Race.builder().id("1").build());
mockMvc.perform(post("/races")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"UTMB\"}"))
.andExpect(status().isCreated());
}
}
```
---
## Architecture Tests
- Use ArchUnit in `HexagonalArchitectureTest` to enforce all layer boundaries.
- These tests live in `src/test/java/com/ultratrail/architecture/`.
- Do **not** use `@SpringBootTest` here.
- Run as standard JUnit 5 tests (`@AnalyzeClasses` + `@ArchTest`).
---
## General Rules
- Do **not** use `@SpringBootTest` for unit tests. It loads the full application context and is too heavy.
- Test class must be in the same package as the class under test.
- One test class per production class.
- Test method names use `should<Behaviour>When<Condition>` format.
These files provide the necessary structure for our project, offering clear guidelines for our AI coding agents to work within.
Extending the Application
Now that we have our foundation in place, let’s put our ArchUnit tests and instruction files to the test. We’ve already established a solid structure with races, segments, and plans for each race. As we continue to grow our application, we’ll explore more features.
For instance, ultra races often take place far from home, spanning multiple days if the distance is significant, such as 100 miles or 160km. Planning accommodations as an athlete is crucial in these situations. Sometimes, the start and finish of a race can even be located in different areas.
To extend our application, I’ll send this prompt:
Extend the application by another feature. The feature is about accommodation. Each race needs accommodation possibilities that one can maintain in a CRUD manner. There could be a single accommodation for the duration of the race, and the days, before and after. But there could also be the case where an accommodation is taken before the race at the race start, and another one at the race finish location. Breakfast is an important option that should be made visible if available. Other than that the price and the distance to start or distance to the finish line are crucial. Another aspects is the number of guests. Sometimes you would like to travel with someone and need a double room. Sometimes a single room is enough and also cheaper.
How does CoPilot work? After submitting the prompt, CoPilot springs into action, analyzing the request and generating a list of potential next steps. In this case, it identifies six TODOs that can be explored further.
The AI agent then uses its existing knowledge to study these patterns and find ways to integrate them seamlessly:

CoPilot summarizes the changes by providing a report of all the passed tests, including the Hexagonal Architecture Test. This ensures that our codebase continues to adhere to the architecture we’ve established, ensuring stability and maintainability in the long run.

Finally, we can visually inspect our application to ensure everything is working as intended. For instance, let’s take a closer look at the Domain class:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Accommodation {
private String id;
private String raceId;
private String name;
private String location;
private AccommodationType type;
private LocalDate checkIn;
private LocalDate checkOut;
private Boolean breakfastIncluded;
private BigDecimal pricePerNight;
private BigDecimal distanceToStartKm;
private BigDecimal distanceToFinishKm;
private RoomType roomType;
private Integer numberOfGuests;
public enum AccommodationType {
RACE_START, RACE_FINISH, FULL_STAY
}
public enum RoomType {
SINGLE, DOUBLE
}
}
I like the categorisation in RACE_START, RACE_FINISH, and FULL_STAY. Additionally, the frontend has been extended (by a separate prompt) to display accommodation options, providing users with a more comprehensive view of their stay arrangements.

And there you have it! By leveraging ArchUnit tests and instruction files, we were able to extend our application with a new feature in just 10 minutes, without compromising its underlying structure or architecture. This approach has proven to be highly effective in promoting maintainability, scalability, and extensibility for future features.
Summary
As software engineers we ask ourselves how much we should vibe code? Additionally, we wonder what an application would look like if it were extended solely by AI over an extended period?
I believe that an application seriously deteriorates in quality over time if only extended by AI in a naive way without much guardrails. Understanding the architecture is more crucial than ever. But once that is defined well and put into mechanisms to verify, the engineer can focus on what really matters: the business logic and new features.
By striking a balance between human oversight and AI-assisted development, we can create applications that are both maintainable and scalable – a true win-win for software engineers.