CRUD Module Tutorial
This tutorial demonstrates building a simple User management system using the CRUD generator for straightforward resources that don’t require full hexagonal architecture.
When to Use CRUD vs. Hexagonal Architecture
Use make:crud for:
- Simple CRUD operations: Create, Read, Update, Delete without complex business logic
- Master data: Countries, categories, tags, settings
- Admin resources: User management, configuration panels
- Straightforward entities: Resources with minimal invariants
- Rapid development: Prototypes and MVPs
Use make:module for:
- Complex domain logic: Business rules, aggregates, invariants
- Event-driven features: Publishing and consuming domain events
- Multiple adapters: REST + messaging + batch processing
- CQRS patterns: Separate read and write models
- Strategic modules: Core business capabilities
The CRUD Pattern
The CRUD generator creates a traditional MVC structure:
User
├── Model (domain object)
├── Entity (JPA entity)
├── Repository (Spring Data JPA)
├── Mapper (domain ↔ entity conversion)
├── Service (business logic)
└── Controller (REST API)
This is simpler than hexagonal architecture but sufficient for many use cases.
Step 1: Initialize Spring-Hex
If you haven’t already initialized your project:
spring-hex init
Output:
Spring-Hex initialized successfully!
Configuration file created: .hex/config.yml
Base package: com.example.demo
You can now start generating components.
Step 2: Generate a CRUD Module
Generate a complete CRUD module for User management:
spring-hex make:crud User
Output:
Generated: src/main/java/com/example/demo/user/model/User.java
Generated: src/main/java/com/example/demo/user/entity/UserEntity.java
Generated: src/main/java/com/example/demo/user/repository/UserRepository.java
Generated: src/main/java/com/example/demo/user/mapper/UserMapper.java
Generated: src/main/java/com/example/demo/user/service/UserService.java
Generated: src/main/java/com/example/demo/user/web/UserController.java
User CRUD module created successfully!
6 files generated.
Step 3: Examine Generated Files
User Model (Domain Object)
// src/main/java/com/example/demo/user/model/User.java
package com.example.demo.user.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
private Long id;
private String username;
private String email;
private String firstName;
private String lastName;
private boolean active;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
The model represents the domain object without persistence concerns.
UserEntity (JPA Entity)
// src/main/java/com/example/demo/user/entity/UserEntity.java
package com.example.demo.user.entity;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
@Data
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false, unique = true)
private String email;
@Column(name = "first_name")
private String firstName;
@Column(name = "last_name")
private String lastName;
@Column(nullable = false)
private boolean active = true;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
The entity handles persistence with JPA annotations.
UserRepository (Spring Data JPA)
// src/main/java/com/example/demo/user/repository/UserRepository.java
package com.example.demo.user.repository;
import com.example.demo.user.entity.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
Optional<UserEntity> findByUsername(String username);
Optional<UserEntity> findByEmail(String email);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}
The repository provides data access methods using Spring Data JPA.
UserMapper (Domain ↔ Entity Conversion)
// src/main/java/com/example/demo/user/mapper/UserMapper.java
package com.example.demo.user.mapper;
import com.example.demo.user.entity.UserEntity;
import com.example.demo.user.model.User;
import org.springframework.stereotype.Component;
@Component
public class UserMapper {
public User toDomain(UserEntity entity) {
if (entity == null) {
return null;
}
return User.builder()
.id(entity.getId())
.username(entity.getUsername())
.email(entity.getEmail())
.firstName(entity.getFirstName())
.lastName(entity.getLastName())
.active(entity.isActive())
.createdAt(entity.getCreatedAt())
.updatedAt(entity.getUpdatedAt())
.build();
}
public UserEntity toEntity(User user) {
if (user == null) {
return null;
}
UserEntity entity = new UserEntity();
entity.setId(user.getId());
entity.setUsername(user.getUsername());
entity.setEmail(user.getEmail());
entity.setFirstName(user.getFirstName());
entity.setLastName(user.getLastName());
entity.setActive(user.isActive());
return entity;
}
public void updateEntity(User user, UserEntity entity) {
if (user == null || entity == null) {
return;
}
entity.setUsername(user.getUsername());
entity.setEmail(user.getEmail());
entity.setFirstName(user.getFirstName());
entity.setLastName(user.getLastName());
entity.setActive(user.isActive());
}
}
The mapper converts between domain and persistence models.
UserService (Business Logic)
// src/main/java/com/example/demo/user/service/UserService.java
package com.example.demo.user.service;
import com.example.demo.user.entity.UserEntity;
import com.example.demo.user.mapper.UserMapper;
import com.example.demo.user.model.User;
import com.example.demo.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@Transactional
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
public User create(User user) {
if (userRepository.existsByUsername(user.getUsername())) {
throw new IllegalArgumentException("Username already exists: " + user.getUsername());
}
if (userRepository.existsByEmail(user.getEmail())) {
throw new IllegalArgumentException("Email already exists: " + user.getEmail());
}
UserEntity entity = userMapper.toEntity(user);
UserEntity savedEntity = userRepository.save(entity);
return userMapper.toDomain(savedEntity);
}
@Transactional(readOnly = true)
public Optional<User> findById(Long id) {
return userRepository.findById(id)
.map(userMapper::toDomain);
}
@Transactional(readOnly = true)
public Optional<User> findByUsername(String username) {
return userRepository.findByUsername(username)
.map(userMapper::toDomain);
}
@Transactional(readOnly = true)
public List<User> findAll() {
return userRepository.findAll().stream()
.map(userMapper::toDomain)
.collect(Collectors.toList());
}
public User update(Long id, User user) {
UserEntity entity = userRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + id));
userMapper.updateEntity(user, entity);
UserEntity updatedEntity = userRepository.save(entity);
return userMapper.toDomain(updatedEntity);
}
public void delete(Long id) {
if (!userRepository.existsById(id)) {
throw new IllegalArgumentException("User not found: " + id);
}
userRepository.deleteById(id);
}
}
The service layer handles business logic and transaction management.
UserController (REST API)
// src/main/java/com/example/demo/user/web/UserController.java
package com.example.demo.user.web;
import com.example.demo.user.model.User;
import com.example.demo.user.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping
public ResponseEntity<User> createUser(@RequestBody User user) {
User createdUser = userService.create(user);
return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
}
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping
public ResponseEntity<List<User>> getAllUsers() {
List<User> users = userService.findAll();
return ResponseEntity.ok(users);
}
@PutMapping("/{id}")
public ResponseEntity<User> updateUser(
@PathVariable Long id,
@RequestBody User user) {
User updatedUser = userService.update(id, user);
return ResponseEntity.ok(updatedUser);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}
}
The controller exposes REST endpoints for the User resource.
Step 4: Default CRUD Path Structure
The CRUD generator uses these default paths (configurable in .hex/config.yml):
crud:
model: "{name}.model"
entity: "{name}.entity"
repository: "{name}.repository"
mapper: "{name}.mapper"
service: "{name}.service"
controller: "{name}.web"
The {name} placeholder is replaced with the lowercased entity name.
For spring-hex make:crud User, this generates:
com.example.demo/
└── user/
├── model/
│ └── User.java
├── entity/
│ └── UserEntity.java
├── repository/
│ └── UserRepository.java
├── mapper/
│ └── UserMapper.java
├── service/
│ └── UserService.java
└── web/
└── UserController.java
Step 5: Customizing CRUD Paths
You can customize the CRUD structure to match your conventions. Edit .hex/config.yml:
crud:
model: "modules.{name}.domain"
entity: "modules.{name}.persistence"
repository: "modules.{name}.persistence"
mapper: "modules.{name}.infrastructure"
service: "modules.{name}.application"
controller: "modules.{name}.api"
After this change, spring-hex make:crud Product generates:
com.example.demo/
└── modules/
└── product/
├── domain/
│ └── Product.java
├── persistence/
│ ├── ProductEntity.java
│ └── ProductRepository.java
├── infrastructure/
│ └── ProductMapper.java
├── application/
│ └── ProductService.java
└── api/
└── ProductController.java
Step 6: Partial Generation Options
Skip specific components using flags:
Skip Model Generation
spring-hex make:crud Category --no-model
Generates entity, repository, service, and controller but no separate domain model. The entity serves as both persistence and domain model.
Skip Service Layer
spring-hex make:crud Tag --no-service
Generates model, entity, repository, and controller. The controller calls the repository directly.
Combined Options
spring-hex make:crud Setting --no-model --no-service
Generates only entity, repository, and controller for the simplest CRUD setup.
Step 7: Testing the Generated Code
Create a test for the UserService:
// src/test/java/com/example/demo/user/service/UserServiceTest.java
package com.example.demo.user.service;
import com.example.demo.user.entity.UserEntity;
import com.example.demo.user.mapper.UserMapper;
import com.example.demo.user.model.User;
import com.example.demo.user.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private UserMapper userMapper;
@InjectMocks
private UserService userService;
@Test
void create_shouldSaveUser_whenValidUser() {
// Given
User user = User.builder()
.username("johndoe")
.email("john@example.com")
.build();
UserEntity entity = new UserEntity();
UserEntity savedEntity = new UserEntity();
savedEntity.setId(1L);
when(userRepository.existsByUsername("johndoe")).thenReturn(false);
when(userRepository.existsByEmail("john@example.com")).thenReturn(false);
when(userMapper.toEntity(user)).thenReturn(entity);
when(userRepository.save(entity)).thenReturn(savedEntity);
when(userMapper.toDomain(savedEntity)).thenReturn(user);
// When
User result = userService.create(user);
// Then
assertThat(result).isEqualTo(user);
verify(userRepository).save(entity);
}
@Test
void create_shouldThrowException_whenUsernameExists() {
// Given
User user = User.builder()
.username("existing")
.email("new@example.com")
.build();
when(userRepository.existsByUsername("existing")).thenReturn(true);
// When/Then
assertThatThrownBy(() -> userService.create(user))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Username already exists");
verify(userRepository, never()).save(any());
}
}
Step 8: API Testing
Test the REST endpoints:
# Create user
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{
"username": "johndoe",
"email": "john@example.com",
"firstName": "John",
"lastName": "Doe",
"active": true
}'
# Get user by ID
curl http://localhost:8080/api/users/1
# Get all users
curl http://localhost:8080/api/users
# Update user
curl -X PUT http://localhost:8080/api/users/1 \
-H "Content-Type: application/json" \
-d '{
"username": "johndoe",
"email": "john.doe@example.com",
"firstName": "Jonathan",
"lastName": "Doe",
"active": true
}'
# Delete user
curl -X DELETE http://localhost:8080/api/users/1
Step 9: Adding Validation
Enhance the model with Bean Validation:
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
private Long id;
@NotBlank(message = "Username is required")
@Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
private String username;
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
private String email;
private String firstName;
private String lastName;
private boolean active;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
Enable validation in the controller:
import jakarta.validation.Valid;
@PostMapping
public ResponseEntity<User> createUser(@Valid @RequestBody User user) {
User createdUser = userService.create(user);
return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
}
When to Graduate to Hexagonal Architecture
Consider migrating from CRUD to hexagonal when:
- Business logic complexity increases: Validation rules, state transitions, invariants
- Multiple adapters needed: REST + GraphQL + messaging + scheduled jobs
- Domain events required: Publish events for other bounded contexts
- Testing becomes difficult: Need to mock infrastructure in domain tests
- Team grows: Clear boundaries help with parallel development
Migration path:
# 1. Extract domain logic to a proper aggregate
# 2. Generate hexagonal module
spring-hex make:module User
# 3. Migrate business logic from UserService to command handlers
# 4. Keep existing controller temporarily, redirect to command bus
# 5. Replace controller with new hexagonal controller
# 6. Remove old CRUD files
Key Takeaways
- Use CRUD for simple resources without complex domain logic
- The CRUD generator creates a complete MVC stack in seconds
- Customize paths in
.hex/config.ymlto match your conventions - Use
--no-modeland--no-serviceflags for simpler structures - CRUD and hexagonal modules can coexist in the same project
- Graduate to hexagonal architecture when domain complexity warrants it
Next Steps
- Add validation constraints to your models
- Implement pagination for list endpoints
- Add filtering and sorting capabilities
- Create integration tests with TestContainers
- Add API documentation with SpringDoc OpenAPI
- Implement soft deletes instead of hard deletes