← Назад к вопросам
Расскажи про дизайн функционала в текущем проекте
1.0 Junior🔥 201 комментариев
#SOLID и паттерны проектирования#Soft Skills и карьера
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Расскажи про дизайн функционала в текущем проекте
Контекст проекта
В моем текущем проекте я работаю над системой управления подписками для мобильного приложения. Это высоконагруженный сервис, обслуживающий миллионы пользователей с критическими требованиями к надежности, масштабируемости и быстродействию. Основной стек: Spring Boot, PostgreSQL, Kafka, Redis.
Архитектура и слои
Мы придерживаемся Clean Architecture с DDD (Domain-Driven Design):
Presentation Layer (API Controllers, REST)
↓
Application Layer (Use Cases, DTOs, Validators)
↓
Domain Layer (Entities, Value Objects, Services)
↓
Infrastructure Layer (Repositories, External APIs, Event Bus)
Данный подход обеспечивает полную независимость бизнес-логики от технических деталей.
Пример: Функционал управления подписками
1. Domain Layer (Ядро бизнес-логики)
// Value Object для представления периода подписки
public class SubscriptionPeriod {
private final LocalDate startDate;
private final LocalDate endDate;
public SubscriptionPeriod(LocalDate startDate, int durationDays) {
if (durationDays <= 0) {
throw new InvalidSubscriptionPeriodException("Duration must be positive");
}
this.startDate = startDate;
this.endDate = startDate.plusDays(durationDays);
}
public boolean isActive() {
LocalDate today = LocalDate.now();
return !today.isBefore(startDate) && today.isBefore(endDate);
}
public int daysRemaining() {
return (int) ChronoUnit.DAYS.between(LocalDate.now(), endDate);
}
}
// Aggregate Root
public class Subscription {
private final SubscriptionId id;
private final UserId userId;
private SubscriptionStatus status; // ACTIVE, EXPIRED, CANCELLED
private SubscriptionPeriod period;
private Money amount;
private List<SubscriptionEvent> domainEvents = new ArrayList<>();
public void activate(SubscriptionPeriod period) {
if (this.status != SubscriptionStatus.PENDING) {
throw new SubscriptionAlreadyActivatedException();
}
this.period = period;
this.status = SubscriptionStatus.ACTIVE;
// Генерируем domain event
addDomainEvent(new SubscriptionActivatedEvent(this.id, this.userId, period));
}
public void cancel(String reason) {
if (this.status == SubscriptionStatus.CANCELLED) {
throw new SubscriptionAlreadyCancelledException();
}
this.status = SubscriptionStatus.CANCELLED;
addDomainEvent(new SubscriptionCancelledEvent(this.id, this.userId, reason));
}
public void renew(SubscriptionPeriod newPeriod) {
if (!this.status.canBeRenewed()) {
throw new SubscriptionCannotBeRenewedException();
}
this.period = newPeriod;
this.status = SubscriptionStatus.ACTIVE;
addDomainEvent(new SubscriptionRenewedEvent(this.id, this.userId, newPeriod));
}
private void addDomainEvent(SubscriptionEvent event) {
domainEvents.add(event);
}
public List<SubscriptionEvent> getDomainEvents() {
return domainEvents;
}
}
// Domain Service (инкапсулирует сложную бизнес-логику)
public class SubscriptionRenewalService {
private final SubscriptionRepository repository;
private final PaymentGateway paymentGateway;
public void renewExpiringSubscriptions() {
// Находим подписки, заканчивающиеся в течение 3 дней
List<Subscription> expiringSubscriptions = repository.findExpiringWithin(3);
for (Subscription subscription : expiringSubscriptions) {
try {
// Проверяем наличие valid платежного средства
PaymentMethod paymentMethod = paymentGateway.getPaymentMethod(subscription.getUserId());
// Пытаемся провести платёж
PaymentResult result = paymentGateway.charge(paymentMethod, subscription.getAmount());
if (result.isSuccessful()) {
// Возобновляем подписку
SubscriptionPeriod newPeriod = new SubscriptionPeriod(
LocalDate.now(),
subscription.getDurationDays()
);
subscription.renew(newPeriod);
repository.save(subscription);
} else {
// Отправляем уведомление пользователю
sendPaymentFailureNotification(subscription.getUserId(), result.getErrorMessage());
}
} catch (PaymentGatewayException e) {
// Логируем ошибку для retry
logPaymentRetry(subscription.getId(), e);
}
}
}
}
2. Application Layer (Orchestration)
// Use Case DTO
public class CreateSubscriptionRequest {
private final UserId userId;
private final String planId;
private final PaymentMethodId paymentMethodId;
// Constructor и getters
}
public class SubscriptionResponseDTO {
private final String id;
private final String status;
private final String planName;
private final LocalDate startDate;
private final LocalDate endDate;
private final String amount;
}
// Application Service (Use Case)
@Service
public class CreateSubscriptionUseCase {
private final SubscriptionRepository subscriptionRepository;
private final SubscriptionPlanService planService;
private final PaymentGateway paymentGateway;
private final SubscriptionEventPublisher eventPublisher;
private final Logger logger = LoggerFactory.getLogger(CreateSubscriptionUseCase.class);
@Transactional
public SubscriptionResponseDTO execute(CreateSubscriptionRequest request) {
// 1. Валидируем входные данные
validateRequest(request);
// 2. Получаем план подписки
SubscriptionPlan plan = planService.getPlan(request.getPlanId());
if (plan == null) {
throw new SubscriptionPlanNotFoundException();
}
// 3. Создаём сущность (Domain Entity)
Subscription subscription = new Subscription(
SubscriptionId.generate(),
request.getUserId(),
Money.of(plan.getPrice(), Currency.USD)
);
// 4. Проводим платёж
PaymentResult paymentResult = paymentGateway.charge(
request.getPaymentMethodId(),
subscription.getAmount()
);
if (!paymentResult.isSuccessful()) {
throw new PaymentFailedException(paymentResult.getErrorMessage());
}
// 5. Активируем подписку (вызываем бизнес-логику на domain entity)
SubscriptionPeriod period = new SubscriptionPeriod(
LocalDate.now(),
plan.getDurationDays()
);
subscription.activate(period);
// 6. Сохраняем в БД
subscriptionRepository.save(subscription);
// 7. Публикуем domain events (для асинхронной обработки)
for (SubscriptionEvent event : subscription.getDomainEvents()) {
eventPublisher.publish(event);
}
logger.info("Subscription created: {} for user: {}",
subscription.getId(), subscription.getUserId());
// 8. Возвращаем ответ
return new SubscriptionResponseDTO(
subscription.getId().toString(),
subscription.getStatus().name(),
plan.getName(),
period.getStartDate(),
period.getEndDate(),
subscription.getAmount().toString()
);
}
private void validateRequest(CreateSubscriptionRequest request) {
if (request.getUserId() == null) {
throw new ValidationException("User ID is required");
}
if (request.getPlanId() == null || request.getPlanId().isEmpty()) {
throw new ValidationException("Plan ID is required");
}
}
}
3. Infrastructure Layer
// Repository Implementation
@Repository
public class SubscriptionRepositoryImpl implements SubscriptionRepository {
private final JdbcTemplate jdbcTemplate;
private final SubscriptionMapper mapper;
@Override
public void save(Subscription subscription) {
String sql = "INSERT INTO subscriptions (id, user_id, status, start_date, end_date, amount) " +
"VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET " +
"status = EXCLUDED.status, end_date = EXCLUDED.end_date";
jdbcTemplate.update(sql,
subscription.getId().toString(),
subscription.getUserId().toString(),
subscription.getStatus().name(),
subscription.getPeriod().getStartDate(),
subscription.getPeriod().getEndDate(),
subscription.getAmount().getAmount()
);
}
@Override
public List<Subscription> findExpiringWithin(int days) {
String sql = "SELECT * FROM subscriptions " +
"WHERE status = 'ACTIVE' AND end_date <= NOW() + INTERVAL '1 day' * ? " +
"AND end_date > NOW() " +
"FOR UPDATE SKIP LOCKED";
return jdbcTemplate.query(sql, new Object[]{days},
(rs, rowNum) -> mapper.mapToSubscription(rs));
}
}
// Event Publisher (через Kafka)
@Component
public class SubscriptionEventPublisher {
private final KafkaTemplate<String, SubscriptionEvent> kafkaTemplate;
public void publish(SubscriptionEvent event) {
kafkaTemplate.send("subscription-events", event.getSubscriptionId().toString(), event);
}
}
// Event Listener (обработка domain events)
@Component
public class SubscriptionEventListener {
private final NotificationService notificationService;
private final AnalyticsService analyticsService;
@KafkaListener(topics = "subscription-events")
public void handleSubscriptionActivated(SubscriptionActivatedEvent event) {
// Отправляем welcome email
notificationService.sendWelcomeEmail(event.getUserId());
// Логируем событие для аналитики
analyticsService.recordSubscriptionActivation(event.getUserId());
}
@KafkaListener(topics = "subscription-events")
public void handleSubscriptionCancelled(SubscriptionCancelledEvent event) {
// Отправляем уведомление о отмене
notificationService.sendCancellationNotification(event.getUserId(), event.getReason());
// Запускаем win-back кампанию
analyticsService.recordChurn(event.getUserId());
}
}
4. Presentation Layer (API)
@RestController
@RequestMapping("/api/v1/subscriptions")
public class SubscriptionController {
private final CreateSubscriptionUseCase createSubscriptionUseCase;
private final CancelSubscriptionUseCase cancelSubscriptionUseCase;
@PostMapping
public ResponseEntity<SubscriptionResponseDTO> createSubscription(
@RequestBody CreateSubscriptionRequest request) {
SubscriptionResponseDTO response = createSubscriptionUseCase.execute(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@DeleteMapping("/{subscriptionId}")
public ResponseEntity<Void> cancelSubscription(
@PathVariable String subscriptionId,
@RequestParam(required = false) String reason) {
cancelSubscriptionUseCase.execute(subscriptionId, reason);
return ResponseEntity.noContent().build();
}
}
Ключевые преимущества этого подхода
- Разделение ответственности — каждый слой имеет четкую роль
- Тестируемость — domain logic тестируется без БД и HTTP
- Переиспользуемость — one use case может быть вызван из разных мест
- Масштабируемость — легко добавлять новые функции
- Поддерживаемость — новые разработчики быстро понимают структуру
Паттерны, используемые в проекте
- DDD: Aggregates, Value Objects, Domain Events
- CQRS: Отделение read моделей от write моделей для complex scenarios
- Event Sourcing: Сохранение истории всех изменений
- Repository Pattern: Абстракция доступа к данным
- Saga Pattern: Распределённые транзакции для сложных бизнес-процессов
Этот подход позволяет нам эффективно масштабировать систему, быстро добавлять новые функции и поддерживать высокое качество кода.