spring data core annotations
Tung Bui Thanh (TungDaDev)18 mins read

# giới thiệu
Ai từng viết DAO layer với cả trăm dòng boilerplate (mở connection, prepare statement, map ResultSet, bắt exception, đóng resource) thì sẽ hiểu vì sao Spring Data ra đời.
org.springframework.data là một lớp abstraction dùng chung cho mọi data store: JPA (PostgreSQL), MongoDB, Redis, Elasticsearch... Bạn khai báo interface, Spring lo phần implementation. Cùng một kiểu viết findByStatusAndCreatedAtAfter() chạy được cho cả SQL lẫn NoSQL.
Trong CSP, các service dùng lẫn lộn: PostgreSQL cho dữ liệu quan hệ (permissions, process apps), MongoDB cho dữ liệu dạng document (process definitions, build records, service discovery), Redis cho caching và distributed lock. Có Spring Data thì cả ba đều viết theo cùng một kiểu.
Note: annotation riêng của JPA mình đã viết ở bài spring data jpa. Bài này nói về các annotation dùng chung và phần MongoDB/Redis.
# repository annotations
Cái hay của Spring Data nằm ở Repository pattern: bạn khai báo interface với tên method có nghĩa → Spring đọc tên method → sinh query lúc runtime. Không cần viết dòng SQL nào.
# @Repository: đánh dấu data access layer
@Repository // Exception translation + component scan
public interface ProductRepository extends JpaRepository<Product, UUID> {}
// Spring Data auto-generates implementation cho interface
// @Repository thật ra KHÔNG cần vì Spring Data đã tự detect, nhưng convention tốtVới interface của Spring Data thì @Repository gần như chỉ để đánh dấu cho người đọc: proxy repository do Spring Data sinh ra đã tự được đăng ký bean và tự có exception translation (dịch exception của JPA/Hibernate sang DataAccessException). Annotation này thật sự có tác dụng khi bạn tự viết class DAO và để component scan nhặt lên.
# @NoRepositoryBean: base repository (không tạo implementation)
@NoRepositoryBean // Spring KHÔNG tạo bean cho interface này
public interface BaseRepository<T, ID> extends JpaRepository<T, ID> {
@Query("SELECT e FROM #{#entityName} e WHERE e.deletedAt IS NULL")
List<T> findAllActive();
@Transactional
@Modifying
@Query("UPDATE #{#entityName} e SET e.deletedAt = CURRENT_TIMESTAMP WHERE e.id = :id")
int softDelete(@Param("id") ID id);
}
// Concrete repositories extend base → Spring tạo implementation
@Repository
public interface ProductRepository extends BaseRepository<Product, UUID> {
List<Product> findByStatus(ProductStatus status);
}
@Repository
public interface OrderRepository extends BaseRepository<Order, UUID> {
List<Order> findByCustomerId(UUID customerId);
}# @RepositoryDefinition: bản rút gọn
// Thay vì extend JpaRepository (có 20+ methods), chỉ expose methods cần thiết
@RepositoryDefinition(domainClass = Product.class, idClass = UUID.class)
public interface ReadOnlyProductRepository {
Optional<Product> findById(UUID id);
List<Product> findByStatus(ProductStatus status);
long count();
boolean existsById(UUID id);
// Không có save, delete → read-only
}# auditing annotations
Record nào trong database cũng nên trả lời được: ai tạo, tạo lúc nào, ai sửa lần cuối, sửa lúc nào. Đó là audit trail, app enterprise gần như bắt buộc phải có, nhất là ngành tài chính.
Khỏi phải tự set createdAt = LocalDateTime.now() trong từng service method, Spring Data Auditing tự điền các field có @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy. Khai báo một lần trong base entity là mọi entity kế thừa đều có auditing, chẳng tốn thêm công.
@CreatedBy/@LastModifiedBy lấy username từ bean AuditorAware, thường là lấy ra từ JWT token trong SecurityContext.
# @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter @Setter
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@CreatedDate // Auto set khi INSERT
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate // Auto set khi INSERT + UPDATE
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@CreatedBy // Auto set username/ID khi INSERT
@Column(name = "created_by", updatable = false)
private String createdBy;
@LastModifiedBy // Auto set khi INSERT + UPDATE
@Column(name = "updated_by")
private String updatedBy;
}
// Enable auditing
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class AuditConfig {
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.filter(Authentication::isAuthenticated)
.map(auth -> {
if (auth.getPrincipal() instanceof Jwt jwt) {
return jwt.getClaimAsString("preferred_username");
}
return auth.getName();
});
}
}# mongodb auditing
@Document(collection = "process_definitions")
@Getter @Setter
public class ProcessDefinition {
@Id
private String id;
private String name;
private String version;
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
@CreatedBy
private String createdBy;
@LastModifiedBy
private String updatedBy;
}
// Enable
@Configuration
@EnableMongoAuditing
public class MongoAuditConfig {
@Bean
public AuditorAware<String> auditorProvider() { ... }
}# query annotations
Derived query (đọc từ tên method) lo được phần lớn trường hợp. Còn khi query phức tạp, có nhiều JOIN, subquery, native function hay aggregation, thì bạn cần @Query để tự viết.
Điểm hay là cú pháp trong @Query khác nhau tuỳ module (JPQL cho JPA, JSON cho MongoDB) nhưng annotation thì vẫn là một, nên đổi data store cũng không thấy lạ tay.
# @Query: custom query (cú pháp tuỳ module)
// JPA (JPQL)
@Repository
public interface ProductJpaRepository extends JpaRepository<Product, UUID> {
@Query("SELECT p FROM Product p WHERE p.status = :status AND p.deletedAt IS NULL")
List<Product> findActiveByStatus(@Param("status") ProductStatus status);
}
// MongoDB
@Repository
public interface ProcessDefinitionRepository extends MongoRepository<ProcessDefinition, String> {
@Query("{'workspaceId': ?0, 'status': ?1, 'deletedAt': null}")
List<ProcessDefinition> findByWorkspaceAndStatus(String workspaceId, String status);
@Query(value = "{'name': {$regex: ?0, $options: 'i'}}", sort = "{'createdAt': -1}")
Page<ProcessDefinition> searchByName(String nameRegex, Pageable pageable);
@Query(value = "{'tags': {$in: ?0}}", fields = "{'name': 1, 'version': 1, 'status': 1}")
List<ProcessDefinition> findSummaryByTags(List<String> tags);
}# @Param: named parameter
@Query("SELECT p FROM Product p WHERE p.name LIKE %:keyword% AND p.category.id = :catId")
Page<Product> search(@Param("keyword") String keyword, @Param("catId") UUID categoryId, Pageable pageable);# @Modifying: thao tác ghi
@Transactional
@Modifying(clearAutomatically = true)
@Query("UPDATE Product p SET p.status = :status WHERE p.id IN :ids")
int bulkUpdateStatus(@Param("ids") Collection<UUID> ids, @Param("status") ProductStatus status);Hai điểm hay bị quên với @Modifying. Thứ nhất, query method bạn tự khai báo trong interface không tự có transaction như các method kế thừa từ SimpleJpaRepository, nên nếu chỗ gọi không nằm sẵn trong một transaction thì UPDATE/DELETE sẽ ném TransactionRequiredException; vì vậy mới có @Transactional ở trên. Thứ hai, bulk update chạy thẳng xuống DB, bỏ qua persistence context, nên entity đã load trước đó vẫn giữ giá trị cũ. clearAutomatically = true xoá persistence context sau khi chạy để lần đọc sau lấy dữ liệu mới.
# mongodb annotations (org.springframework.data.mongodb)
Data có schema linh hoạt hoặc lồng nhau nhiều tầng thì MongoDB là lựa chọn dễ hiểu. Trong CSP, MongoDB lưu process definitions (BPMN XML + metadata), build records, service discovery entries, form schemas, toàn những thứ nhét vào schema quan hệ cứng nhắc thì rất khó chịu.
Spring Data MongoDB có 2 cách làm việc:
- MongoRepository (declarative): khai báo interface, giống JPA repository. Hợp với CRUD đơn giản.
- MongoTemplate (imperative): viết code, toàn quyền kiểm soát. Hợp với query phức tạp, aggregation, bulk operation.
@Document map class → collection (giống @Entity → table trong JPA). @Field đặt lại tên field, @Indexed tạo index, @DBRef trỏ sang document khác.
Một cái bẫy: từ Spring Data MongoDB 3.0, việc tự tạo index từ @Indexed/@CompoundIndex bị tắt mặc định. Muốn bật thì set spring.data.mongodb.auto-index-creation=true, còn production thì nhiều team chọn tạo index bằng migration script cho chủ động. Ngoài ra @DBRef mặc định load eager: đọc một BuildRecord là kéo luôn ProcessDefinition bằng một query riêng; chỉ khi đặt lazy = true thì mới load khi truy cập.
# document mapping
@Document(collection = "build_records") // Collection name
@TypeAlias("BuildRecord") // Short class name trong _class field
@CompoundIndex(def = "{'workspaceId': 1, 'status': 1}", name = "idx_workspace_status")
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class BuildRecord {
@Id // Mapped to _id
private String id;
@Field("workspace_id") // Custom field name in MongoDB
@Indexed // Single field index
private String workspaceId;
@Field("app_name")
private String appName;
@Field("app_version")
private String appVersion;
@Indexed(unique = true)
@Field("build_number")
private String buildNumber;
@Field("status")
private BuildStatus status;
@Field("artifacts")
private List<Artifact> artifacts;
@Field("metadata")
private Map<String, Object> metadata;
@DBRef // Reference to another document (mặc định eager)
private ProcessDefinition processDefinition;
@DBRef(lazy = true)
private User triggeredBy;
// Embedded subdocument (không cần @DBRef)
private BuildConfig config;
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
@Version // Optimistic locking
private Long version;
@Transient // NOT persisted to MongoDB
private transient String computedField;
}
@Getter @Setter
public class Artifact {
private String name;
private String path;
private long size;
private String checksum;
}
@Getter @Setter
public class BuildConfig {
private String baseImage;
private Map<String, String> envVars;
private List<String> buildArgs;
}# mongodb repository
@Repository
public interface BuildRecordRepository extends MongoRepository<BuildRecord, String> {
// Derived queries (same syntax as JPA)
List<BuildRecord> findByWorkspaceIdAndStatus(String workspaceId, BuildStatus status);
Optional<BuildRecord> findByBuildNumber(String buildNumber);
Page<BuildRecord> findByWorkspaceIdOrderByCreatedAtDesc(String workspaceId, Pageable pageable);
long countByWorkspaceIdAndStatus(String workspaceId, BuildStatus status);
boolean existsByBuildNumber(String buildNumber);
// @Query with MongoDB JSON syntax
@Query("{'workspaceId': ?0, 'status': {$in: ?1}, 'createdAt': {$gte: ?2}}")
List<BuildRecord> findRecentByStatuses(String workspaceId, List<BuildStatus> statuses, Instant since);
// Projection — chỉ lấy fields cần thiết
@Query(value = "{'workspaceId': ?0}", fields = "{'appName': 1, 'buildNumber': 1, 'status': 1}")
List<BuildRecord> findSummaryByWorkspace(String workspaceId);
// Delete
void deleteByWorkspaceIdAndStatusAndCreatedAtBefore(
String workspaceId, BuildStatus status, Instant before);
// Aggregation
@Aggregation(pipeline = {
"{'$match': {'workspaceId': ?0}}",
"{'$group': {'_id': '$status', 'count': {'$sum': 1}}}",
"{'$sort': {'count': -1}}"
})
List<StatusCount> countByStatusGrouped(String workspaceId);
}
@Data
public class StatusCount {
@Id // _id của $group (giá trị status) map vào đây
private String status;
private long count;
}# MongoTemplate: thao tác phức tạp
@Service
@RequiredArgsConstructor
public class BuildRecordService {
private final MongoTemplate mongoTemplate;
// Complex query
public List<BuildRecord> search(BuildSearchCriteria criteria) {
Query query = new Query();
if (criteria.getWorkspaceId() != null) {
query.addCriteria(Criteria.where("workspaceId").is(criteria.getWorkspaceId()));
}
if (criteria.getKeyword() != null) {
query.addCriteria(Criteria.where("appName")
.regex(criteria.getKeyword(), "i"));
}
if (criteria.getStatuses() != null) {
query.addCriteria(Criteria.where("status").in(criteria.getStatuses()));
}
if (criteria.getFromDate() != null) {
query.addCriteria(Criteria.where("createdAt").gte(criteria.getFromDate()));
}
query.with(Sort.by(Sort.Direction.DESC, "createdAt"));
query.with(PageRequest.of(criteria.getPage(), criteria.getSize()));
return mongoTemplate.find(query, BuildRecord.class);
}
// Update specific fields
public void updateStatus(String id, BuildStatus status) {
Query query = Query.query(Criteria.where("id").is(id));
Update update = new Update()
.set("status", status)
.set("updatedAt", Instant.now())
.push("metadata.statusHistory", Map.of("status", status, "at", Instant.now()));
mongoTemplate.updateFirst(query, update, BuildRecord.class);
}
// Upsert
public void upsertDiscovery(String serviceId, ServiceInfo info) {
Query query = Query.query(Criteria.where("serviceId").is(serviceId));
Update update = new Update()
.set("host", info.getHost())
.set("port", info.getPort())
.set("lastHeartbeat", Instant.now())
.setOnInsert("registeredAt", Instant.now());
mongoTemplate.upsert(query, update, "service_discovery");
}
// Aggregation
public List<BuildStats> getBuildStats(String workspaceId, Instant from) {
Aggregation agg = Aggregation.newAggregation(
Aggregation.match(Criteria.where("workspaceId").is(workspaceId)
.and("createdAt").gte(from)),
Aggregation.group("status")
.count().as("count")
.avg("metadata.durationMs").as("avgDuration"),
Aggregation.sort(Sort.Direction.DESC, "count")
);
return mongoTemplate.aggregate(agg, "build_records", BuildStats.class)
.getMappedResults();
}
}# redis annotations (org.springframework.data.redis)
Redis trong CSP gánh 3 việc: caching (giảm tải cho DB), lưu session (để các service stateless dùng chung session), và distributed lock (Shedlock, để scheduled task không bị chạy trùng giữa các instance).
Spring Data Redis có 2 kiểu dùng:
- @RedisHash: lưu object thành Redis Hash, có repository pattern, query được theo field có index. Dùng cho entity cần CRUD (session, dữ liệu tạm).
- @Cacheable/@CacheEvict: cache kết quả method, code gọi không cần biết có cache. Dùng cho dữ liệu đọc nhiều, ít đổi (danh mục sản phẩm, config).
@TimeToLive rất tiện: entry nào cũng tự hết hạn, khỏi cần job dọn dẹp. Hợp với session, mã OTP, bộ đếm rate limit. Có điều Redis chỉ tự xoá key chính, còn các set index do @Indexed tạo ra thì không. Muốn Spring Data dọn luôn phần index khi entry hết hạn, bạn bật @EnableRedisRepositories(enableKeyspaceEvents = EnableKeyspaceEvents.ON_STARTUP) để app nghe keyspace event của Redis.
# @RedisHash: redis entity
@RedisHash(value = "sessions", timeToLive = 3600) // TTL = 1 hour
@Getter @Setter @Builder
public class UserSession {
@Id
private String id;
@Indexed // Searchable by this field
private String userId;
@Indexed
private String workspaceId;
private String username;
private Set<String> roles;
private Map<String, String> metadata;
private Instant createdAt;
private Instant lastAccessedAt;
@TimeToLive // Dynamic TTL per instance
private Long ttl;
}
@Repository
public interface UserSessionRepository extends CrudRepository<UserSession, String> {
List<UserSession> findByUserId(String userId);
List<UserSession> findByWorkspaceId(String workspaceId);
void deleteByUserId(String userId);
}# spring cache with redis
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
"products", defaultConfig.entryTtl(Duration.ofHours(1)),
"users", defaultConfig.entryTtl(Duration.ofMinutes(15)),
"configs", defaultConfig.entryTtl(Duration.ofHours(24))
);
return RedisCacheManager.builder(factory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
@Cacheable(value = "products", key = "#id", unless = "#result == null")
public ProductDTO getById(UUID id) {
return productRepository.findById(id)
.map(this::toDTO)
.orElseThrow(() -> new EntityNotFoundException("Product not found: " + id));
}
@Cacheable(value = "productLists", key = "#status + ':' + #pageable.pageNumber + ':' + #pageable.pageSize")
public Page<ProductDTO> findByStatus(ProductStatus status, Pageable pageable) {
return productRepository.findByStatus(status, pageable).map(this::toDTO);
}
@CachePut(value = "products", key = "#result.id")
public ProductDTO update(UUID id, UpdateProductRequest request) {
Product product = productRepository.findById(id).orElseThrow();
product.setName(request.getName());
return toDTO(productRepository.save(product));
}
@CacheEvict(value = "products", key = "#id")
public void delete(UUID id) {
productRepository.deleteById(id);
}
@CacheEvict(value = "products", allEntries = true)
public void clearCache() {
// Xóa tất cả entries trong cache "products"
}
// Multiple cache operations
@Caching(evict = {
@CacheEvict(value = "products", key = "#id"),
@CacheEvict(value = "productLists", allEntries = true) // cache key không hỗ trợ wildcard
})
public void deleteAndInvalidateList(UUID id) {
productRepository.deleteById(id);
}
}Vài chỗ dễ vấp trong đoạn trên. Spring Cache không có khái niệm key wildcard kiểu 'list:*': muốn xoá cả nhóm thì tách nhóm đó ra một cache riêng (ở đây là productLists) rồi dùng allEntries = true. Key của cache phân trang phải chứa đủ những gì làm kết quả khác đi (page, size, sort), thiếu một thành phần là trả nhầm trang. Cache thẳng Page<...> qua GenericJackson2JsonRedisSerializer cũng hay lỗi lúc đọc ra vì Jackson không dựng lại được PageImpl; an toàn hơn là cache một DTO tự định nghĩa (list + total). Cuối cùng, Spring Data Redis 4 (Spring Boot 4) chuyển sang Jackson 3 với GenericJacksonJsonRedisSerializer, bản Jackson2 vẫn còn nhưng đã deprecated.
# pagination & sorting (cross-module)
API list/search nào cũng cần phân trang, chẳng ai muốn trả 100K record trong 1 response. Spring Data có sẵn Pageable (request) và Page/Slice (response), dùng chung cho JPA, MongoDB, Elasticsearch.
Page hay Slice là bài toán đánh đổi quen thuộc: Page biết tổng số record (phải chạy thêm COUNT query, bảng lớn thì chậm), Slice chỉ biết "còn trang sau không" (nhanh hơn, hợp với infinite scroll). Chọn theo UI: có nút số trang thì cần Page, có nút "Load more" thì Slice là đủ.
# pageable, page, slice, sort
// Pageable — request object
Pageable pageable = PageRequest.of(0, 20); // page 0, size 20
Pageable pageable = PageRequest.of(0, 20, Sort.by("name")); // + sort
Pageable pageable = PageRequest.of(0, 20, Sort.by(Direction.DESC, "createdAt"));
// Sort
Sort sort = Sort.by("name"); // ASC
Sort sort = Sort.by(Direction.DESC, "createdAt"); // DESC
Sort sort = Sort.by("status").ascending().and(Sort.by("name").descending()); // Multi
// Page — kết quả phân trang (biết total)
Page<Product> page = repository.findByStatus(status, pageable);
page.getContent(); // List<Product> — data
page.getTotalElements(); // long — total records
page.getTotalPages(); // int — total pages
page.getNumber(); // int — current page (0-based)
page.getSize(); // int — page size
page.hasNext(); // boolean
page.hasPrevious(); // boolean
page.isFirst(); // boolean
page.isLast(); // boolean
// Slice — kết quả phân trang (KHÔNG biết total — faster)
Slice<Product> slice = repository.findByCategory(cat, pageable);
slice.getContent(); // List<Product>
slice.hasNext(); // boolean (fetches N+1 records to check)
// KHÔNG có getTotalElements(), getTotalPages()
// Page transformation
Page<ProductDTO> dtoPage = page.map(product -> toDTO(product));# domain events (cross-module)
Theo Domain-Driven Design (DDD), khi aggregate đổi một state quan trọng thì nó nên phát ra event. Spring Data hỗ trợ sẵn pattern này qua AbstractAggregateRoot: entity tự đăng ký event, Spring tự publish sau repository.save().
Nó nối persistence layer với kiến trúc event-driven. Order entity gọi confirm() → đăng ký OrderConfirmedEvent → repository.save() → Spring publish event → các listener xử lý (gửi thông báo, cập nhật tồn kho, ghi analytics).
Cần hiểu đúng thời điểm: event được publish ngay trong lúc gọi save() (vẫn đang trong transaction), không phải sau commit. Phần "chờ commit xong" là việc của listener: @TransactionalEventListener(phase = AFTER_COMMIT) giữ event lại và chỉ chạy khi transaction commit thành công, còn @EventListener thường thì chạy ngay. Event chỉ nằm trong bộ nhớ, nên app chết đúng lúc vừa commit là mất; việc nào bắt buộc phải tới nơi (gửi sang service khác) thì nên dùng outbox. Thêm nữa, phải gọi save() (hoặc saveAll(), delete()) thì event mới được publish, chỉ sửa entity managed rồi để dirty checking tự flush thì không có event nào cả.
// AbstractAggregateRoot — tích hợp Domain Events
@Entity
public class Order extends AbstractAggregateRoot<Order> {
@Id
private UUID id;
private OrderStatus status;
public Order confirm() {
this.status = OrderStatus.CONFIRMED;
registerEvent(new OrderConfirmedEvent(this.id)); // Queue event
return this;
}
public Order cancel(String reason) {
this.status = OrderStatus.CANCELLED;
registerEvent(new OrderCancelledEvent(this.id, reason));
return this;
}
}
// Events published khi gọi repository.save()
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository repo;
@Transactional
public OrderDTO confirm(UUID id) {
Order order = repo.findById(id).orElseThrow();
order.confirm();
repo.save(order); // → publish OrderConfirmedEvent ngay tại đây (trong TX)
return toDTO(order);
}
}
// Listener
@Component
public class OrderEventHandler {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderConfirmed(OrderConfirmedEvent event) {
notificationService.notifyCustomer(event.getOrderId());
}
}# @Transient & @ReadOnlyProperty
Không phải field nào trong Java object cũng cần lưu xuống DB. Field tính toán (discounted price = price × 0.9), giá trị cache, state tạm thời thì đánh @Transient để Spring Data bỏ qua khi đọc/ghi database.
@ReadOnlyProperty (có từ Spring Data Commons 1.9) dành cho field chỉ đọc từ DB chứ không ghi, ví dụ computed column, giá trị do trigger tự sinh, hoặc field do function của DB quản lý.
Lưu ý với entity JPA: mapping do Hibernate lo chứ không phải Spring Data, nên Hibernate không đọc @ReadOnlyProperty hay org.springframework.data.annotation.Transient. Với JPA, field không lưu phải dùng jakarta.persistence.Transient, còn field chỉ đọc thì dùng @Column(insertable = false, updatable = false) như ví dụ dưới (để @ReadOnlyProperty cũng không sao, chỉ là không có tác dụng). Hai annotation của Spring Data thì có tác dụng với các module tự lo mapping như MongoDB, Redis, JDBC.
@Entity
public class Product extends BaseEntity {
private String name;
private BigDecimal price;
@Transient // KHÔNG lưu vào database
private BigDecimal discountedPrice;
@ReadOnlyProperty // Chỉ đọc từ DB, không ghi (computed column, DB trigger)
@Column(name = "search_vector", insertable = false, updatable = false)
private String searchVector;
@PostLoad
public void computeFields() {
this.discountedPrice = this.price.multiply(BigDecimal.valueOf(0.9));
}
}# spring data rest (auto-expose repositories as rest apis)
Tính năng này làm prototype nhanh phải biết: Spring tự tạo đủ bộ REST CRUD endpoint từ repository interface. Không viết controller, không viết service, khai báo entity + repository là có API.
Lên production thì thường không ai dùng thẳng Spring Data REST (thiếu business logic, validation, phân quyền chưa đủ chi tiết). Nhưng làm API admin, tool nội bộ, hay giai đoạn prototype cần API gấp cho frontend bắt tay vào làm thì rất hợp.
// Tự động tạo REST endpoints từ repository
@RepositoryRestResource(path = "products", collectionResourceRel = "products")
public interface ProductRepository extends JpaRepository<Product, UUID> {
@RestResource(path = "by-status", rel = "by-status")
List<Product> findByStatus(@Param("status") ProductStatus status);
@RestResource(exported = false) // KHÔNG expose endpoint này
void deleteById(UUID id);
}
// Auto-generated endpoints:
// GET /products → findAll (paginated)
// GET /products/{id} → findById
// POST /products → save
// PUT /products/{id} → save (update)
// PATCH /products/{id} → partial update
// DELETE /products/{id} → delete
// GET /products/search/by-status?status=ACTIVE → custom finder
// Configuration
@Configuration
public class RestConfig implements RepositoryRestConfigurer {
@Override
public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config,
CorsRegistry cors) {
config.exposeIdsFor(Product.class, Order.class); // Include ID in response
config.setBasePath("/api"); // Base path
}
}# tra cứu nhanh: toàn bộ annotation spring data
| Annotation | Module | Mục đích |
|---|---|---|
| @Repository | Core | Data access bean marker |
| @NoRepositoryBean | Core | Base repository (no impl) |
| @Query | Core | Custom query definition |
| @Param | Core | Named parameter |
| @Modifying | Core | Write operation query |
| @CreatedDate | Core | Auto audit creation time |
| @LastModifiedDate | Core | Auto audit update time |
| @CreatedBy | Core | Auto audit creator |
| @LastModifiedBy | Core | Auto audit updater |
| @Version | Core | Optimistic locking |
| @Transient | Core | Not persisted |
| @ReadOnlyProperty | Core | Read-only field |
| @Id | Core | Primary key / Document ID |
| @Document | MongoDB | MongoDB document mapping |
| @Field | MongoDB | Custom field name |
| @Indexed | MongoDB/Redis | Index on field |
| @CompoundIndex | MongoDB | Compound index |
| @DBRef | MongoDB | Document reference |
| @Aggregation | MongoDB | Aggregation pipeline query |
| @TextIndexed | MongoDB | Full-text search index |
| @RedisHash | Redis | Redis hash entity |
| @TimeToLive | Redis | TTL for Redis entry |
| @Cacheable | Cache | Cache method result |
| @CachePut | Cache | Update cache |
| @CacheEvict | Cache | Remove from cache |
| @EnableCaching | Cache | Enable cache support |
| @EnableJpaAuditing | JPA | Enable JPA auditing |
| @EnableMongoAuditing | MongoDB | Enable MongoDB auditing |
| @RepositoryRestResource | REST | REST exposure config |
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.