
Spring Boot Crud Patterns
- 1.7k installs
- 311 repo stars
- Updated June 22, 2026
- giuseppe-trisciuoglio/developer-kit
Spring Boot 3 CRUD pattern generator.
About
The spring-boot-crud-patterns skill delivers complete CRUD workflows for Spring Boot 3.5 plus using feature-focused architecture. Creates domain aggregates JPA repositories application services REST controllers DTO records and validation gates across domain application presentation infrastructure packages. Workflow establishes feature structure defines entity invariants exposes repository ports implements services and controllers with pagination support. Use implement Spring CRUD controller create endpoint add JPA entity refine repository map DTOs diagnose transaction boundaries in Java backend services. Feature packages domain application presentation infra. Entity invariants via factory methods. Repository ports without framework in domain. REST controllers with DTO validation. Validation gates before each workflow step. Spring Boot 3 CRUD pattern generator. User asks Spring Boot CRUD controller entity.
- Feature packages domain application presentation infra.
- Entity invariants via factory methods.
- Repository ports without framework in domain.
- REST controllers with DTO validation.
- Validation gates before each workflow step.
Spring Boot Crud Patterns by the numbers
- 1,721 all-time installs (skills.sh)
- +59 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #269 of 4,386 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
spring-boot-crud-patterns capabilities & compatibility
- Capabilities
- crud scaffolding · ddd packages
- Use cases
- api development
npx skills add https://github.com/giuseppe-trisciuoglio/developer-kit --skill spring-boot-crud-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.7k |
|---|---|
| repo stars | ★ 311 |
| Security audit | 3 / 3 scanners passed |
| Last updated | June 22, 2026 |
| Repository | giuseppe-trisciuoglio/developer-kit ↗ |
Spring CRUD endpoint for feature?
Generate Spring Boot 3 CRUD features with DDD-style packages JPA aggregates DTOs controllers and validation gates.
Who is it for?
Java backend developers.
Skip if: Non-Spring stacks.
When should I use this skill?
User asks Spring Boot CRUD controller entity.
What you get
Feature-aligned CRUD service and REST API.
- Entity classes
- REST controllers
- DTOs with validation
By the numbers
- Uses 4 standard HTTP status codes: POST 201, GET 200, PUT/PATCH 200, DELETE 204
Files
Spring Boot CRUD Patterns
Overview
Provides complete CRUD workflows for Spring Boot 3.5+ services using feature-focused architecture. Creates and validates domain aggregates, JPA repositories, application services, and REST controllers with proper separation of concerns. Defer detailed code listings to reference files for progressive disclosure.
When to Use
- Create REST endpoints for create/read/update/delete workflows backed by Spring Data JPA.
- Implement feature packages following DDD-inspired architecture with aggregates, repositories, and application services.
- Define DTO records, request validation, and controller mappings for external clients.
- Diagnose CRUD regressions, repository contracts, or transaction boundaries in existing Spring Boot services.
- Trigger phrases: "implement Spring CRUD controller", "create an endpoint", "add database entity", "refine feature-based repository", "map DTOs for JPA aggregate", "add pagination to REST list endpoint".
Instructions
Follow this streamlined workflow to deliver feature-aligned CRUD services with explicit validation gates:
1. Establish Feature Structure
Create feature/<name>/ directories with domain, application, presentation, and infrastructure subpackages. Validate: Verify directory structure matches the feature boundary before proceeding.
2. Define Domain Model
Create entity classes with invariants enforced through factory methods (create, update). Keep domain logic framework-free. Validate: Assert all invariants are covered by unit tests before advancing.
3. Expose Domain Ports
Declare repository interfaces in domain/repository describing persistence contracts without implementation details. Validate: Confirm interface signatures match domain operations.
4. Provide Infrastructure Adapter
Create JPA entities in infrastructure/persistence that map to domain models. Implement Spring Data repositories. Validate: Run @DataJpaTest to verify entity mapping and repository integration.
5. Implement Application Services
Create @Transactional service classes that orchestrate domain operations and DTO mapping. Validate: Ensure transaction boundaries are correct and optimistic locking is applied where needed.
6. Define DTOs and Controllers
Use Java records for API contracts with jakarta.validation annotations. Map REST endpoints with proper status codes. Validate: Test validation constraints and verify HTTP status codes (201 POST, 200 GET, 204 DELETE).
7. Validate and Deploy
Run integration tests with Testcontainers. Verify migrations (Liquibase/Flyway) mirror the aggregate schema. Validate: Execute full test suite before deployment; confirm schema migration scripts are applied.
See references/examples-product-feature.md for complete code aligned with each step.
Examples
Java Code Example: Product Feature
// feature/product/domain/Product.java
package com.example.product.domain;
import java.math.BigDecimal;
import java.time.Instant;
public record Product(
String id,
String name,
String description,
BigDecimal price,
int stock,
Instant createdAt,
Instant updatedAt
) {
public static Product create(String name, String desc, BigDecimal price, int stock) {
if (name == null || name.isBlank()) throw new IllegalArgumentException("Name required");
if (price == null || price.compareTo(BigDecimal.ZERO) < 0) throw new IllegalArgumentException("Invalid price");
return new Product(null, name.trim(), desc, price, stock, Instant.now(), null);
}
public Product withPrice(BigDecimal newPrice) {
return new Product(id, name, description, newPrice, stock, createdAt, Instant.now());
}
}// feature/product/domain/repository/ProductRepository.java
package com.example.product.domain.repository;
import com.example.product.domain.Product;
import java.util.Optional;
public interface ProductRepository {
Product save(Product product);
Optional<Product> findById(String id);
void deleteById(String id);
}// feature/product/infrastructure/persistence/ProductJpaEntity.java
package com.example.product.infrastructure.persistence;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
@Entity @Table(name = "products")
public class ProductJpaEntity {
@Id @GeneratedValue(strategy = GenerationType.UUID)
private String id;
private String name;
private String description;
private BigDecimal price;
private int stock;
private Instant createdAt;
private Instant updatedAt;
// getters, setters, constructor from domain (omitted for brevity)
}// feature/product/infrastructure/persistence/JpaProductRepository.java
package com.example.product.infrastructure.persistence;
import com.example.product.domain.Product;
import com.example.product.domain.repository.ProductRepository;
import org.springframework.stereotype.Repository;
@Repository
public class JpaProductRepository implements ProductRepository {
private final SpringDataProductRepository springData;
public JpaProductRepository(SpringDataProductRepository springData) {
this.springData = springData;
}
@Override
public Product save(Product product) {
ProductJpaEntity entity = toEntity(product);
ProductJpaEntity saved = springData.save(entity);
return toDomain(saved);
}
// findById, deleteById implementations...
}// feature/product/presentation/rest/ProductController.java
package com.example.product.presentation.rest;
import com.example.product.domain.Product;
import com.example.product.domain.repository.ProductRepository;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController @RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) { this.service = service; }
@PostMapping
public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest req) {
Product product = service.create(req.toDomain());
return ResponseEntity.status(201).body(ProductResponse.from(product));
}
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getById(@PathVariable String id) {
return service.findById(id)
.map(p -> ResponseEntity.ok(ProductResponse.from(p)))
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.deleteById(id);
return ResponseEntity.noContent().build();
}
// record DTOs
public record CreateProductRequest(
@NotBlank String name,
String description,
@NotNull @DecimalMin("0.01") java.math.BigDecimal price,
@Min(0) int stock
) {
Product toDomain() { return Product.create(name, description, price, stock); }
}
public record ProductResponse(String id, String name, java.math.BigDecimal price) {
static ProductResponse from(Product p) { return new ProductResponse(p.id(), p.name(), p.price()); }
}
}JSON Input/Output Examples
Create Request:
{
"name": "Wireless Keyboard",
"description": "Ergonomic keyboard",
"price": 79.99,
"stock": 50
}Created Response (201):
{
"id": "prod-123",
"name": "Wireless Keyboard",
"price": 79.99,
"_links": { "self": "/api/products/prod-123" }
}Paginated List Request:
curl "http://localhost:8080/api/products?page=0&size=10&sort=name,asc"Best Practices
- Co-locate domain, application, and presentation code per aggregate within feature packages.
- Use Java records for immutable DTOs; convert domain types at the service boundary.
- Apply transactions and optimistic locking for write operations.
- Normalize pagination defaults (page, size, sort) and document query parameters.
- Log CRUD lifecycle events (create, update, delete) at info level with structured audit trails.
- Surface health and metrics through Spring Boot Actuator; monitor throughput and error rates.
Constraints and Warnings
- Never expose JPA entities directly in controllers to prevent lazy-loading leaks and serialization issues.
- Never mix field injection with constructor injection; maintain immutability for testability.
- Never embed business logic in controllers or repository adapters; keep it in domain/application layers.
- Always validate input aggressively to prevent constraint violations and produce consistent error payloads.
- Always ensure migrations (Liquibase/Flyway) mirror aggregate evolution before deploying schema changes.
- Always run integration tests with Testcontainers before merging to prevent persistence regressions.
References
- HTTP methods, annotations, DTO patterns
- Progressive examples from starter to advanced
- Spring Boot official documentation
- CRUD generator script -
python scripts/generate_crud_boilerplate.py --spec entity.json --package com.example.product --output ./generated
{
"entity": "Order",
"id": { "name": "id", "type": "Long", "generated": true },
"fields": [
{ "name": "orderNumber", "type": "String" },
{ "name": "total", "type": "BigDecimal" }
],
"relationships": [
{ "type": "ONE_TO_MANY", "name": "items", "target": "OrderItem", "mappedBy": "order" }
]
}
{
"entity": "Product",
"id": { "name": "id", "type": "Long", "generated": true },
"fields": [
{ "name": "name", "type": "String" },
{ "name": "price", "type": "BigDecimal" },
{ "name": "inStock", "type": "Boolean" }
]
}
CRUD Reference (Quick)
- Status codes: POST 201, GET 200, PUT/PATCH 200, DELETE 204.
- Validation: jakarta.validation annotations on DTOs.
- Repositories: feature-scoped interfaces + Spring Data adapters.
Product Feature Examples (Skeleton)
Feature structure:
- domain/model/Product.java
- domain/repository/ProductRepository.java
- infrastructure/persistence/ProductEntity.java
- infrastructure/persistence/ProductJpaRepository.java
- infrastructure/persistence/ProductRepositoryAdapter.java
- application/service/ProductService.java
- presentation/dto/ProductRequest.java
- presentation/dto/ProductResponse.java
- presentation/rest/ProductController.java
CRUD Generator Usage
Quick start:
python skills/spring-boot-crud-patterns/scripts/generate_crud_boilerplate.py \
--spec skills/spring-boot-crud-patterns/assets/specs/product.json \
--package com.example.product \
--output ./generated \
--templates-dir skills/spring-boot-crud-patterns/templates [--lombok]Spec (JSON/YAML):
- entity: PascalCase name (e.g., Product)
- id: { name, type (Long|UUID|...), generated: true|false }
- fields: array of { name, type }
- relationships: optional (currently model as FK ids in fields)
What gets generated:
- REST controller at /v1/{resources} with POST 201 + Location header
- Pageable list endpoint returning PageResponse<T>
- Application mapper (application/mapper/${Entity}Mapper) for DTO↔Domain
- Exception types: ${Entity}NotFoundException, ${Entity}ExistException + ${Entity}ExceptionHandler
- GlobalExceptionHandler with validation + DataIntegrityViolationException→409
DTOs:
- Request excludes id when id.generated=true
- Response always includes id
JPA entity:
@Id with@GeneratedValue(IDENTITY) for numeric generated ids
Notes:
- Provide all templates in templates/ (see templates/README.md)
- Use --lombok to add Lombok annotations without introducing blank lines between annotations
Spring Docs Pointers
- Spring Boot Reference Guide
- Spring Data JPA Reference
- Validation (Jakarta Validation)
package $package.presentation.rest;
$lombok_common_imports
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import $package.application.service.Create${entity}Service;
import $package.application.service.Get${entity}Service;
import $package.application.service.Update${entity}Service;
import $package.application.service.Delete${entity}Service;
import $package.application.service.List${entity}Service;
import $package.presentation.dto.$EntityRequest;
import $package.presentation.dto.$EntityResponse;
import $package.presentation.dto.PageResponse;
import org.springframework.data.domain.Pageable;
@RestController$controller_annotations_block
@RequestMapping("$base_path")
public class ${entity}Controller {
private final Create${entity}Service createService;
private final Get${entity}Service getService;
private final Update${entity}Service updateService;
private final Delete${entity}Service deleteService;
private final List${entity}Service listService;
$controller_constructor
@PostMapping
public ResponseEntity<$EntityResponse> create(@RequestBody @Valid $EntityRequest request) {
$EntityResponse created = createService.create(request);
return ResponseEntity.status(HttpStatus.CREATED)
.header("Location", "$base_path/" + created.$id_name())
.body(created);
}
@GetMapping("/{${id_name_lower}}")
public ResponseEntity<$EntityResponse> get(@PathVariable $id_type ${id_name_lower}) {
return ResponseEntity.ok(getService.get(${id_name_lower}));
}
@PutMapping("/{${id_name_lower}}")
public ResponseEntity<$EntityResponse> update(@PathVariable $id_type ${id_name_lower},
@RequestBody @Valid $EntityRequest request) {
return ResponseEntity.ok(updateService.update(${id_name_lower}, request));
}
@DeleteMapping("/{${id_name_lower}}")
public ResponseEntity<Void> delete(@PathVariable $id_type ${id_name_lower}) {
deleteService.delete(${id_name_lower});
return ResponseEntity.noContent().build();
}
@GetMapping
public ResponseEntity<PageResponse<$EntityResponse>> list(Pageable pageable) {
return ResponseEntity.ok(listService.list(pageable.getPageNumber(), pageable.getPageSize()));
}
}
package $package.application.service;
$lombok_common_imports
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import $package.domain.model.$entity;
import $package.domain.service.${entity}Service;
import $package.application.mapper.${entity}Mapper;
import $package.application.exception.${entity}ExistException;
import org.springframework.dao.DataIntegrityViolationException;
import $package.presentation.dto.$EntityRequest;
import $package.presentation.dto.$EntityResponse;
@Service$service_annotations_block
@Transactional
public class Create${entity}Service {
private final ${entity}Service ${entity_lower}Service;
private final ${entity}Mapper mapper;
$create_constructor
public $EntityResponse create($EntityRequest request) {
try {
$entity agg = mapper.toAggregate($create_id_arg, request);
agg = ${entity_lower}Service.save(agg);
return mapper.toResponse(agg);
} catch (DataIntegrityViolationException ex) {
throw new ${entity}ExistException("Duplicate $entity");
}
}
}
package $package.application.service;
$lombok_common_imports
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import $package.domain.service.${entity}Service;
import $package.application.exception.${entity}NotFoundException;
@Service$service_annotations_block
@Transactional
public class Delete${entity}Service {
private final ${entity}Service ${entity_lower}Service;
$delete_constructor
public void delete($id_type $id_name) {
if (!${entity_lower}Service.existsById($id_name)) {
throw new ${entity}NotFoundException($id_name);
}
${entity_lower}Service.deleteById($id_name);
}
}
package $package.domain.model;
$lombok_domain_imports
$extra_imports
$lombok_domain_annotations_block
public class $entity {
$domain_fields_decls
private $entity($domain_ctor_params) {
$domain_assigns
}
public static $entity create($domain_ctor_params) {
// TODO: add invariant checks
return new $entity($all_names_csv);
}
$domain_getters
}
package $package.domain.repository;
import java.util.Optional;
import java.util.List;
import $package.domain.model.$entity;
public interface ${entity}Repository {
$entity save($entity aggregate);
Optional<$entity> findById($id_type $id_name);
List<$entity> findAll(int page, int size);
void deleteById($id_type $id_name);
boolean existsById($id_type $id_name);
long count();
}
package $package.domain.service;
import java.util.List;
import java.util.Optional;
$lombok_common_imports
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import $package.domain.model.$entity;
import $package.domain.repository.${entity}Repository;
@Service$service_annotations_block
@Transactional
public class ${entity}Service {
private final ${entity}Repository repository;
$domain_service_constructor
public $entity save($entity aggregate) {
return repository.save(aggregate);
}
public Optional<$entity> findById($id_type $id_name) {
return repository.findById($id_name);
}
public List<$entity> findAll(int page, int size) {
return repository.findAll(page, size);
}
public void deleteById($id_type $id_name) {
repository.deleteById($id_name);
}
public boolean existsById($id_type $id_name) {
return repository.existsById($id_name);
}
public long count() {
return repository.count();
}
}
package $package.presentation.dto;
$extra_imports
public record $EntityRequest($dto_request_components) { }
package $package.presentation.dto;
$extra_imports
public record $EntityResponse($dto_response_components) { }
package $package.presentation.rest;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import $package.application.exception.${entity}NotFoundException;
import $package.application.exception.${entity}ExistException;
import $package.presentation.dto.ErrorResponse;
@RestControllerAdvice
public class ${entity}ExceptionHandler {
@ExceptionHandler(${entity}NotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(${entity}NotFoundException ex, org.springframework.web.context.request.WebRequest request) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
"Not Found",
ex.getMessage(),
request.getDescription(false).replaceFirst("uri=", "")
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(${entity}ExistException.class)
public ResponseEntity<ErrorResponse> handleExist(${entity}ExistException ex, org.springframework.web.context.request.WebRequest request) {
ErrorResponse error = new ErrorResponse(
HttpStatus.CONFLICT.value(),
"Conflict",
ex.getMessage(),
request.getDescription(false).replaceFirst("uri=", "")
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
}
package $package.presentation.dto;
public record ErrorResponse(
int status,
String error,
String message,
String path
) { }
package $package.application.exception;
public class ${entity}ExistException extends RuntimeException {
public ${entity}ExistException(String message) {
super(message);
}
public ${entity}ExistException($id_type $id_name) {
super("$entity already exists: " + $id_name);
}
}
package $package.application.service;
$lombok_common_imports
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import $package.domain.service.${entity}Service;
import $package.application.mapper.${entity}Mapper;
import $package.application.exception.${entity}NotFoundException;
import $package.presentation.dto.$EntityResponse;
@Service$service_annotations_block
@Transactional(readOnly = true)
public class Get${entity}Service {
private final ${entity}Service ${entity_lower}Service;
private final ${entity}Mapper mapper;
$get_constructor
public $EntityResponse get($id_type $id_name) {
return ${entity_lower}Service.findById($id_name)
.map(mapper::toResponse)
.orElseThrow(() -> new ${entity}NotFoundException($id_name));
}
}
package $package.presentation.rest;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.dao.DataIntegrityViolationException;
import $package.presentation.dto.ErrorResponse;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
MethodArgumentNotValidException ex, org.springframework.web.context.request.WebRequest request) {
String errors = ex.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(java.util.stream.Collectors.joining(", "));
ErrorResponse error = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"Validation Error",
"Validation failed: " + errors,
request.getDescription(false).replaceFirst("uri=", "")
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatusException(
ResponseStatusException ex, org.springframework.web.context.request.WebRequest request) {
ErrorResponse error = new ErrorResponse(
ex.getStatusCode().value(),
ex.getStatusCode().toString(),
ex.getReason(),
request.getDescription(false).replaceFirst("uri=", "")
);
return new ResponseEntity<>(error, ex.getStatusCode());
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrityViolation(
DataIntegrityViolationException ex, org.springframework.web.context.request.WebRequest request) {
ErrorResponse error = new ErrorResponse(
HttpStatus.CONFLICT.value(),
"Conflict",
ex.getMostSpecificCause() != null ? ex.getMostSpecificCause().getMessage() : ex.getMessage(),
request.getDescription(false).replaceFirst("uri=", "")
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
}
package $package.infrastructure.persistence;
import jakarta.persistence.*;
$extra_imports
$lombok_model_imports
import lombok.NoArgsConstructor;
@Entity
@Table(name = "$table_name")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ${entity}Entity {
$jpa_fields_decls
protected ${entity}Entity() { /* for JPA */ }
// Full constructor (optional, can be removed if not needed)
public ${entity}Entity($jpa_ctor_params) {
$jpa_assigns
}
// Lombok generates getters and setters automatically
}
package $package.application.service;
$lombok_common_imports
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import $package.domain.service.${entity}Service;
import $package.application.mapper.${entity}Mapper;
import $package.presentation.dto.$EntityResponse;
import $package.presentation.dto.PageResponse;
import java.util.List;
import java.util.stream.Collectors;
@Service$service_annotations_block
@Transactional(readOnly = true)
public class List${entity}Service {
private final ${entity}Service ${entity_lower}Service;
private final ${entity}Mapper mapper;
$list_constructor
public PageResponse<$EntityResponse> list(int page, int size) {
List<$EntityResponse> content = ${entity_lower}Service.findAll(page, size)
.stream()
.map(mapper::toResponse)
.collect(Collectors.toList());
long total = ${entity_lower}Service.count();
int totalPages = (int) Math.ceil(total / (double) size);
return new PageResponse<>(content, page, size, total, totalPages);
}
}
package $package.application.mapper;
import $package.domain.model.$entity;
import $package.presentation.dto.$dto_request;
import $package.presentation.dto.$dto_response;
public class ${entity}Mapper {
public $entity toAggregate($id_type id, $dto_request request) {
return $entity.create($mapper_create_args);
}
public $dto_response toResponse($entity a) {
return new $dto_response($list_map_response_args);
}
}
package $package.application.exception;
public class ${entity}NotFoundException extends RuntimeException {
public ${entity}NotFoundException($id_type $id_name) {
super("$entity not found: " + $id_name);
}
}
package $package.presentation.dto;
public record PageResponse<T>(
java.util.List<T> content,
int page,
int size,
long totalElements,
int totalPages
) { }
package $package.infrastructure.persistence;
$lombok_common_imports
import java.util.Optional;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.data.domain.PageRequest;
import $package.domain.model.$entity;
import $package.domain.repository.${entity}Repository;
import org.springframework.stereotype.Component;
@Component$adapter_annotations_block
public class ${entity}RepositoryAdapter implements ${entity}Repository {
private final ${entity}JpaRepository jpa;
$adapter_constructor
@Override
public $entity save($entity a) {
${entity}Entity e = new ${entity}Entity($adapter_to_entity_args);
e = jpa.save(e);
return $entity.create($adapter_to_domain_args);
}
@Override
public Optional<$entity> findById($id_type $id_name) {
return jpa.findById($id_name).map(e -> $entity.create($adapter_to_domain_args));
}
@Override
public List<$entity> findAll(int page, int size) {
return jpa.findAll(PageRequest.of(page, size))
.stream()
.map(e -> $entity.create($adapter_to_domain_args))
.collect(Collectors.toList());
}
@Override
public void deleteById($id_type $id_name) {
jpa.deleteById($id_name);
}
@Override
public boolean existsById($id_type $id_name) {
return jpa.existsById($id_name);
}
@Override
public long count() {
return jpa.count();
}
}
CRUD Generator Templates
You must provide all templates (.tpl) required by the generator; there is no fallback.
How it works:
- The generator loads .tpl files from this directory (or a directory passed via --templates-dir).
- It uses Python string.Template placeholders (e.g., $package, $entity, $id_type, $id_name, $id_name_lower, $base_path, $dto_request, $dto_response).
- If any template is missing or fails to render, generation fails.
Required template filenames:
- DomainModel.java.tpl
- DomainRepository.java.tpl
- JpaEntity.java.tpl
- SpringDataRepository.java.tpl
- PersistenceAdapter.java.tpl
- CreateService.java.tpl
- GetService.java.tpl
- UpdateService.java.tpl
- DeleteService.java.tpl
- ListService.java.tpl
- Mapper.java.tpl
- DtoRequest.java.tpl
- DtoResponse.java.tpl
- PageResponse.java.tpl
- ErrorResponse.java.tpl
- Controller.java.tpl
- GlobalExceptionHandler.java.tpl
- EntityExceptionHandler.java.tpl
- NotFoundException.java.tpl
- ExistException.java.tpl
Tip: Start simple and expand over time; these files are your team’s baseline.
Conventions:
- Base path is versioned: /v1/{resources}
- POST returns 201 Created and sets Location: /v1/{resources}/{id}
- GET collection supports pagination via Pageable in controller and returns PageResponse<T>
- Application layer uses ${Entity}Mapper for DTO↔Domain and throws ${Entity}ExistException on duplicates
- Exceptions are mapped by GlobalExceptionHandler and ${Entity}ExceptionHandler
package $package.infrastructure.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ${entity}JpaRepository extends JpaRepository<${entity}Entity, $id_type> { }
package $package.application.service;
$lombok_common_imports
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import $package.domain.model.$entity;
import $package.domain.service.${entity}Service;
import $package.application.mapper.${entity}Mapper;
import $package.application.exception.${entity}ExistException;
import org.springframework.dao.DataIntegrityViolationException;
import $package.presentation.dto.$EntityRequest;
import $package.presentation.dto.$EntityResponse;
@Service$service_annotations_block
@Transactional
public class Update${entity}Service {
private final ${entity}Service ${entity_lower}Service;
private final ${entity}Mapper mapper;
$update_constructor
public $EntityResponse update($id_type $id_name, $EntityRequest request) {
try {
$entity agg = mapper.toAggregate($id_name, request);
agg = ${entity_lower}Service.save(agg);
return mapper.toResponse(agg);
} catch (DataIntegrityViolationException ex) {
throw new ${entity}ExistException("Duplicate $entity");
}
}
}
{
"entity": "Product",
"id": { "name": "id", "type": "Long", "generated": true },
"fields": [
{ "name": "name", "type": "String" },
{ "name": "price", "type": "BigDecimal" },
{ "name": "inStock", "type": "Boolean" }
],
"relationships": [
{ "name": "category", "type": "ONE_TO_ONE", "target": "Category", "joinColumn": "category_id", "optional": true },
{ "name": "reviews", "type": "ONE_TO_MANY", "target": "Review", "mappedBy": "product" },
{ "name": "tags", "type": "MANY_TO_MANY", "target": "Tag", "joinTable": { "name": "product_tag", "joinColumn": "product_id", "inverseJoinColumn": "tag_id" } }
]
}
{
"entity": "Product",
"id": { "name": "id", "type": "Long", "generated": true },
"fields": [
{ "name": "name", "type": "String" },
{ "name": "price", "type": "BigDecimal" },
{ "name": "inStock", "type": "Boolean" }
]
}
#!/usr/bin/env python3
"""
Spring Boot CRUD boilerplate generator
Given an entity spec, scaffold a feature-based CRUD template aligned with the
"Spring Boot CRUD Patterns" skill (domain/application/presentation/infrastructure).
Usage:
python skills/spring-boot-crud-patterns/scripts/generate_crud_boilerplate.py \
--spec entity.json --package com.example.product --output ./generated
Spec format (JSON preferred; YAML supported if PyYAML is installed and file ends with .yml/.yaml):
{
"entity": "Product",
"id": {"name": "id", "type": "Long", "generated": true},
"fields": [
{"name": "name", "type": "String"},
{"name": "price", "type": "BigDecimal"},
{"name": "inStock", "type": "Boolean"}
]
}
Notes:
- Generates a feature folder with domain/application/presentation/infrastructure subpackages
- Uses Java records for DTOs, constructor injection, @Transactional, and standard REST codes
- Keep output as a starting point; adapt to your conventions
"""
import argparse
import json
import os
import re
import sys
from textwrap import dedent
from string import Template
try:
import yaml # type: ignore
_HAS_YAML = True
except Exception:
_HAS_YAML = False
# ------------------------- Helpers -------------------------
JAVA_TYPE_IMPORTS = {
"BigDecimal": "import java.math.BigDecimal;",
"UUID": "import java.util.UUID;",
"LocalDate": "import java.time.LocalDate;",
"LocalDateTime": "import java.time.LocalDateTime;",
}
JPA_IMPORTS = dedent(
"""
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
"""
).strip()
COLLECTION_IMPORTS = "import java.util.Set;"
SPRING_IMPORTS = dedent(
"""
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
"""
).strip()
CONTROLLER_IMPORTS = dedent(
"""
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
"""
).strip()
REPOSITORY_IMPORTS = "import org.springframework.data.jpa.repository.JpaRepository;"
SUPPORTED_SIMPLE_TYPES = {
# primitive/object pairs default to wrapper types for null-safety in DTOs
"String": "String",
"Long": "Long",
"Integer": "Integer",
"Boolean": "Boolean",
"BigDecimal": "BigDecimal",
"UUID": "UUID",
"LocalDate": "LocalDate",
"LocalDateTime": "LocalDateTime",
}
def load_spec(spec_path: str) -> dict:
with open(spec_path, "r", encoding="utf-8") as f:
text = f.read()
if spec_path.endswith('.yml') or spec_path.endswith('.yaml'):
if not _HAS_YAML:
raise SystemExit("PyYAML not installed. Install with `pip install pyyaml` or provide JSON spec.")
return yaml.safe_load(text)
return json.loads(text)
def camel_to_snake(name: str) -> str:
import re as _re
s1 = _re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
return _re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
def lower_first(s: str) -> str:
return s[:1].lower() + s[1:] if s else s
def ensure_dir(path: str) -> None:
os.makedirs(path, exist_ok=True)
def write_file(path: str, content: str) -> None:
ensure_dir(os.path.dirname(path))
with open(path, "w", encoding="utf-8") as f:
f.write(content.rstrip() + "\n")
def write_file_if_absent(path: str, content: str) -> None:
if os.path.exists(path):
return
write_file(path, content)
def qualify_imports(types: list[str]) -> str:
imports = []
for t in types:
imp = JAVA_TYPE_IMPORTS.get(t)
if imp and imp not in imports:
imports.append(imp)
return "\n".join(imports)
def indent_block(s: str, n: int = 4) -> str:
prefix = " " * n
return "\n".join((prefix + line if line.strip() else line) for line in (s or "").splitlines())
# ------------------------- Template loading -------------------------
def load_template_text(templates_dir: str | None, filename: str) -> str | None:
if not templates_dir:
return None
candidate = os.path.join(templates_dir, filename)
if os.path.isfile(candidate):
with open(candidate, "r", encoding="utf-8") as f:
return f.read()
return None
def render_template_file(templates_dir: str | None, filename: str, placeholders: dict) -> str | None:
text = load_template_text(templates_dir, filename)
if text is None:
return None
try:
return Template(text).safe_substitute(placeholders).rstrip() + "\n"
except Exception:
# On any template error, fall back to defaults
return None
# ------------------------- Templates -------------------------
def tmpl_domain_model(pkg: str, entity: str, id: dict, fields: list[dict], use_lombok: bool = False) -> str:
used_types = {id["type"]} | {f["type"] for f in fields}
extra_imports = qualify_imports(sorted(used_types))
fields_src = []
for f in [id, *fields]:
fields_src.append(f" private final {f['type']} {f['name']};")
ctor_params = ", ".join([f"{f['type']} {f['name']}" for f in [id, *fields]])
assigns = "\n".join([f" this.{f['name']} = {f['name']};" for f in [id, *fields]])
lombok_import = "import lombok.Getter;" if use_lombok else ""
extra_imports_full = "\n".join(filter(None, [extra_imports, lombok_import]))
class_annot = "@Getter\n" if use_lombok else ""
getters = "\n".join([f" public {f['type']} {('get' + f['name'][0].upper() + f['name'][1:])}() {{ return {f['name']}; }}" for f in [id, *fields]]) if not use_lombok else ""
return dedent(f"""
package {pkg}.domain.model;
{extra_imports_full}
/**
* Domain aggregate for {entity}.
* Keep framework-free; capture invariants in factories/methods.
*/
{class_annot}public class {entity} {{
{os.linesep.join(fields_src)}
private {entity}({ctor_params}) {{
{assigns}
}}
public static {entity} create({ctor_params}) {{
// TODO: add invariant checks
return new {entity}({', '.join([f['name'] for f in [id, *fields]])});
}}
{getters}
}}
""")
def tmpl_domain_repository(pkg: str, entity: str, id_type: str) -> str:
return dedent(f"""
package {pkg}.domain.repository;
import java.util.Optional;
import java.util.List;
import {pkg}.domain.model.{entity};
public interface {entity}Repository {{
{entity} save({entity} aggregate);
Optional<{entity}> findById({id_type} id);
List<{entity}> findAll(int page, int size);
void deleteById({id_type} id);
boolean existsById({id_type} id);
long count();
}}
""")
def tmpl_jpa_entity(pkg: str, entity: str, id: dict, fields: list[dict], relationships: list[dict], use_lombok: bool = False) -> str:
used_types = {id["type"]} | {f["type"] for f in fields}
extra_imports = qualify_imports(sorted(used_types))
id_ann = "@GeneratedValue(strategy = GenerationType.IDENTITY)" if id["type"] == "Long" and id.get("generated", True) else ""
all_fields = [id, *fields]
fields_src = [
f" private {f['type']} {f['name']};" if f is id else f" @Column(nullable = false)\n private {f['type']} {f['name']};"
for f in all_fields
]
# Relationship fields and imports
rel_fields_src = []
target_imports = []
need_set = False
for r in relationships or []:
rtype = (r.get("type") or "").upper()
name = r.get("name")
target = r.get("target")
if not name or not target or rtype not in {"ONE_TO_ONE", "ONE_TO_MANY", "MANY_TO_MANY"}:
continue
target_import = f"import {pkg}.infrastructure.persistence.{target}Entity;"
if target_import not in target_imports:
target_imports.append(target_import)
annotations = []
if rtype == "ONE_TO_ONE":
mapped_by = r.get("mappedBy")
optional = r.get("optional", True)
if mapped_by:
annotations.append(f" @OneToOne(mappedBy = \"{mapped_by}\", fetch = FetchType.LAZY)")
else:
annotations.append(f" @OneToOne(fetch = FetchType.LAZY, optional = {str(optional).lower()})")
join_col = r.get("joinColumn")
if join_col:
annotations.append(f" @JoinColumn(name = \"{join_col}\")")
field_type = f"{target}Entity"
init = ""
elif rtype == "ONE_TO_MANY":
need_set = True
mapped_by = r.get("mappedBy")
if mapped_by:
annotations.append(f" @OneToMany(mappedBy = \"{mapped_by}\", fetch = FetchType.LAZY)")
else:
annotations.append(" @OneToMany(fetch = FetchType.LAZY)")
join_col = r.get("joinColumn")
if join_col:
annotations.append(f" @JoinColumn(name = \"{join_col}\")")
field_type = f"Set<{target}Entity>"
init = " = new java.util.LinkedHashSet<>()"
else: # MANY_TO_MANY
need_set = True
annotations.append(" @ManyToMany(fetch = FetchType.LAZY)")
jt = r.get("joinTable") or {}
jt_name = jt.get("name")
join_col = jt.get("joinColumn")
inv_join_col = jt.get("inverseJoinColumn")
if jt_name and join_col and inv_join_col:
annotations.append(
f" @JoinTable(name = \"{jt_name}\", joinColumns = @JoinColumn(name = \"{join_col}\"), inverseJoinColumns = @JoinColumn(name = \"{inv_join_col}\"))"
)
field_type = f"Set<{target}Entity>"
init = " = new java.util.LinkedHashSet<>()"
rel_fields_src.append("\n".join(annotations + [f" private {field_type} {name}{init};"]))
rel_block = ("\n" + os.linesep.join(rel_fields_src)) if rel_fields_src else ""
lombok_imports = ("\n".join([
"import lombok.Getter;",
"import lombok.Setter;",
"import lombok.NoArgsConstructor;",
"import lombok.AccessLevel;",
]) if use_lombok else "")
imports_block = "\n".join(filter(None, [JPA_IMPORTS, extra_imports, COLLECTION_IMPORTS if need_set else "", "\n".join(target_imports), lombok_imports]))
imports_block_indented = indent_block(imports_block)
rel_getters = os.linesep.join([
f" public {('Set<' + r['target'] + 'Entity>' if r['type'].upper() != 'ONE_TO_ONE' else r['target'] + 'Entity')} get{r['name'][0].upper() + r['name'][1:]}() {{ return {r['name']}; }}"
for r in (relationships or []) if r.get('name') and r.get('target') and r.get('type', '').upper() in {"ONE_TO_ONE", "ONE_TO_MANY", "MANY_TO_MANY"}
]) if not use_lombok else ""
rel_setters = os.linesep.join([
f" public void set{r['name'][0].upper() + r['name'][1:]}({('Set<' + r['target'] + 'Entity>' if r['type'].upper() != 'ONE_TO_ONE' else r['target'] + 'Entity')} {r['name']}) {{ this.{r['name']} = {r['name']}; }}"
for r in (relationships or []) if r.get('name') and r.get('target') and r.get('type', '').upper() in {"ONE_TO_ONE", "ONE_TO_MANY", "MANY_TO_MANY"}
]) if not use_lombok else ""
class_annots = ("\n".join([
"@Getter",
"@Setter",
"@NoArgsConstructor(access = AccessLevel.PROTECTED)",
]) + "\n") if use_lombok else ""
class_annots_block = indent_block(class_annots.strip()) if use_lombok else ""
fields_getters = os.linesep.join([f" public {f['type']} {('get' + f['name'][0].upper() + f['name'][1:])}() {{ return {f['name']}; }}" for f in all_fields]) if not use_lombok else ""
fields_setters = os.linesep.join([f" public void set{f['name'][0].upper() + f['name'][1:]}({f['type']} {f['name']}) {{ this.{f['name']} = {f['name']}; }}" for f in all_fields]) if not use_lombok else ""
return dedent(f"""
package {pkg}.infrastructure.persistence;
{imports_block}
@Entity
@Table(name = "{camel_to_snake(entity)}")
{class_annots}public class {entity}Entity {{
@Id
{id_ann}
private {id['type']} {id['name']};
{os.linesep.join(fields_src[1:])}
{rel_block}
{'' if use_lombok else f'protected {entity}Entity() {{ /* for JPA */ }}'}
public {entity}Entity({', '.join([f['type'] + ' ' + f['name'] for f in all_fields])}) {{
{os.linesep.join([f" this.{f['name']} = {f['name']};" for f in all_fields])}
}}
{fields_getters}
{fields_setters}
{rel_getters}
{rel_setters}
}}
""")
def tmpl_spring_data_repo(pkg: str, entity: str, id_type: str) -> str:
return dedent(f"""
package {pkg}.infrastructure.persistence;
{indent_block(REPOSITORY_IMPORTS)}
public interface {entity}JpaRepository extends JpaRepository<{entity}Entity, {id_type}> {{}}
""")
def tmpl_persistence_adapter(pkg: str, entity: str, id: dict, fields: list[dict], use_lombok: bool = False) -> str:
return dedent(f"""
package {pkg}.infrastructure.persistence;
import java.util.Optional;
import java.util.List;
import java.util.stream.Collectors;
import {pkg}.domain.model.{entity};
import {pkg}.domain.repository.{entity}Repository;
import org.springframework.stereotype.Component;
{('import lombok.RequiredArgsConstructor;' if use_lombok else '')}
@Component
{('@RequiredArgsConstructor' if use_lombok else '')}
public class {entity}RepositoryAdapter implements {entity}Repository {{
private final {entity}JpaRepository jpa;
{'' if use_lombok else f'public {entity}RepositoryAdapter({entity}JpaRepository jpa) {{\n this.jpa = jpa;\n }}'}
@Override
public {entity} save({entity} aggregate) {{
{entity}Entity e = toEntity(aggregate);
e = jpa.save(e);
return toDomain(e);
}}
@Override
public Optional<{entity}> findById({id['type']} id) {{
return jpa.findById(id).map(this::toDomain);
}}
@Override
public List<{entity}> findAll(int page, int size) {{
return jpa.findAll(org.springframework.data.domain.PageRequest.of(page, size))
.stream().map(this::toDomain).collect(java.util.stream.Collectors.toList());
}}
@Override
public void deleteById({id['type']} id) {{
jpa.deleteById(id);
}}
@Override
public boolean existsById({id['type']} id) {{
return jpa.existsById(id);
}}
@Override
public long count() {{
return jpa.count();
}}
private {entity}Entity toEntity({entity} a) {{
return new {entity}Entity({', '.join(['a.get' + id['name'][0].upper() + id['name'][1:] + '()'] + ['a.get' + f['name'][0].upper() + f['name'][1:] + '()' for f in fields])});
}}
private {entity} toDomain({entity}Entity e) {{
return {entity}.create({', '.join(['e.get' + id['name'][0].upper() + id['name'][1:] + '()'] + ['e.get' + f['name'][0].upper() + f['name'][1:] + '()' for f in fields])});
}}
}}
""")
def tmpl_application_service(pkg: str, entity: str, id: dict, fields: list[dict], use_lombok: bool = False) -> str:
lc_entity = lower_first(entity)
dto_req = f"{entity}Request"
dto_res = f"{entity}Response"
params = ", ".join([f"request.{f['name']}()" for f in [id, *fields]])
update_params = ", ".join([f"request.{f['name']}()" for f in [*fields]])
return dedent(f"""
package {pkg}.application.service;
{indent_block(SPRING_IMPORTS)}
import java.util.List;
import {pkg}.domain.model.{entity};
import {pkg}.domain.repository.{entity}Repository;
import {pkg}.presentation.dto.{dto_req};
import {pkg}.presentation.dto.{dto_res};
@Service
@Transactional
public class {entity}Service {{
private final {entity}Repository repository;
public {entity}Service({entity}Repository repository) {{
this.repository = repository;
}}
public {dto_res} create({dto_req} request) {{
{entity} {lc_entity} = {entity}.create({params});
{lc_entity} = repository.save({lc_entity});
return {dto_res}.from({lc_entity});
}}
@Transactional(readOnly = true)
public {dto_res} get({id['type']} id) {{
return repository.findById(id).map({dto_res}::from)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND));
}}
public {dto_res} update({dto_req} request) {{
// In a real app, load existing aggregate and apply changes
{entity} updated = {entity}.create(request.{id['name']}(), {update_params});
updated = repository.save(updated);
return {dto_res}.from(updated);
}}
public void delete({id['type']} id) {{
repository.deleteById(id);
}}
@Transactional(readOnly = true)
public java.util.List<{dto_res}> list(int page, int size) {{
return repository.findAll(page, size).stream().map({dto_res}::from).collect(java.util.stream.Collectors.toList());
}}
}}
""")
def tmpl_dto_request(pkg: str, entity: str, id: dict, fields: list[dict]) -> str:
used_types = {id["type"]} | {f["type"] for f in fields}
extra_imports = qualify_imports(sorted(used_types))
comps = ", ".join([f"{f['type']} {f['name']}" for f in [id, *fields]])
return dedent(f"""
package {pkg}.presentation.dto;
{extra_imports}
public record {entity}Request({comps}) {{ }}
""")
def tmpl_dto_response(pkg: str, entity: str, id: dict, fields: list[dict]) -> str:
used_types = {id["type"]} | {f["type"] for f in fields}
extra_imports = qualify_imports(sorted(used_types))
comps = ", ".join([f"{f['type']} {f['name']}" for f in [id, *fields]])
getters = ", ".join([f"aggregate.get{f['name'][0].upper() + f['name'][1:]}()" for f in [id, *fields]])
return dedent(f"""
package {pkg}.presentation.dto;
{indent_block(extra_imports)}
import {pkg}.domain.model.{entity};
public record {entity}Response({comps}) {{
public static {entity}Response from({entity} aggregate) {{
return new {entity}Response({getters});
}}
}}
""")
def tmpl_controller(pkg: str, entity: str, id: dict, use_lombok: bool = False) -> str:
base = f"/api/{camel_to_snake(entity)}s" # naive pluralization with 's'
dto_req = f"{entity}Request"
dto_res = f"{entity}Response"
var_name = lower_first(id['name'])
path_seg = "/{" + var_name + "}"
return dedent(f"""
package {pkg}.presentation.rest;
{indent_block(CONTROLLER_IMPORTS)}
import {pkg}.application.service.{entity}Service;
import {pkg}.presentation.dto.{dto_req};
import {pkg}.presentation.dto.{dto_res};
@RestController
@RequestMapping("{base}")
public class {entity}Controller {{
private final {entity}Service service;
public {entity}Controller({entity}Service service) {{
this.service = service;
}}
@PostMapping
public ResponseEntity<{dto_res}> create(@RequestBody @Valid {dto_req} request) {{
var created = service.create(request);
return ResponseEntity.status(201).body(created);
}}
@GetMapping("{path_seg}")
public ResponseEntity<{dto_res}> get(@PathVariable {id['type']} {var_name}) {{
return ResponseEntity.ok(service.get({var_name}));
}}
@PutMapping("{path_seg}")
public ResponseEntity<{dto_res}> update(@PathVariable {id['type']} {var_name},
@RequestBody @Valid {dto_req} request) {{
// In a real app: ensure path id == request id
return ResponseEntity.ok(service.update(request));
}}
@DeleteMapping("{path_seg}")
public ResponseEntity<Void> delete(@PathVariable {id['type']} {var_name}) {{
service.delete({var_name});
return ResponseEntity.noContent().build();
}}
@GetMapping
public ResponseEntity<java.util.List<{dto_res}>> list(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {{
return ResponseEntity.ok(service.list(page, size));
}}
}}
""")
# ------------------------- Main -------------------------
def main():
parser = argparse.ArgumentParser(description="Generate Spring Boot CRUD boilerplate from entity spec")
parser.add_argument("--spec", required=True, help="Path to entity spec (JSON or YAML)")
parser.add_argument("--package", required=True, help="Base package, e.g., com.example.product")
parser.add_argument("--output", default="./generated", help="Output root directory")
parser.add_argument("--lombok", action="store_true", help="Use Lombok annotations for getters/setters where applicable")
parser.add_argument("--templates-dir", help="Directory with override templates (*.tpl). If omitted, auto-detects ../templates relative to this script if present.")
args = parser.parse_args()
spec = load_spec(args.spec)
entity = spec.get("entity")
if not entity or not re.match(r"^[A-Z][A-Za-z0-9_]*$", entity):
raise SystemExit("Spec 'entity' must be a PascalCase identifier, e.g., 'Product'")
id_spec = spec.get("id") or {"name": "id", "type": "Long", "generated": True}
if id_spec["type"] not in SUPPORTED_SIMPLE_TYPES:
raise SystemExit(f"Unsupported id type: {id_spec['type']}")
fields = spec.get("fields", [])
relationships = spec.get("relationships", [])
for f in fields:
if f["type"] not in SUPPORTED_SIMPLE_TYPES:
raise SystemExit(f"Unsupported field type: {f['name']} -> {f['type']}")
feature_name = entity.lower()
base_pkg = args.package
out_root = os.path.abspath(args.output)
java_root = os.path.join(out_root, "src/main/java", base_pkg.replace(".", "/"))
# Paths
paths = {
"domain_model": os.path.join(java_root, "domain/model", f"{entity}.java"),
"domain_repo": os.path.join(java_root, "domain/repository", f"{entity}Repository.java"),
"domain_service": os.path.join(java_root, "domain/service", f"{entity}Service.java"),
"jpa_entity": os.path.join(java_root, "infrastructure/persistence", f"{entity}Entity.java"),
"spring_data_repo": os.path.join(java_root, "infrastructure/persistence", f"{entity}JpaRepository.java"),
"persistence_adapter": os.path.join(java_root, "infrastructure/persistence", f"{entity}RepositoryAdapter.java"),
"app_service_create": os.path.join(java_root, "application/service", f"Create{entity}Service.java"),
"app_service_get": os.path.join(java_root, "application/service", f"Get{entity}Service.java"),
"app_service_update": os.path.join(java_root, "application/service", f"Update{entity}Service.java"),
"app_service_delete": os.path.join(java_root, "application/service", f"Delete{entity}Service.java"),
"app_service_list": os.path.join(java_root, "application/service", f"List{entity}Service.java"),
"dto_req": os.path.join(java_root, "presentation/dto", f"{entity}Request.java"),
"dto_res": os.path.join(java_root, "presentation/dto", f"{entity}Response.java"),
"controller": os.path.join(java_root, "presentation/rest", f"{entity}Controller.java"),
"ex_not_found": os.path.join(java_root, "application/exception", f"{entity}NotFoundException.java"),
"ex_exist": os.path.join(java_root, "application/exception", f"{entity}ExistException.java"),
"entity_exception_handler": os.path.join(java_root, "presentation/rest", f"{entity}ExceptionHandler.java"),
}
# Resolve templates directory (required; no fallback to built-ins)
script_dir = os.path.dirname(os.path.abspath(__file__))
default_templates_dir = os.path.normpath(os.path.join(script_dir, "..", "templates"))
templates_dir = args.templates_dir or (default_templates_dir if os.path.isdir(default_templates_dir) else None)
if not templates_dir or not os.path.isdir(templates_dir):
raise SystemExit("Templates directory not found. Provide --templates-dir or create: " + default_templates_dir)
required_templates = [
"DomainModel.java.tpl",
"DomainRepository.java.tpl",
"DomainService.java.tpl",
"JpaEntity.java.tpl",
"SpringDataRepository.java.tpl",
"PersistenceAdapter.java.tpl",
"CreateService.java.tpl",
"GetService.java.tpl",
"UpdateService.java.tpl",
"DeleteService.java.tpl",
"ListService.java.tpl",
"DtoRequest.java.tpl",
"DtoResponse.java.tpl",
"Controller.java.tpl",
"NotFoundException.java.tpl",
"ExistException.java.tpl",
"EntityExceptionHandler.java.tpl",
]
missing = [name for name in required_templates if load_template_text(templates_dir, name) is None]
if missing:
raise SystemExit("Missing required templates: " + ", ".join(missing) + " in " + templates_dir)
# Build dynamic code fragments for templates
all_fields = [id_spec, *fields]
used_types = {f["type"] for f in all_fields}
extra_imports = qualify_imports(sorted(used_types))
def cap(s: str) -> str:
return s[:1].upper() + s[1:] if s else s
# Domain fragments
final_kw = "" if args.lombok else "final "
domain_fields_decls = "\n".join([f" private {final_kw}{f['type']} {f['name']};" for f in all_fields])
domain_ctor_params = ", ".join([f"{f['type']} {f['name']}" for f in all_fields])
domain_assigns = "\n".join([f" this.{f['name']} = {f['name']};" for f in all_fields])
domain_getters = ("\n".join([f" public {f['type']} get{cap(f['name'])}() {{ return {f['name']}; }}" for f in all_fields]) if not args.lombok else "")
model_constructor_block = (f" private {entity}({domain_ctor_params}) {{\n{domain_assigns}\n }}" if not args.lombok else "")
all_names_csv = ", ".join([f["name"] for f in all_fields])
# JPA fragments
def _jpa_field_decl(f: dict) -> str:
if f is id_spec:
lines = [" @Id"]
if bool(id_spec.get("generated", False)) and id_spec["type"] in ("Long", "Integer"):
lines.append(" @GeneratedValue(strategy = GenerationType.IDENTITY)")
lines.append(f" private {f['type']} {f['name']};")
return "\n".join(lines)
return f" @Column(nullable = false)\n private {f['type']} {f['name']};"
jpa_fields_decls = "\n".join([_jpa_field_decl(f) for f in all_fields])
jpa_ctor_params = domain_ctor_params
jpa_assigns = "\n".join([f" this.{f['name']} = {f['name']};" for f in all_fields])
jpa_getters_setters = "\n".join([
f" public {f['type']} get{cap(f['name'])}() {{ return {f['name']}; }}\n public void set{cap(f['name'])}({f['type']} {f['name']}) {{ this.{f['name']} = {f['name']}; }}" for f in all_fields
])
# DTO components
id_generated = bool(id_spec.get("generated", False))
dto_response_components = ", ".join([f"{f['type']} {f['name']}" for f in all_fields])
dto_request_fields = (fields if id_generated else all_fields)
dto_request_components = ", ".join([f"{f['type']} {f['name']}" for f in dto_request_fields])
# Mapping fragments
adapter_to_entity_args = ", ".join([f"a.get{cap(f['name'])}()" for f in all_fields])
adapter_to_domain_args = ", ".join([f"e.get{cap(f['name'])}()" for f in all_fields])
if id_generated:
request_all_args = ", ".join(["null", *[f"request.{f['name']}()" for f in fields]])
else:
request_all_args = ", ".join([f"request.{id_spec['name']}()", *[f"request.{f['name']}()" for f in fields]])
response_from_agg_args = ", ".join([f"agg.get{cap(f['name'])}()" for f in all_fields])
list_map_response_args = ", ".join([f"a.get{cap(f['name'])}()" for f in all_fields])
update_create_args = ", ".join([id_spec["name"], *[f"request.{f['name']}()" for f in fields]])
mapper_create_args = ", ".join(["id", *[f"request.{f['name']}()" for f in fields]])
create_id_arg = ("null" if id_generated else f"request.{id_spec['name']}()")
table_name = camel_to_snake(entity)
base_path = f"/api/{table_name}"
# Common placeholders for external templates
# Lombok-related placeholders
# Domain model should only have @Getter for DDD immutability
lombok_domain_imports = "import lombok.Getter;" if args.lombok else ""
lombok_domain_annotations = "@Getter" if args.lombok else ""
lombok_domain_annotations_block = ("\n" + lombok_domain_annotations) if lombok_domain_annotations else ""
lombok_model_imports = "import lombok.Getter;\nimport lombok.Setter;\nimport lombok.AllArgsConstructor;" if args.lombok else ""
lombok_common_imports = "import lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;" if args.lombok else ""
model_annotations = "@Getter\n@Setter\n@AllArgsConstructor" if args.lombok else ""
service_annotations = "@RequiredArgsConstructor\n@Slf4j" if args.lombok else ""
controller_annotations = "@RequiredArgsConstructor\n@Slf4j" if args.lombok else ""
adapter_annotations = "@RequiredArgsConstructor\n@Slf4j" if args.lombok else ""
# annotation blocks that include a leading newline when present to avoid empty lines
service_annotations_block = ("\n" + service_annotations) if service_annotations else ""
controller_annotations_block = ("\n" + controller_annotations) if controller_annotations else ""
adapter_annotations_block = ("\n" + adapter_annotations) if adapter_annotations else ""
model_annotations_block = ("\n" + model_annotations) if model_annotations else ""
# Common placeholders for external templates
placeholders = {
"entity": entity,
"Entity": entity,
"EntityRequest": f"{entity}Request",
"EntityResponse": f"{entity}Response",
"entity_lower": lower_first(entity),
"package": base_pkg,
"Package": base_pkg.replace(".", "/"),
"table_name": table_name,
"base_path": base_path,
"id_type": id_spec["type"],
"id_name": id_spec["name"],
"id_name_lower": lower_first(id_spec["name"]),
"id_generated": str(id_generated).lower(),
"fields": fields,
"all_fields": all_fields,
"extra_imports": extra_imports,
"final_kw": final_kw,
"domain_fields_decls": domain_fields_decls,
"domain_ctor_params": domain_ctor_params,
"domain_assigns": domain_assigns,
"domain_getters": domain_getters,
"model_constructor_block": model_constructor_block,
"all_names_csv": all_names_csv,
"jpa_fields_decls": jpa_fields_decls,
"jpa_ctor_params": jpa_ctor_params,
"jpa_assigns": jpa_assigns,
"jpa_getters_setters": jpa_getters_setters,
"dto_response_components": dto_response_components,
"dto_request_components": dto_request_components,
"adapter_to_entity_args": adapter_to_entity_args,
"adapter_to_domain_args": adapter_to_domain_args,
"request_all_args": request_all_args,
"response_from_agg_args": response_from_agg_args,
"list_map_response_args": list_map_response_args,
"update_create_args": update_create_args,
"mapper_create_args": mapper_create_args,
"create_id_arg": create_id_arg,
# Domain-specific Lombok placeholders (DDD-compliant)
"lombok_domain_imports": lombok_domain_imports,
"lombok_domain_annotations_block": lombok_domain_annotations_block,
# Infrastructure/infrastructure Lombok placeholders
"lombok_model_imports": lombok_model_imports,
"lombok_common_imports": lombok_common_imports,
"model_annotations": model_annotations,
"service_annotations": service_annotations,
"controller_annotations": controller_annotations,
"adapter_annotations": adapter_annotations,
"service_annotations_block": service_annotations_block,
"controller_annotations_block": controller_annotations_block,
"adapter_annotations_block": adapter_annotations_block,
"model_annotations_block": model_annotations_block,
# Constructor placeholders
"controller_constructor": "",
"adapter_constructor": "",
"create_constructor": "",
"update_constructor": "",
"get_constructor": "",
"list_constructor": "",
"delete_constructor": "",
"domain_service_constructor": "",
}
def _render(name, placeholders_dict):
c = render_template_file(templates_dir, name, placeholders_dict)
if c is None: raise SystemExit(f"Template render failed: {name}")
c = (c.replace("$controller_constructor", placeholders_dict.get("controller_constructor", ""))
.replace("$adapter_constructor", placeholders_dict.get("adapter_constructor", ""))
.replace("$create_constructor", placeholders_dict.get("create_constructor", ""))
.replace("$update_constructor", placeholders_dict.get("update_constructor", ""))
.replace("$get_constructor", placeholders_dict.get("get_constructor", ""))
.replace("$list_constructor", placeholders_dict.get("list_constructor", ""))
.replace("$delete_constructor", placeholders_dict.get("delete_constructor", ""))
.replace("$domain_service_constructor", placeholders_dict.get("domain_service_constructor", "")))
return c
# Write files (templates only, fail on error)
content = _render("DomainModel.java.tpl", placeholders)
write_file(paths["domain_model"], content)
content = _render("DomainRepository.java.tpl", placeholders)
write_file(paths["domain_repo"], content)
content = _render("DomainService.java.tpl", placeholders)
write_file(paths["domain_service"], content)
content = _render("JpaEntity.java.tpl", placeholders)
write_file(paths["jpa_entity"], content)
content = _render("SpringDataRepository.java.tpl", placeholders)
write_file(paths["spring_data_repo"], content)
content = _render("PersistenceAdapter.java.tpl", placeholders)
write_file(paths["persistence_adapter"], content)
content = _render("CreateService.java.tpl", placeholders)
write_file(paths["app_service_create"], content)
content = _render("GetService.java.tpl", placeholders)
write_file(paths["app_service_get"], content)
content = _render("UpdateService.java.tpl", placeholders)
write_file(paths["app_service_update"], content)
content = _render("DeleteService.java.tpl", placeholders)
write_file(paths["app_service_delete"], content)
content = _render("ListService.java.tpl", placeholders)
write_file(paths["app_service_list"], content)
content = _render("DtoRequest.java.tpl", placeholders)
write_file(paths["dto_req"], content)
content = _render("DtoResponse.java.tpl", placeholders)
write_file(paths["dto_res"], content)
content = _render("Controller.java.tpl", placeholders)
write_file(paths["controller"], content)
# Exceptions
content = _render("NotFoundException.java.tpl", placeholders)
write_file(paths["ex_not_found"], content)
content = _render("ExistException.java.tpl", placeholders)
write_file(paths["ex_exist"], content)
content = _render("EntityExceptionHandler.java.tpl", placeholders)
write_file(paths["entity_exception_handler"], content)
# Helpful README
readme = dedent(f"""
# Generated CRUD Feature: {entity}
Base package: {base_pkg}
Structure:
- domain/model/{entity}.java
- domain/repository/{entity}Repository.java
- infrastructure/persistence/{entity}Entity.java
- infrastructure/persistence/{entity}JpaRepository.java
- infrastructure/persistence/{entity}RepositoryAdapter.java
- application/service/Create{entity}Service.java
- application/service/Get{entity}Service.java
- application/service/Update{entity}Service.java
- application/service/Delete{entity}Service.java
- application/service/List{entity}Service.java
- presentation/dto/{entity}Request.java
- presentation/dto/{entity}Response.java
- presentation/rest/{entity}Controller.java
Next steps:
- Add validation and invariants in domain aggregate
- Secure endpoints and add tests (unit + @DataJpaTest + Testcontainers)
- Wire into your Spring Boot app (component scan should pick up beans)
""")
write_file(os.path.join(out_root, "README-GENERATED.md"), readme)
print(f"CRUD boilerplate generated under: {out_root}")
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
Related skills
How it compares
Pick Spring Boot CRUD Patterns for JSON-driven Java REST scaffolding rather than Quarkus verification or document conversion skills.
FAQ
Package layout?
feature name with domain application presentation infrastructure.
Domain rules?
Invariants in entities framework-free with factory methods.
Trigger phrases?
implement Spring CRUD create endpoint JPA aggregate.
Is Spring Boot Crud Patterns safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.