← Назад к вопросам

Расскажи про дизайн функционала в текущем проекте

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();
    }
}

Ключевые преимущества этого подхода

  1. Разделение ответственности — каждый слой имеет четкую роль
  2. Тестируемость — domain logic тестируется без БД и HTTP
  3. Переиспользуемость — one use case может быть вызван из разных мест
  4. Масштабируемость — легко добавлять новые функции
  5. Поддерживаемость — новые разработчики быстро понимают структуру

Паттерны, используемые в проекте

  • DDD: Aggregates, Value Objects, Domain Events
  • CQRS: Отделение read моделей от write моделей для complex scenarios
  • Event Sourcing: Сохранение истории всех изменений
  • Repository Pattern: Абстракция доступа к данным
  • Saga Pattern: Распределённые транзакции для сложных бизнес-процессов

Этот подход позволяет нам эффективно масштабировать систему, быстро добавлять новые функции и поддерживать высокое качество кода.

Расскажи про дизайн функционала в текущем проекте | PrepBro