├── .gitignore
├── .mvn
└── wrapper
│ ├── maven-wrapper.jar
│ └── maven-wrapper.properties
├── README.md
├── images
└── custom-soft-deletes-1.png
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
└── main
├── java
└── com
│ └── piinalpin
│ └── customsoftdeletes
│ ├── CustomSoftDeletesApplication.java
│ ├── config
│ └── CustomJpaRepositoryFactoryBean.java
│ ├── constant
│ └── AppConstant.java
│ ├── entity
│ ├── Author.java
│ ├── Book.java
│ ├── BookDetail.java
│ ├── Transaction.java
│ ├── TransactionDetail.java
│ └── base
│ │ ├── BaseEntity.java
│ │ └── BaseEntityWithDeletedAt.java
│ ├── http
│ ├── controller
│ │ ├── AuthorController.java
│ │ ├── BookController.java
│ │ └── TransactionController.java
│ └── dto
│ │ ├── AuthorRequest.java
│ │ ├── BookRequest.java
│ │ ├── TransactionDetailRequest.java
│ │ ├── TransactionRequest.java
│ │ └── base
│ │ └── BaseResponse.java
│ ├── repository
│ ├── AuthorRepository.java
│ ├── BookDetailRepository.java
│ ├── BookRepository.java
│ ├── TransactionDetailRepository.java
│ ├── TransactionRepository.java
│ └── softdeletes
│ │ ├── SoftDeletesRepository.java
│ │ └── SoftDeletesRepositoryImpl.java
│ ├── service
│ ├── AuthorService.java
│ ├── BookService.java
│ └── TransactionService.java
│ └── util
│ └── ResponseUtil.java
└── resources
├── META-INF
└── additional-spring-configuration-metadata.json
└── application.properties
/.gitignore:
--------------------------------------------------------------------------------
1 | HELP.md
2 | target/
3 | !.mvn/wrapper/maven-wrapper.jar
4 | !**/src/main/**/target/
5 | !**/src/test/**/target/
6 |
7 | ### STS ###
8 | .apt_generated
9 | .classpath
10 | .factorypath
11 | .project
12 | .settings
13 | .springBeans
14 | .sts4-cache
15 |
16 | ### IntelliJ IDEA ###
17 | .idea
18 | *.iws
19 | *.iml
20 | *.ipr
21 |
22 | ### NetBeans ###
23 | /nbproject/private/
24 | /nbbuild/
25 | /dist/
26 | /nbdist/
27 | /.nb-gradle/
28 | build/
29 | !**/src/main/**/build/
30 | !**/src/test/**/build/
31 |
32 | ### VS Code ###
33 | .vscode/
34 |
--------------------------------------------------------------------------------
/.mvn/wrapper/maven-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piinalpin/springboot-data-jpa-soft-delete/d2322f101d38ba25977337fe6a80da9e6a0d1929/.mvn/wrapper/maven-wrapper.jar
--------------------------------------------------------------------------------
/.mvn/wrapper/maven-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.4/apache-maven-3.8.4-bin.zip
2 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Overview
2 |
3 | Deleting data permanently from a table is a common requirement when interacting with database. But, sometimes there are business requirements to not permanently delete data from the database. The solution is we just hide that data so that can't be accessed from the front-end.
4 |
5 | In this documentation, I will share how I implementing custom JPA repository with soft deletes using `JpaRepositoryFactoryBean`. So, that data can be tracked or audited when is created, updated, or deleted. For example, let's design a table with a book sale case study like this. There are `created_at`, `created_by`, `updated_at` and `deleted_at` fields. Some case `updated_at` can be replace with `modified_at` and `modified_by`. But, the point is `deleted_at` field.
6 |
7 | 
8 |
9 | ## Project Setup and Dependency
10 | I'm depending [Spring Initializr](https://start.spring.io/) for this as it is much easier.
11 |
12 | We need `spring-boot-starter-data-jpa`, `spring-boot-starter-web`, `lombok` and `h2database`. There is my `pom.xml`.
13 |
14 | ```xml
15 |
16 | org.springframework.boot
17 | spring-boot-starter-data-jpa
18 |
19 |
20 | org.springframework.boot
21 | spring-boot-starter-web
22 |
23 |
24 |
25 | org.projectlombok
26 | lombok
27 | true
28 |
29 |
30 | org.springframework.boot
31 | spring-boot-starter-test
32 | test
33 |
34 |
35 |
36 | com.h2database
37 | h2
38 | runtime
39 |
40 | ```
41 |
42 | Change configuration `application.properties` file like following below.
43 |
44 | ```sh
45 | server.port=8080
46 | spring.application.name=custom-soft-deletes
47 | server.servlet.context-path=/api
48 |
49 | spring.datasource.url=jdbc:h2:mem:db;
50 | spring.datasource.driverClassName=org.h2.Driver
51 | spring.datasource.username=sa
52 | spring.datasource.password=password
53 | spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
54 | spring.h2.console.enabled=true
55 | spring.jpa.show-sql=true
56 | ```
57 |
58 | ## Implementation
59 |
60 | **Soft Deletes Repository Interface**
61 |
62 | Create an interface `SoftDeletesRepository` which will be used to replace the repository that inherit from `JpaRepository`.
63 |
64 | ```java
65 | @SuppressWarnings("java:S119")
66 | @Transactional
67 | @NoRepositoryBean
68 | public interface SoftDeletesRepository extends PagingAndSortingRepository {
69 |
70 | @Override
71 | Iterable findAll();
72 |
73 | @Override
74 | Iterable findAll(Sort sort);
75 |
76 | @Override
77 | Page findAll(Pageable page);
78 |
79 | Optional findOne(ID id);
80 |
81 | @Modifying
82 | void delete(ID id);
83 |
84 | @Override
85 | @Modifying
86 | void delete(T entity);
87 |
88 | void hardDelete(T entity);
89 |
90 | }
91 | ```
92 |
93 | Create an implementation from `SoftDeletesRepository` interface class.
94 |
95 | ```java
96 | @SuppressWarnings("java:S119")
97 | @Slf4j
98 | public class SoftDeletesRepositoryImpl extends SimpleJpaRepository
99 | implements SoftDeletesRepository {
100 |
101 | private final JpaEntityInformation entityInformation;
102 | private final EntityManager em;
103 | private final Class domainClass;
104 | private static final String DELETED_FIELD = "deletedAt";
105 |
106 | public SoftDeletesRepositoryImpl(Class domainClass, EntityManager em) {
107 | super(domainClass, em);
108 | this.em = em;
109 | this.domainClass = domainClass;
110 | this.entityInformation = JpaEntityInformationSupport.getEntityInformation(domainClass, em);
111 | }
112 |
113 |
114 | @Override
115 | public Optional findOne(ID id) {
116 | return Optional.empty();
117 | }
118 |
119 | @Override
120 | public void delete(ID id) {
121 |
122 | }
123 |
124 | @Override
125 | public void hardDelete(T entity) {
126 |
127 | }
128 | }
129 | ```
130 |
131 | Add method in `SoftDeletesRepositoryImpl` to check if field `deletedAt` is exist on super class, because some entity have `deletedAt` some case the don't have. So, I create method returning boolean to handle that.
132 |
133 | ```java
134 | private boolean isFieldDeletedAtExists() {
135 | try {
136 | domainClass.getSuperclass().getDeclaredField(DELETED_FIELD);
137 | return true;
138 | } catch (NoSuchFieldException e) {
139 | return false;
140 | }
141 | }
142 | ```
143 |
144 | Create predicate specification class to filter entity if `deletedAt` is null. So, if translated in a native query is `SELECT * FROM table WHERE deleted_at is null`.
145 |
146 | ```java
147 | private static final class DeletedIsNUll implements Specification {
148 |
149 | private static final long serialVersionUID = -940322276301888908L;
150 |
151 | @Override
152 | public Predicate toPredicate(Root root, CriteriaQuery> query, CriteriaBuilder criteriaBuilder) {
153 | return criteriaBuilder.isNull(root.get(DELETED_FIELD));
154 | }
155 |
156 | }
157 |
158 | private static Specification notDeleted() {
159 | return Specification.where(new DeletedIsNUll<>());
160 | }
161 | ```
162 |
163 | Create predicate specification class to filter entity by ID. And can be reuse with `notDeleted()` or without `notDeleted()`. If I translated in sql is `SELECT * FROM table WHERE id = ?` or `SELECT * FROM table WHERE id = ? AND deletedAt is null`.
164 |
165 | ```java
166 | private static final class ByIdSpecification implements Specification {
167 |
168 | private static final long serialVersionUID = 6523470832851906115L;
169 | private final transient JpaEntityInformation entityInformation;
170 | private final transient ID id;
171 |
172 | ByIdSpecification(JpaEntityInformation entityInformation, ID id) {
173 | this.entityInformation = entityInformation;
174 | this.id = id;
175 | }
176 |
177 | @Override
178 | public Predicate toPredicate(Root root, CriteriaQuery> query, CriteriaBuilder cb) {
179 | return cb.equal(root.get(Objects.requireNonNull(entityInformation.getIdAttribute()).getName()), id);
180 | }
181 | }
182 | ```
183 |
184 | Then, create method to do updating `deletedAt` with `LocalDateTime.now()` when process delete data.
185 |
186 | ```java
187 | private void softDelete(ID id, LocalDateTime localDateTime) {
188 | Assert.notNull(id, "The given id must not be null!");
189 |
190 | Optional entity = findOne(id);
191 |
192 | if (entity.isEmpty())
193 | throw new EmptyResultDataAccessException(
194 | String.format("No %s entity with id %s exists!", entityInformation.getJavaType(), id), 1);
195 |
196 | softDelete(entity.get(), localDateTime);
197 | }
198 |
199 | private void softDelete(T entity, LocalDateTime localDateTime) {
200 | Assert.notNull(entity, "The entity must not be null!");
201 |
202 | CriteriaBuilder cb = em.getCriteriaBuilder();
203 |
204 | CriteriaUpdate update = cb.createCriteriaUpdate(domainClass);
205 |
206 | Root root = update.from(domainClass);
207 |
208 | update.set(DELETED_FIELD, localDateTime);
209 |
210 | update.where(
211 | cb.equal(
212 | root.get(Objects.requireNonNull(entityInformation.getIdAttribute()).getName()),
213 | entityInformation.getId(entity)
214 | )
215 | );
216 |
217 | em.createQuery(update).executeUpdate();
218 | }
219 | ```
220 |
221 | Enhance override method `findAll()`, `findOne`, `delete()` and etc.
222 |
223 | ```java
224 | @Override
225 | public List findAll(){
226 | if (isFieldDeletedAtExists()) return super.findAll(notDeleted());
227 | return super.findAll();
228 | }
229 |
230 | @Override
231 | public List findAll(Sort sort){
232 | if (isFieldDeletedAtExists()) return super.findAll(notDeleted(), sort);
233 | return super.findAll(sort);
234 | }
235 |
236 | @Override
237 | public Page findAll(Pageable page) {
238 | if (isFieldDeletedAtExists()) return super.findAll(notDeleted(), page);
239 | return super.findAll(page);
240 | }
241 |
242 | @Override
243 | public Optional findOne(ID id) {
244 | if (isFieldDeletedAtExists())
245 | return super.findOne(Specification.where(new ByIdSpecification<>(entityInformation, id)).and(notDeleted()));
246 | return super.findOne(Specification.where(new ByIdSpecification<>(entityInformation, id)));
247 | }
248 |
249 | @Override
250 | @Transactional
251 | public void delete(ID id) {
252 | softDelete(id, LocalDateTime.now());
253 | }
254 |
255 | @Override
256 | @Transactional
257 | public void delete(T entity) {
258 | softDelete(entity, LocalDateTime.now());
259 | }
260 |
261 | @Override
262 | public void hardDelete(T entity) {
263 | super.delete(entity);
264 | }
265 | ```
266 |
267 | **Jpa Repository Factory Bean**
268 |
269 | I create a custom repository factory to replace the default `RepositoryFactoryBean` that will in turn produce a custom `RepositoryFactory`. The new repository factory will then provide your `SoftDeletesRepositoryImpl` as the implementation of any interfaces that extend the `Repository` interface, replacing the `SimpleJpaRepository` implementation I just extended.
270 |
271 | ```java
272 | @SuppressWarnings("all")
273 | public class CustomJpaRepositoryFactoryBean, S, ID extends Serializable>
274 | extends JpaRepositoryFactoryBean {
275 |
276 | public CustomJpaRepositoryFactoryBean(Class extends T> repositoryInterface) {
277 | super(repositoryInterface);
278 | }
279 |
280 | @Override
281 | protected RepositoryFactorySupport createRepositoryFactory(EntityManager entityManager) {
282 | return new CustomJpaRepositoryFactory(entityManager);
283 | }
284 |
285 | private static class CustomJpaRepositoryFactory extends JpaRepositoryFactory {
286 |
287 | private final EntityManager entityManager;
288 |
289 | CustomJpaRepositoryFactory(EntityManager entityManager) {
290 | super(entityManager);
291 | this.entityManager = entityManager;
292 | }
293 |
294 | @Override
295 | protected JpaRepositoryImplementation, ?> getTargetRepository(RepositoryInformation information, EntityManager entityManager) {
296 | return new SoftDeletesRepositoryImpl((Class) information.getDomainType(), this.entityManager);
297 | }
298 |
299 | @Override
300 | protected Class> getRepositoryBaseClass(RepositoryMetadata metadata) {
301 | return SoftDeletesRepositoryImpl.class;
302 | }
303 | }
304 |
305 | }
306 | ```
307 |
308 | **Enable Custom JPA Repository Bean**
309 |
310 | Add `@EnableJpaRepositories` in the main `Application` class.
311 |
312 | ```java
313 | @SpringBootApplication
314 | @EnableJpaRepositories(repositoryFactoryBeanClass = CustomJpaRepositoryFactoryBean.class)
315 | ```
316 |
317 | **Base Entity**
318 |
319 | Create a base entity so I can reuse it for all entity by extending the base entity. I create `BaseEntity` and `BaseEntityWithDeletedAt` which is extending from `BaseEntity`. It means the `BaseEntityWithDeletedAt` has the attributes contained in the `BaseEntity`.
320 |
321 | ```java
322 | @Data
323 | @SuperBuilder
324 | @MappedSuperclass
325 | @NoArgsConstructor
326 | @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
327 | public abstract class BaseEntity implements Serializable {
328 |
329 | private static final long serialVersionUID = 346886977546599767L;
330 |
331 | @Column(name = "created_at", nullable = false)
332 | private LocalDateTime createdAt;
333 |
334 | @Column(name = "created_by", nullable = false)
335 | private String createdBy;
336 |
337 | @Column(name = "updated_at")
338 | private LocalDateTime updatedAt;
339 |
340 | @PrePersist
341 | void onCreate() {
342 | this.createdAt = LocalDateTime.now();
343 | if (createdBy == null) createdBy = AppConstant.DEFAULT_SYSTEM;
344 | }
345 |
346 | @PreUpdate
347 | void onUpdate() {
348 | this.updatedAt = LocalDateTime.now();
349 | }
350 |
351 | }
352 | ```
353 |
354 | ```java
355 | @EqualsAndHashCode(callSuper = true)
356 | @Data
357 | @SuperBuilder
358 | @MappedSuperclass
359 | @NoArgsConstructor
360 | @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
361 | public abstract class BaseEntityWithDeletedAt extends BaseEntity {
362 |
363 | private static final long serialVersionUID = 8570014337552990877L;
364 |
365 | @JsonIgnore
366 | @Column(name = "deleted_at")
367 | private LocalDateTime deletedAt;
368 |
369 | }
370 | ```
371 |
372 | **Create Entity According Study Case**
373 |
374 | *Author*
375 |
376 | ```java
377 | @EqualsAndHashCode(callSuper = true)
378 | @Data
379 | @Entity
380 | @SuperBuilder
381 | @NoArgsConstructor
382 | @AllArgsConstructor
383 | @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
384 | @Table(name = "M_AUTHOR")
385 | public class Author extends BaseEntityWithDeletedAt {
386 |
387 | private static final long serialVersionUID = 5703123232205376654L;
388 |
389 | @Id
390 | @GeneratedValue(strategy = GenerationType.IDENTITY)
391 | private Long id;
392 |
393 | @Column(name = "full_name", nullable = false)
394 | private String fullName;
395 |
396 | }
397 | ```
398 |
399 | **Create Request DTO**
400 |
401 | *AuthorRequest*
402 |
403 | ```java
404 | @Data
405 | @Builder
406 | @NoArgsConstructor
407 | @AllArgsConstructor
408 | @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
409 | @JsonIgnoreProperties(ignoreUnknown = true)
410 | public class AuthorRequest implements Serializable {
411 |
412 | private static final long serialVersionUID = 2120677063776280918L;
413 |
414 | private String fullName;
415 |
416 | }
417 | ```
418 |
419 | **Create Repository, Service and Controller**
420 |
421 | *AuthorRepository*
422 |
423 | ```java
424 | @Repository
425 | public interface AuthorRepository extends SoftDeletesRepository {
426 | }
427 | ```
428 |
429 | *AuthorService*
430 |
431 | ```java
432 | @Slf4j
433 | @Service
434 | public class AuthorService {
435 |
436 | private final AuthorRepository authorRepository;
437 |
438 | @Autowired
439 | public AuthorService(AuthorRepository authorRepository) {
440 | this.authorRepository = authorRepository;
441 | }
442 |
443 | public ResponseEntity