= Напреден Апликативен Развој === 1. Pooling - Hikari Configuration === Pooling претставува процес каде што не се отвара нова конекција за секој request, туку има константен број на отворени конекције кои се доделуваат на различни requests. Со ова значително се намалува времето по request бидејќи не се губи време за отварање на нова и затворање на стара конекција. Во мојот проект ова го правам со Hikari Pool во Java Spring Boot. Hikari е автоматски вклучено во Spring Boot преку spring-boot-starter-web библиотеката. Пред (конзервативна конфигурација за SSH tunnel): {{{ spring.datasource.hikari.maximum-pool-size=${SPRING_DATASOURCE_HIKARI_MAX_POOL_SIZE:2} spring.datasource.hikari.minimum-idle=${SPRING_DATASOURCE_HIKARI_MIN_IDLE:1} }}} След (поголем default pool за поголем број корисници): {{{ # Максимален број на конекции (default 20, може да се override преку env) spring.datasource.hikari.maximum-pool-size=${SPRING_DATASOURCE_HIKARI_MAX_POOL_SIZE:20} # Минимум конекции кои секогаш ќе се спремни за да преземат нови requests spring.datasource.hikari.minimum-idle=${SPRING_DATASOURCE_HIKARI_MIN_IDLE:5} # Колку долго да се чека за конекција пред requestot да е одбиен spring.datasource.hikari.connection-timeout=30000 # Колку долго може да седи една конекција слободна spring.datasource.hikari.idle-timeout=600000 # Максимално времетраење на една конекција spring.datasource.hikari.max-lifetime=1800000 }}} === 2. Транзакции - Write vs Read Operations === Основна философија: Транзакциите се применуваат САМО каде што се необходни. ==== Write Operations - Се Задржуваат Транзакции ==== ===== AuthService - Register (Транзакционо) ===== {{{ @Transactional public AuthResponse register(RegisterRequest request) { if (userRepository.existsByEmail(request.getEmail())) { throw new RuntimeException("Email already exists"); } if (userRepository.existsByUsername(request.getUsername())) { throw new RuntimeException("Username already exists"); } User user = new User(); user.setEmail(request.getEmail().trim().toLowerCase()); user.setUsername(request.getUsername().trim()); user.setPassword(passwordEncoder.encode(request.getPassword())); user = userRepository.save(user); String token = jwtService.generateToken(user.getUserId(), user.getUsername(), user.getEmail()); return new AuthResponse(token, "Bearer", user.getUserId(), user.getUsername(), user.getEmail()); } }}} Причина: Проверка дали email/username постои → unique constraint validation → single write. Транзакцијата гарантира consistency. ===== DisciplineService - Compute Daily Completion (Транзакционо + Batch Optimized) ===== Пред (неоптимално - една insert per task): {{{ for (Task t : finishedTasks) { TaskDailyCompletion link = new TaskDailyCompletion(); link.setTask(t); link.setDailyCompletion(savedCompletion); link.setId(new TaskDailyCompletionId(t.getTaskId(), savedCompletion.getDailyCompletionId())); taskDailyCompletionRepository.save(link); // Loop - неоптимално! } }}} След (оптимизирано - batch insert): {{{ List links = taskRepository .findByDisciplineUser_UserIdAndFinishedTrue(userId) .stream() .map(t -> { TaskDailyCompletion link = new TaskDailyCompletion(); link.setTask(t); link.setDailyCompletion(savedCompletion); link.setId(new TaskDailyCompletionId(t.getTaskId(), savedCompletion.getDailyCompletionId())); return link; }) .toList(); taskDailyCompletionRepository.saveAll(links); // Batch! taskRepository.resetFinishedForUser(userId); }}} ==== Read-Only Operations - @Transactional(readOnly = true) ==== ===== AuthService - Login (Read-Only) ===== {{{ @Transactional(readOnly = true) public AuthResponse login(LoginRequest request) { String usernameOrEmail = request.getUsernameOrEmail().trim(); String emailLookup = usernameOrEmail.contains("@") ? usernameOrEmail.toLowerCase() : usernameOrEmail; User user = userRepository.findByUsername(usernameOrEmail) .or(() -> userRepository.findByEmail(emailLookup)) .orElseThrow(() -> new RuntimeException("Invalid credentials")); if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new RuntimeException("Invalid credentials"); } String token = jwtService.generateToken(user.getUserId(), user.getUsername(), user.getEmail()); return new AuthResponse(token, "Bearer", user.getUserId(), user.getUsername(), user.getEmail()); } }}} ===== WeightService - Get Daily Intakes (Read-Only + N+1 OPTIMIZACIJA) ===== КРИТИЧНО: N+1 Query Fix Пред (ПРОБЛЕМ - N+1): {{{ public WeightDailyIntakesResponse getDailyIntakes(Long userId, int page, int size) { Page result = dailyIntakeRepository .findByWeightUser_UserIdOrderByDateDesc(userId, PageRequest.of(page, size)); List intakes = result.getContent().stream() .map(d -> { // ПРОБЛЕМ: За СЕКОЈ intake, една query за training sessions! var trainingSessions = trainingSessionRepository .findByTrainingUser_UserIdAndDate(userId, d.getDate()); // 20 intakes = 20 queries + 1 page query = 21 queries! }) .toList(); return new WeightDailyIntakesResponse(intakes, result.hasNext(), hasTodayIntake); } }}} След (РЕШЕНО - Bulk Query): {{{ @Transactional(readOnly = true) public WeightDailyIntakesResponse getDailyIntakes(Long userId, int page, int size) { Page result = dailyIntakeRepository .findByWeightUser_UserIdOrderByDateDesc(userId, PageRequest.of(page, size)); List dates = result.getContent().stream() .map(DailyIntake::getDate) .distinct() .toList(); // РЕШЕНО: Една bulk query наместо N queries Map trainingCountsByDate = new HashMap<>(); Map burnedCaloriesByDate = new HashMap<>(); if (!dates.isEmpty()) { trainingSessionRepository.findByTrainingUser_UserIdAndDateIn(userId, dates) .forEach(session -> { LocalDate sessionDate = session.getDate(); trainingCountsByDate.merge(sessionDate, 1, Integer::sum); BigDecimal calories = session.getCalories() != null ? session.getCalories() : BigDecimal.ZERO; burnedCaloriesByDate.merge(sessionDate, calories, BigDecimal::add); }); } // Map lookup е O(1), не O(N) queries List intakes = result.getContent().stream() .map(d -> { boolean trainedThatDay = trainingCountsByDate .getOrDefault(d.getDate(), 0) > 0; BigDecimal burnedCalories = burnedCaloriesByDate .getOrDefault(d.getDate(), BigDecimal.ZERO) .setScale(2, java.math.RoundingMode.HALF_UP); return new WeightDailyIntakeDto( d.getDailyIntakeId(), d.getDate(), d.getCalories(), trainedThatDay, burnedCalories); }) .toList(); return new WeightDailyIntakesResponse(intakes, result.hasNext(), hasTodayIntake); } }}} Што се промени: Од per-row queries → една bulk query со IN clause. За страна од 20 items: 21 queries → 2 queries. ===== DisciplineService - Get Daily Completions (Read-Only) ===== {{{ @Transactional(readOnly = true) public DailyCompletionsResponse getDailyCompletions(Long userId, int page, int size) { Page result = dailyCompletionRepository .findByUser_UserIdOrderByDateDescDailyCompletionIdDesc(userId, PageRequest.of(page, size)); List items = result.getContent().stream() .map(dc -> new DailyCompletionDto(dc.getDailyCompletionId(), dc.getDate(), dc.getProcent())) .toList(); return new DailyCompletionsResponse(items, result.hasNext()); } }}} ===== CustomTrackingService - Get Categories (Read-Only) ===== {{{ @Transactional(readOnly = true) public CustomTrackingCategoriesResponse getCustomTrackingCategories(Long userId) { List items = customTrackingCategoryRepository .findByUser_UserIdOrderByCustomTrackingIdDesc(userId) .stream() .map(c -> new CustomTrackingCategoryDto(c.getCustomTrackingId(), c.getName())) .toList(); return new CustomTrackingCategoriesResponse(items); } }}} ===== FinanceService & InvestingService - Get Operations (Read-Only) ===== {{{ @Transactional(readOnly = true) public FinanceProfileResponse getProfile(Long userId) { ... } @Transactional(readOnly = true) public IncomesResponse getIncomes(Long userId, int page, int size) { ... } @Transactional(readOnly = true) public InvestingAssetsResponse getAssets(Long userId, int page, int size) { ... } }}} === 3. Repository Changes === Додена нова bulk lookup метода во TrainingSessionRepository: {{{ @SuppressWarnings("unused") java.util.List findByTrainingUser_UserIdAndDateIn(Long userId, Collection dates); }}}