Принцип единственной ответственности (Single Responsibility Principle — SRP, буква S в аббревиатуре SOLID), описанный Робертом Мартином, гласит: «Класс должен иметь только одну причину для изменения».
Обратите внимание на следующий код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
public class TopicService { private final EntityManager entityManager; @Transactional public Topic update(int id, String title, String content, int userAccountId) { if (title == null || title.length() > 255) { throw new IllegalArgumentException("Title is too long"); } if (content.length() > 65535) { throw new IllegalArgumentException("Content is too long"); } return Optional.ofNullable(this.entityManager.find(Topic.class, id)) .filter(topic -> topic.getUserAccountId() == userAccountId) .map(topic -> { topic.setContent(content); topic.setTitle(title); topic.setDateLastModified(new Date()); return this.entityManager.merge(topic); }) .orElseThrow(EntityNotFoundException::new); } } |
В примере приведён метод сохранения изменений в записи типа Topic. Однако этот метод выполняет слишком много действий: валидацию полученных данных, поиск существующего объекта типа Topic, проверку прав доступа пользователя на этот объект, применение и сохранение изменений. И все эти действия могут меняться независимо друг от друга.
Следуя логике принципа единственной ответственности, нам следует выделить работу с источником данных, валидацию и проверку прав доступа, как минимум, в отдельные методы, а наиболее правильно — в отдельные классы.
Выделение логики в отдельные методы
В первом варианте мы разделим логику между методами класса TopicService. Кроме этого данные, получаемые методом update, логично будет инкапсулировать в одном классе:
1 2 3 4 5 6 7 8 9 |
public class UpdateTopicAction { @NotBlank @Size(max = 255) private String title; @Size(max = 65535) private String content; } |
В результате выделения логики валидации, работы с источником данных и проверки прав доступа в отдельные методы должен получиться примерно такой код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
public class TopicService { private final EntityManager entityManager; private final Validator validator; private void validate(UpdateTopicAction action) { Set<ConstraintViolation<UpdateTopicAction>> violations = this.validator.validate(action); if (!violations.isEmpty()) { throw new IllegalArgumentException("Invalid data passed"); } } private Topic findOneById(int id) { return Optional.ofNullable(this.entityManager.find(Topic.class, id)) .orElseThrow(EntityNotFoundException::new); } @Transactional protected Topic save(Topic topic) { return this.entityManager.merge(topic); } private void checkAccess(Topic topic, int userAccountId) { if (topic.getUserAccountId() != userAccountId) { throw new EntityActionForbiddenException(); } } public Topic update(UpdateTopicAction action, int id, int userAccountId) { this.validate(action); Topic topic = this.findOneById(id); this.checkAccess(topic, userAccountId); topic.setContent(action.getContent()); topic.setTitle(action.getContent()); topic.setDateLastModified(new Date()); return this.save(topic); } } |
Теперь этот код на уровне методов соответствует принципу единственной ответственности, чего не скажешь о всём классе TopicService. Не смотря на то, что методы findOneById, save, checkAccess и validate скрыты от внешнего наблюдателя, класс всё равно выполняет много действий.
Однако даже при этом код уже более удобен для понимания, а так же пригоден для повторного использования.
Выделение логики в отдельные классы
При выделении логики валидации, проверки прав и работы с источниками данных в отдельные классы, код приложения станет ещё удобнее и понятнее.
Таким образом мы получим класс, работающий с источником данных:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class TopicRepository { private final EntityManager entityManager; public Optional<Topic> findOneById(int id) { return Optional.ofNullable(this.entityManager.find(Topic.class, id)); } @Transactional public Topic save(Topic topic) { return this.entityManager.merge(topic); } } |
Класс, выполняющий валидацию UpdateTopicAction:
1 2 3 4 5 6 7 8 |
public class UpdateTopicActionValidationService { private final Validator validator; public Set<ConstraintViolation<UpdateTopicAction>> validate(UpdateTopicAction action) { return this.validator.validate(action); } } |
Класс, проверяющий доступ пользователя к объекту Topic:
1 2 3 4 5 6 |
public class TopicAccessCheckService { public boolean hasAccess(Topic topic, int userAccountId) { return topic.getUserAccountId() == userAccountId; } } |
Итоговый вид нашего сервиса будет следующим:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
public class TopicService { private final TopicRepository topicRepository; private final UpdateTopicActionValidationService updateTopicActionValidationService; private final TopicAccessCheckService topicAccessCheckService; public Topic update(UpdateTopicAction action, int id, int userAccountId) { Topic topic = this.topicRepository.findOneById(id) .orElseThrow(EntityNotFoundException::new); if (!this.topicAccessCheckService.hasAccess(topic, userAccountId)) { throw new EntityActionForbiddenException(); } Set<ConstraintViolation<UpdateTopicAction>> violations = this.updateTopicActionValidationService.validate(action); if (!violations.isEmpty()) { throw new ValidationFailedException(violations); } topic.setTitle(action.getTitle()); topic.setContent(action.getContent()); topic.setDateLastModified(new Date()); return this.topicRepository.save(topic); } } |
Таким образом мы на практике применили принцип единой ответственности.