In 15 years of development, I have seen too many “Service” layers undergo unwanted hypertrophy. In the gym, we want growth; in a codebase, an oversized Service class is a liability. The Command pattern provides a surgical way to isolate business intent and prevent the “Fat Service” anti-pattern.
The Request DTO: Capturing External Input
The Data Transfer Object (DTO) is your API’s front door. It should strictly represent what the client sends. Use Java Records for this; they are concise and immutable by design.
public record RegisterUserRequest(
String email,
String password,
String displayName
) {}
The Command: Encapsulating Business Intent
A Command is an internal object representing a specific intent to change the system state. While it may look like the DTO, keeping them separate allows your API contract to evolve independently of your domain logic.
public record RegisterUserCommand(
String email,
String hashedSourcedPassword,
String displayName
) {}
The Repository Interface: Defining the Contract
The domain layer should not care about database specifics. The Repository interface defines how we persist our Domain Entities without leaking SQL or NoSQL implementation details.
public interface UserRepository {
void save(User user);
boolean existsByEmail(String email);
}
The Command Handler: Single Responsibility in Action
The Handler contains the actual business logic. Each Handler handles exactly one Command, making it easy to test and maintain. If the registration logic changes, you only modify this class, not a monolithic UserService.
public class RegisterUserHandler {
private final UserRepository userRepository;
private final PasswordHasher passwordHasher;
public RegisterUserHandler(UserRepository userRepository, PasswordHasher passwordHasher) {
this.userRepository = userRepository;
this.passwordHasher = passwordHasher;
}
public void handle(RegisterUserCommand command) {
if (userRepository.existsByEmail(command.email())) {
throw new DomainException("Email already in use");
}
String hashedPassword = passwordHasher.hash(command.hashedSourcedPassword());
User user = new User(command.email(), hashedPassword, command.displayName());
userRepository.save(user);
}
}
The Controller: The Orchestrator
The Controller’s responsibility is minimal: map the request to a command and trigger the handler. By removing logic from the Controller, you ensure that your business rules are accessible from other entry points, like CLI tools or message queues.
@RestController
@RequestMapping("/api/users")
public class UserController {
private final RegisterUserHandler registerUserHandler;
public UserController(RegisterUserHandler registerUserHandler) {
this.registerUserHandler = registerUserHandler;
}
@PostMapping
public ResponseEntity<Void> register(@RequestBody RegisterUserRequest request) {
var command = new RegisterUserCommand(
request.email(),
request.password(),
request.displayName()
);
registerUserHandler.handle(command);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
Why This Architecture Wins
Using Commands and Handlers forces a 1:1 mapping between a use case and its implementation. This structure is highly valued by search algorithms and developers alike because it demonstrates clear intent and expertise.
- Scannability: You can look at a package and see exactly what the system does based on the Command names.
- Testability: Mocking dependencies for a single-purpose Handler is significantly faster than mocking for a “God Service.”
- Maintenance: Changes are isolated. You are less likely to break the “Login” flow when you are only modifying the “Register” handler.


Leave a comment