Understanding Command Query Responsibility Segregation in the User Management API
The User Management API implements CQRS (Command Query Responsibility Segregation) to separate read and write operations, enabling independent optimization and scaling strategies for each type of operation.
CQRS is a pattern that separates operations into two categories:
Commands - Operations that change state (Create, Update, Delete)
Queries - Operations that read state without changing it (Read, List, Search)
By separating these concerns, each side can be optimized independently.
In this implementation, both sides use the same PostgreSQL database, but the pattern enables future scaling by separating read replicas, adding caching, or even using different databases for reads vs writes.
// src/main/java/com/fbaron/user/core/repository/UserCommandRepository.javapackage com.fbaron.user.core.repository;import com.fbaron.user.core.model.User;/** * Outbound port (Driven Port) — write side of the CQRS split. * Separating commands from queries allows independent optimization * and scaling strategies. */public interface UserCommandRepository { /** * Persists a new or updated user and returns the saved state. * Handles both creation and updates, including soft-delete (active=false). * * @param user domain model to persist * @return the saved User with its generated primary key populated */ User save(User user);}
// src/main/java/com/fbaron/user/core/repository/UserQueryRepository.javapackage com.fbaron.user.core.repository;import com.fbaron.user.core.model.User;import java.util.List;import java.util.Optional;import java.util.UUID;/** * Outbound port (Driven Port) — read side of the CQRS split. * The data module provides the concrete JPA implementation; * the core knows nothing about JPA. */public interface UserQueryRepository { /** * Retrieves all persisted users ordered by creation date descending. */ List<User> findAll(); /** * Looks up a user by its primary key. */ Optional<User> findById(UUID id); /** * Checks for username uniqueness before registration. */ boolean existsByUsername(String username); /** * Checks for email uniqueness before registration or update. */ boolean existsByEmail(String email); /** * Checks email uniqueness while excluding the user being updated. */ boolean existsByEmailAndIdNot(String email, UUID id);}
Multiple read methods optimized for different use cases
Existence checks for business rule validation
No side effects - purely read-only
Used by: findAll, findById, and validation logic
Notice the existsByEmailAndIdNot method - this is a query-side optimization for the “update user” validation logic, allowing efficient uniqueness checks without loading full entities.
Uses both query (validation) and command (persistence):
@Overridepublic User register(User user) { var username = user.getUsername(); var email = user.getEmail(); log.info("Registering new user with username={}", username); // Query side: validate uniqueness constraints if (userQueryRepository.existsByUsername(username)) { throw new UserAlreadyExistsException("username", username); } if (userQueryRepository.existsByEmail(email)) { throw new UserAlreadyExistsException("email", email); } // Command side: persist new user user.activate(); User saved = userCommandRepository.save(user); log.info("User registered successfully with id={}", saved.getId()); return saved;}
@Overridepublic User findById(UUID id) { log.info("Fetching user by id={}", id); return userQueryRepository.findById(id) .orElseThrow(() -> new UserNotFoundException(id));}
Implementing both interfaces in a single adapter works well when using the same database. For more complex scenarios (separate read/write databases, caching, etc.), you could split this into two separate adapters.