Я захотел сделать эту статью, так как столкнулся с проблемой, что тяжёло было найти статьи/репозитории/видео с хорошей архитектурой, которую хоть как-то пытаются объяснить. Поэтому решил помочь тем, кто хочет начать делать у свой проект на Java Spring и пытается его сделать по best practices.
В этой статье мы обсудим:
Один из вариантов нормальной архитектуры backend-приложения
Что такое domain и что в ней хранится?
На какие вопросы отвечают конкретные слои?
Саму архитектуру я взял у автора канала Ilya Lisov из плейлиста Spring Boot [КУРС], посмотрите, возможно и вам будет полезно изучить некоторые темы, которые он там довольно неплохо объяснил.
Тут можно спросить зачем тогда объяснять архитектуру, когда он в плейлисте должен был её разъяснить. Он там и вправду многие моменты объяснял, но я предлагаю эту статью, как справочник, который поможет, когда вы будете уже сами строить Backend и думать, куда что класть, а также могли понимать, что за что отвечает.
Начало
Итак, начнём с базы, вот как выглядит сама архитектура нашего сайта:
Почему эта архитектура может кому-то не понравится?
Да, для продвинутых возможно эта архитектура не понравится, так как здесь не прям бизнес-логика будет разделена от внешних деталей, и они знают как сделать полностью чистую архитектуру, но для меня показалось это избыточным, так как проект поддерживаю пока что в одиночку и добавлять лишних абстракций и сложностей было бы геморным.
Перейдем к делу! Пройдёмся по всей архитектуре и опишем её.
Domain
Итак, domain - это понятие, взятое из domain-driven design (Предметно-ориентированное проектирование). В нашем случае:
Domain - это область знаний и правил приложения
Таким образом мы отвечаем на вопрос:
О чём вообще это приложение?
К примеру вот доменные сущности:
Article
Category
User
То есть увидев их, мы сразу можем понять это приложение имеет статьи, категории, пользователей.
В случае интернет-магазина:
Product
Order
Cart
Payment
Customer
Мы понимаем, что в ней есть продукты, заказы, корзина, итд.
В этом плане может возникнуть вопрос, domain - это просто entity?
Ответ: нет
domain отвечает на вопросы "Как это работает? Зачем это нужно? По каким правилам оно работает?"
Объясню на примере:
Есть статья, домен определяет, что статья не может существовать без автора, заголовка, описания или же можно определить, что статью нельзя лайкать, если она заархивирована (при этом мы описываем само правило тоже в коде):
class Article {
private Author author;
private Status status;
private int likesCount;
public void addLike() {
// Вот ОНА — доменная блокировка. Простое бизнес-правило.
if (this.status === 'archived') {
throw new Error("Нельзя лайкать заархивированную статью");
}
this.likesCount++;
}
}
Техническую дальнейшую реализацию домен игнорирует, это остаётся на другие слои.
К примеру, можно перехватить ошибку и отправить статус 400 с текстом ошибки.
Тут может возникнуть вопрос, почему это не сделать в сервисе?
Проблема в том, что в случае развития бизнеса у нас часто могут появляться новые фичи и помнить весь код огромного проекта невозможно, либо невероятно сложно. Получиться так, что мы будем не защищены от того, что когда-нибудь (к примеру в нашем случае) другой программист или мы напишем article.likesCount++ в другом сервисе и в итоге пойдём в обход правила.
А тут мы делаем обход невозможным, кроме как изменив код бизнес-логики. То есть это просто дополнительная оправданная защита.
В проекте WEBCOS я сильно не стал делать чистую архитектуру
В общем-то внутри domain у нас такая архитектура:
Пример кода внутри Article:
package yourpackage.domain.article;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import yourpackage.domain.article_view.ArticleView;
import yourpackage.domain.category.Category;
import yourpackage.domain.user.User;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
@Entity
@Getter
@Setter
@Table(name = "articles")
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "title")
private String title;
@Column(name = "description")
private String description;
@Column(name = "content", columnDefinition = "jsonb")
@JdbcTypeCode(SqlTypes.JSON)
private Object content;
}
Здесь не совсем domain, в идеальной архитектуре, вам нужно делать Article без @Entity и других зависимостей от библиотек (Нужно делать отдельно ArticleEntity), так как когда-то у нас может бд поменяться из Postgresql на MongoDB и тогда получается, что бизнес-часть у нас зависит от базы данных. Для меня это показалось избыточным и поэтому я оставил таким образом.
Что по поводу спецификаций?
В данном проекте Specification хранится рядом с domain-сущностью, потому что она тесно связана с правилами фильтрации Article. Однако это уже зависит от подхода. В более строгой архитектуре такие классы могут находиться ближе к репозиторям.
public class ArticleSpecification {
public static Specification<Article> hasTitle(String search) {
return (root, query, cb) ->
search == null || search.isBlank()
? null
: cb.like(
cb.lower(root.get("title")),
"%" + search.toLowerCase() + "%"
);
}
}
Можно опять же ещё сильней декомпозировать и сделать ArticleHasTitle итд, это на ваше усмотрение.
Exception
Также я храню пакет exception в domain, где у меня находятся бизнес-события, к примеру:
ArticleNotFoundException - статья не была найдена.
Домен заявляет: «Я не могу выполнить операцию по бизнес-причинам».
Но тут есть разногласия, так как если статья не найдена - значит есть поиск, а поиск не относится к бизнес части. Можно сделать отдельно папку exception в service и закидывать туда, либо в domain в зависимости от ситуации. Я не стал опять же усложнять и решил оставить всё в domain.
Config
config отвечает на вопрос:
С какими внешними настройками и технологиями должно работать приложение?
Библиотеки уже скачаны и скомпилированы. Теперь нужно связать их вместе.
Можно передать определенные настройки в minio. Их мы тоже храним в config в папке props
Пример
Сами настройки можно заранее прописать в appplication.yaml, которые затем можно забрать таким образом:
package yourpackage.config.props;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@Data
@ConfigurationProperties(prefix = "minio")
public class MinioProperties {
private String bucket;
private String url;
private String accessKey;
private String secretKey;
}
В application.yaml прописать
minio:
bucket: ${MINIO_BUCKET}
url: ${MINIO_URL}
accessKey: ${MINIO_ACCESS}
secretKey: ${MINIO_SECRET}
Сами настройки можно прописать .env
Не забудьте добавить:
spring:
config:
import: optional:file:.env[.properties]
Само создание и настройку объекта из внешней библиотеки внутри прописываем в конфиге:
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(minioProperties.getUrl())
.credentials(minioProperties.getAccessKey(),
minioProperties.getSecretKey())
.build();
}
Таким образом, мы связываем наше приложение с библиотекой minio.
Repository
Repository отвечает на вопрос:
Как управлять постоянным хранением (сохранять, обновлять, удалять и находить) наших бизнес-объектов?
Постоянное хранилище это обычно:
PostgreSQL;
MySQL;
MongoDB;
Redis;
файловое хранилище (иногда).
Repository - это граница между бизнес-логикой и постоянным хранилищем.
Вот пример репозитория:
package yourpackage.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import yourpackage.domain.article.Article;
public interface ArticleRepository extends JpaRepository<Article, Long>, JpaSpecificationExecutor<Article> {
}
Service
Service отвечает на вопрос:
Что надо сделать?
Примеры:
создать статью
проверить пользователя
отправить событие
Service - это уже про то, как сделать бизнес-операцию и можно ли её вообще сделать.
Мы храним отдельно impl и отдельно интерфейс сервиса:
Это нужно, чтобы отделить сервис от того, что он умеет делать и как он это делает. Мы не пишем конкретно как он получает, удаляет или обновляет статьи, а только говорим про возможность.
Также это позволяет хранить несколько реализаций, к примеру:
@Service("databaseArticleService")
@RequiredArgsConstructor
public class ArticleServiceImpl implements ArticleService {
private final ArticleRepository articleRepository;
@Override
public Article getById(Long id) {
return articleRepository.findById(id)
.orElseThrow(
() -> new ResourceNotFoundException("Article not found")
);
}
@Override
public Article create(Article article) {
return articleRepository.save(article);
}
@Override
public void delete(Long id) {
articleRepository.deleteById(id);
}
}
@Service("cachedArticleService")
@RequiredArgsConstructor
public class CachedArticleServiceImpl implements ArticleService {
private final ArticleRepository articleRepository;
private final RedisTemplate<String, Article> redisTemplate;
@Override
public Article getById(Long id) {
String key = "article:" + id;
Article cachedArticle = redisTemplate.opsForValue()
.get(key);
if (cachedArticle != null) {
return cachedArticle;
}
Article article = articleRepository.findById(id)
.orElseThrow(
() -> new ResourceNotFoundException("Article not found")
);
redisTemplate.opsForValue()
.set(key, article);
return article;
}
@Override
public Article create(Article article) {
return articleRepository.save(article);
}
@Override
public void delete(Long id) {
articleRepository.deleteById(id);
redisTemplate.delete("article:" + id);
}
}Но по итогу в контроллере мы поменяем только Qualifier, так как интерфейс одинаковый:
@RestController
@RequiredArgsConstructor
public class ArticleController {
private final
@Qualifier("cachedArticleService")
ArticleService articleService;
}В реальности это не так часто нужно, то есть по вашему усмотрению, вы можете это упростить и хранить только классы с реализацией.
Web
web отвечает на вопрос:
Как приложение принимает запросы извне?
Например:
POST /api/articlesОн знает:
HTTP;
статус-коды;
request body;
headers.
Но он НЕ должен знать:
как создать статью;
как сделать slug;
как проверить категории.
При этом сам web мы делим на более мелкие части, так как у нас есть сам вид получаемых ответов, есть запросы отправляемые на сервер, и.т.д.
Рассмотрим их:
- Controller
controller отвечает на вопрос:
как получить запрос и как запустить нужный сценарий?
контроллер получает запрос и уже через service выполняет нужный сценарий, при этом контроллер не знает бизнес-правила, SQL и как сделать сам сценарий.
Controller — это граница между внешним миром и приложением
Пример контроллера (в нём используются мапперы, которые мы разберём позже и сервисы, а также проверяются входные данные DTO через @Validated):
@RestController
@RequestMapping("/api/v1/articles")
@RequiredArgsConstructor
@Validated
public class ArticleController {
private final ArticleService articleService;
private final ArticleMapper articleMapper;
@PostMapping
public ArticleDto create(
Authentication authentication,
final @Validated(OnCreate.class) @RequestBody ArticleDto articleDto
) {
Long userId = Long.valueOf(authentication.getName());
Article article = articleMapper.toEntity(articleDto);
Article createdArticle = articleService.create(article, articleDto.getCategoryIds(), userId);
return articleMapper.toDto(createdArticle);
}
}Также в контроллере мы храним перехватчик ошибок ControllerAdvice:
@RestControllerAdvice
public class ControllerAdvice {
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ExceptionBody handleException(
final Exception e
) {
e.printStackTrace();
return new ExceptionBody("Internal error.");
}
}Он находится здесь, так как мы перехватываем ошибки, преобразуем и отдаём клиенту (то есть опять же связываем бизнес-логику и внешний мир)
- Mapper
Мапперы отвечают на вопрос:
Как перевести один объект в другой?
Он находится в папке web, так как он говорит как переделать объект, который получен из внешнего мира и наоборот. То есть эта опять же некая прослойка между внешним миром и бизнес-логикой.
Пример общего интерфейса маппера с помощью библиотеки mapstruct:
package yourpackage.web.mapper;
import org.mapstruct.MappingTarget;
import java.util.List;
public interface Mappable<E, D> {
E toEntity(D dto);
D toDto(E entity);
List<D> toDto(List<E> entities);
}а также применение этого общего интерфейса:
@Mapper(componentModel = "spring")
public interface ArticleMapper extends Mappable<Article, ArticleDto> {
// По надобности можете перезаписывать функции, а так mapstruct сделает реализацию
}- DTO
DTO (Data Transfer Object) - объект передачи данных, он отвечает на вопрос:
В каком виде данные приходят и уходят наружу?
Как раз чтобы преобразовать данные из внешнего мира в данные удобные нам, мы используем мапперы.
Ещё DTO выполняет роль валидации входных данных, так как они приходят из внешнего мира и удобно их валидировать прямо тут.
Пример:
@Getter
@Setter
public class ArticleDto {
@NotNull(
message = "Id must not be null.",
groups = OnUpdate.class
)
private Long id;
@NotNull(
message = "Title must be not null.",
groups = {OnCreate.class, OnUpdate.class}
)
@Length(
max = 255,
message = "Title length must be smaller than 255 symbols."
)
private String title;
@NotNull(
message = "Description must be not null.",
groups = {OnCreate.class, OnUpdate.class}
)
@Length(
max = 255,
message = "Description length must be smaller than 255 symbols."
)
private String description;
@NotNull(
message = "Content must be not null.",
groups = {OnCreate.class, OnUpdate.class}
)
private Object content;
}Вы также могли заметить, что здесь есть группы, по типу groups = {OnCreate.class, OnUpdate.class} и в контроллере @Validated(OnCreate.class), чтобы сделать у себя также, мы добавляем пакет validation в dto, внутри которого будут пустые интерфейсы onCreate и onUpdate:
public interface OnCreate {
}
public interface OnUpdate {
}Это поможет группировать валидацию отдельно при создании и при редактировании.
Также в dto находится ExceptionBody в пакете exception, который отвечает за вид данных, которые мы вернём в клиенту:
package yourpackage.web.dto.exception;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.Map;
@Data
@AllArgsConstructor
public class ExceptionBody {
private String message;
private Map<String, String> errors;
public ExceptionBody(final String message){
this.message = message;
}
}- Security
security отвечает на вопрос:
Кто пришёл в приложение и имеет ли он право попасть внутрь?
Именно поэтому в целом и находится в web слое, ведь опять же работает с внешним миром, а не реализует бизнес-правила.
По реализации тут уже ваш выбор, можно использовать как собственную, так и готовые, выборов много.
Далее осталось 2 слоя, которые я бы выделил отдельно (они уже находятся не в web).
Scheduler
scheduler отвечает на вопрос:
Что должно происходить автоматически?
То просто раз в какой-то срок, будет вызываться метод. Он использует сервисы, чтобы взаимодействовать с бизнес-логикой, но при эттом
Пример (раз в 30 минут пересчёт рекомендаций пользователей):
@Component
@RequiredArgsConstructor
public class RecommendationScheduler {
private final ArticleSimilarityService articleSimilarityService;
@Scheduled(fixedRate = 1800000) // каждые 30 минут
public void retrainRecommendations() {
articleSimilarityService.train();
}
} Aspect
Что нужно сделать вокруг выполнения кода?
Например:
логирование, метрики, аудит
Пример кода логирования:
@Component
@Aspect
@Slf4j
public class LoggingAspect {
@Pointcut("execution(public * yourpackage.web.controller.*.*(..))")
public void controllerLog() {}
@Pointcut("execution(public * yourpackage.service.*.*(..))")
public void serviceLog() {}
@Before("controllerLog()")
public void doBeforeControllerLog(JoinPoint joinPoint) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = null;
if (attributes != null) {
request = attributes.getRequest();
}
if(request != null) {
log.info("New Request -f IP: {}, URL: {}, HTTP_METHOD: {}, CONTROLLER_METHOD: {}.{}",
request.getRemoteAddr(),
request.getRequestURL().toString(),
request.getMethod(),
joinPoint.getSignature().getDeclaringTypeName(),
joinPoint.getSignature().getName()
);
}
}
@Before("serviceLog()")
public void doBeforeServiceLog(JoinPoint joinPoint) {
String className = joinPoint.getSignature().getDeclaringTypeName();
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
String argsString = args.length > 0 ? Arrays.toString(args) : "METHOD HAS NO ARGUMENTS";
log.info("Run Service - SERVICE_METHOD: {}.{}, METHOD ARGUMENTS: {}",
className,
methodName,
argsString
);
}
@AfterReturning(returning = "returnObject", pointcut = "controllerLog()")
public void doAfterControllerLog(Object returnObject) {
log.info("Controller return value: {}", returnObject);
}
@After("controllerLog()")
public void doAfterControllerLog(JoinPoint joinPoint) {
log.info("Controller method executed successfully: {}.{}",
joinPoint.getSignature().getDeclaringTypeName(),
joinPoint.getSignature().getName()
);
}
@AfterThrowing(throwing = "ex", pointcut = "controllerLog()")
public void throwsException(JoinPoint joinPoint, Exception ex) {
String methodName = joinPoint.getSignature().getName();
String className = joinPoint.getTarget().getClass().getSimpleName();
log.error("Exception in {}.{} with arguments {}. Exception message: {}",
className,
methodName,
Arrays.toString(joinPoint.getArgs()),
ex.getMessage()
);
}
}Финал
Получаем такую структуру:
В целом, на этом всё, мы разобрали все слои, и смогли объяснить почему мы их туда положили, для чего они служат и что туда класть.
К примеру, можно сейчас понять, что раз controller и dto находятся вместе и относятся к связи между внешним миром и приложением, то сервис, который знает только бизнес-логику) не должен использовать dto в своей реализации, как раз для этого мы и делали mapper из dto в entity, чтобы передать в сервис.
То есть знания будут вам помогать легче отвечать на некоторые вопросы реализации, вложения новых пакетов и тому подобного.
Благодарю вас за просмотр статьи! Надеюсь чем-то смог вам помочь и вы сможете делать новые проекты легче. Вы можете подписаться на наш телеграм-канал, чтобы видеть такие статьи чаще. Мне нравится программирование и вкладываться в это дело, поэтому буду стараться увеличивать планку и делать более интересные и полезные статьи.
Желаю успешных проектов!


