Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:28:34 +08:00
commit 390afca02b
220 changed files with 86013 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
---
name: spring-boot-crud-patterns
description: Provide repeatable CRUD workflows for Spring Boot 3 services with Spring Data JPA and feature-focused architecture; apply when modeling aggregates, repositories, controllers, and DTOs for REST APIs.
allowed-tools: Read, Write, Bash
category: backend
tags: [spring-boot, java, ddd, rest-api, crud, jpa, feature-architecture]
version: 1.1.0
---
# Spring Boot CRUD Patterns
## Overview
Deliver feature-aligned CRUD services that separate domain, application, presentation, and infrastructure layers while preserving Spring Boot 3.5+ conventions. This skill distills the essential workflow and defers detailed code listings to reference files for progressive disclosure.
## When to Use
- Implement REST endpoints for create/read/update/delete workflows backed by Spring Data JPA.
- Refine feature packages following DDD-inspired architecture with aggregates, repositories, and application services.
- Introduce 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"**, **"refine feature-based repository"**, **"map DTOs for JPA aggregate"**, **"add pagination to REST list endpoint"**.
## Prerequisites
- Java 17+ project using Spring Boot 3.5.x (or later) with `spring-boot-starter-web` and `spring-boot-starter-data-jpa`.
- Constructor injection enabled (Lombok `@RequiredArgsConstructor` or explicit constructors).
- Access to a relational database (Testcontainers recommended for integration tests).
- Familiarity with validation (`jakarta.validation`) and error handling (`ResponseStatusException`).
## Quickstart Workflow
1. **Establish Feature Structure**
Create `feature/<name>/` directories for `domain`, `application`, `presentation`, and `infrastructure`.
2. **Model the Aggregate**
Define domain entities and value objects without Spring dependencies; capture invariants in methods such as `create` and `update`.
3. **Expose Domain Ports**
Declare repository interfaces in `domain/repository` describing persistence contracts.
4. **Provide Infrastructure Adapter**
Implement Spring Data adapters in `infrastructure/persistence` that map domain models to JPA entities and delegate to `JpaRepository`.
5. **Implement Application Services**
Create transactional use cases under `application/service` that orchestrate aggregates, repositories, and mapping logic.
6. **Publish REST Controllers**
Map DTO records under `presentation/rest`, expose endpoints with proper status codes, and wire validation annotations.
7. **Validate with Tests**
Run unit tests for domain logic and repository/service tests with Testcontainers for persistence verification.
Consult `references/examples-product-feature.md` for complete code listings that align with each step.
## Implementation Patterns
### Domain Layer
- Define immutable aggregates with factory methods (`Product.create`) to centralize invariants.
- Use value objects (`Money`, `Stock`) to enforce type safety and encapsulate validation.
- Keep domain objects framework-free; avoid `@Entity` annotations in the domain package when using adapters.
### Application Layer
- Wrap use cases in `@Service` classes using constructor injection and `@Transactional`.
- Map requests to domain operations and persist through domain repositories.
- Return response DTOs or records produced by dedicated mappers to decouple domain from transport.
### Infrastructure Layer
- Implement adapters that translate between domain aggregates and JPA entities; prefer MapStruct or manual mappers for clarity.
- Configure repositories with Spring Data interfaces (e.g., `JpaRepository<ProductEntity, String>`) and custom queries for pagination or batch updates.
- Externalize persistence properties (naming strategies, DDL mode) via `application.yml`; see `references/spring-official-docs.md`.
### Presentation Layer
- Structure controllers by feature (`ProductController`) and expose REST paths (`/api/products`).
- Return `ResponseEntity` with appropriate codes: `201 Created` on POST, `200 OK` on GET/PUT/PATCH, `204 No Content` on DELETE.
- Apply `@Valid` on request DTOs and handle errors with `@ControllerAdvice` or `ResponseStatusException`.
## Validation and Observability
- Write unit tests that assert domain invariants and repository contracts; refer to `references/examples-product-feature.md` integration test snippets.
- Use `@DataJpaTest` and Testcontainers to validate persistence mapping, pagination, and batch operations.
- Surface health and metrics through Spring Boot Actuator; monitor CRUD throughput and error rates.
- Log key actions at `info` for lifecycle events (create, update, delete) and use structured logging for audit trails.
## Best Practices
- Favor feature modules with clear boundaries; colocate domain, application, and presentation code per aggregate.
- Keep DTOs immutable via Java records; convert domain types at the service boundary.
- Guard write operations with transactions and optimistic locking where concurrency matters.
- Normalize pagination defaults (page, size, sort) and document query parameters.
- Capture links between commands and events where integration with messaging or auditing is required.
## Constraints and Warnings
- Avoid exposing JPA entities directly in controllers to prevent lazy-loading leaks and serialization issues.
- Do not mix field injection with constructor injection; maintain immutability for easier testing.
- Refrain from embedding business logic in controllers or repository adapters; keep it in domain/application layers.
- Validate input aggressively to prevent constraint violations and produce consistent error payloads.
- Ensure migrations (Liquibase/Flyway) mirror aggregate evolution before deploying schema changes.
## References
- [HTTP method matrix, annotation catalog, DTO patterns.](references/crud-reference.md)
- [Progressive examples from starter to advanced feature implementation.](references/examples-product-feature.md)
- [Excerpts from official Spring guides and Spring Boot reference documentation.](references/spring-official-docs.md)
- [Python generator to scaffold CRUD boilerplate from entity spec.](scripts/generate_crud_boilerplate.py) Usage: `python skills/spring-boot/spring-boot-crud-patterns/scripts/generate_crud_boilerplate.py --spec entity.json --package com.example.product --output ./generated`
- Templates required: place .tpl files in `skills/spring-boot/spring-boot-crud-patterns/templates/` or pass `--templates-dir <path>`; no fallback to built-ins. See `templates/README.md`.
- Usage guide: [references/generator-usage.md](references/generator-usage.md)
- Example spec: `skills/spring-boot/spring-boot-crud-patterns/assets/specs/product.json`
- Example with relationships: `skills/spring-boot/spring-boot-crud-patterns/assets/specs/product_with_rel.json`

View File

@@ -0,0 +1,9 @@
{
"entity": "Product",
"id": { "name": "id", "type": "Long", "generated": true },
"fields": [
{ "name": "name", "type": "String" },
{ "name": "price", "type": "BigDecimal" },
{ "name": "inStock", "type": "Boolean" }
]
}

View File

@@ -0,0 +1,11 @@
{
"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" }
]
}

View File

@@ -0,0 +1,5 @@
# 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.

View File

@@ -0,0 +1,12 @@
# 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

View File

@@ -0,0 +1,35 @@
# CRUD Generator Usage
Quick start:
```
python skills/spring-boot/spring-boot-crud-patterns/scripts/generate_crud_boilerplate.py \
--spec skills/spring-boot/spring-boot-crud-patterns/assets/specs/product.json \
--package com.example.product \
--output ./generated \
--templates-dir skills/spring-boot/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

View File

@@ -0,0 +1,5 @@
# Spring Docs Pointers
- Spring Boot Reference Guide
- Spring Data JPA Reference
- Validation (Jakarta Validation)

View File

@@ -0,0 +1,9 @@
{
"entity": "Product",
"id": { "name": "id", "type": "Long", "generated": true },
"fields": [
{ "name": "name", "type": "String" },
{ "name": "price", "type": "BigDecimal" },
{ "name": "inStock", "type": "Boolean" }
]
}

View File

@@ -0,0 +1,14 @@
{
"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" } }
]
}

View File

@@ -0,0 +1,898 @@
#!/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/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)

View File

@@ -0,0 +1,60 @@
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()));
}
}

View File

@@ -0,0 +1,32 @@
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");
}
}
}

View File

@@ -0,0 +1,23 @@
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);
}
}

View File

@@ -0,0 +1,20 @@
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
}

View File

@@ -0,0 +1,14 @@
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();
}

View File

@@ -0,0 +1,42 @@
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();
}
}

View File

@@ -0,0 +1,5 @@
package $package.presentation.dto;
$extra_imports
public record $EntityRequest($dto_request_components) { }

View File

@@ -0,0 +1,5 @@
package $package.presentation.dto;
$extra_imports
public record $EntityResponse($dto_response_components) { }

View File

@@ -0,0 +1,35 @@
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);
}
}

View File

@@ -0,0 +1,8 @@
package $package.presentation.dto;
public record ErrorResponse(
int status,
String error,
String message,
String path
) { }

View File

@@ -0,0 +1,10 @@
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);
}
}

View File

@@ -0,0 +1,25 @@
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));
}
}

View File

@@ -0,0 +1,55 @@
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);
}
}

View File

@@ -0,0 +1,25 @@
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
}

View File

@@ -0,0 +1,31 @@
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);
}
}

View File

@@ -0,0 +1,16 @@
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);
}
}

View File

@@ -0,0 +1,7 @@
package $package.application.exception;
public class ${entity}NotFoundException extends RuntimeException {
public ${entity}NotFoundException($id_type $id_name) {
super("$entity not found: " + $id_name);
}
}

View File

@@ -0,0 +1,9 @@
package $package.presentation.dto;
public record PageResponse<T>(
java.util.List<T> content,
int page,
int size,
long totalElements,
int totalPages
) { }

View File

@@ -0,0 +1,54 @@
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();
}
}

View File

@@ -0,0 +1,39 @@
# 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 teams 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

View File

@@ -0,0 +1,5 @@
package $package.infrastructure.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ${entity}JpaRepository extends JpaRepository<${entity}Entity, $id_type> { }

View File

@@ -0,0 +1,32 @@
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");
}
}
}