About 実装コードを見る

Source

実装コードを見る

ここに表示しているのはコピーではなく、ビルド時に実際のソースファイルをそのまま複製したものです。 各ファイルの「ここがポイント」を先に読んでからコードを開くと、設計意図が追いやすくなります。

クラス相関図

Class Diagram

矢印は依存の向き(A → B は「AがBを使う」)。破線+白抜き矢印はインターフェースの実装。 下の各カードで開けるコードと同じクラス名で対応しています。

classDiagram
    class SkillController
    class SkillService
    class SkillRepository
    class StatusChangeLogRepository
    class ProgressTransitionService
    class ProgressStatus
    class NextSkillPolicy
    class SortOrderNextSkillPolicy
    class SkillStatusChangeNotifier
    class SkillStatusChangeListener
    class StatusChangeLogRecorder
    class StatusChangeSlf4jLogger
    class Skill

    SkillController --> SkillService : 呼び出す
    SkillService --> SkillRepository : 検索/保存
    SkillService --> StatusChangeLogRepository : 履歴取得
    SkillService --> ProgressTransitionService : 遷移を適用
    SkillService --> NextSkillPolicy : 推薦を依頼
    SkillService --> SkillStatusChangeNotifier : 変更を通知
    ProgressTransitionService --> ProgressStatus : 遷移可否を問い合わせ
    NextSkillPolicy <|.. SortOrderNextSkillPolicy : 実装
    SkillStatusChangeListener <|.. StatusChangeLogRecorder : 実装
    SkillStatusChangeListener <|.. StatusChangeSlf4jLogger : 実装
    SkillStatusChangeNotifier --> SkillStatusChangeListener : 全件へ通知
    SkillRepository ..> Skill : 扱う
			
全体の要

SkillService — 各パターンを束ねる場所

source-snippets/com/nanade/rashinban/web/service/SkillService.java

  • 下のクラス相関図にある5つの依存(SkillRepository/ProgressTransitionService/NextSkillPolicy/SkillStatusChangeNotifier/StatusChangeLogRepository)は、すべてこのクラスのコンストラクタに集まっている。
  • updateStatus()を読むと、State(遷移適用)→保存→Observer(通知)の順で呼ばれる流れが1メソッドの中でそのまま追える。
コードを表示 / 折りたたむ
package com.nanade.rashinban.web.service;

import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.nanade.rashinban.domain.event.SkillStatusChangeNotifier;
import com.nanade.rashinban.domain.event.SkillStatusChangedEvent;
import com.nanade.rashinban.domain.model.Category;
import com.nanade.rashinban.domain.model.DefaultProgressSnapshot;
import com.nanade.rashinban.domain.model.ProgressSnapshot;
import com.nanade.rashinban.domain.model.ProgressStatus;
import com.nanade.rashinban.domain.model.Skill;
import com.nanade.rashinban.domain.model.StatusChangeLog;
import com.nanade.rashinban.domain.model.User;
import com.nanade.rashinban.domain.model.UserSkillProgress;
import com.nanade.rashinban.domain.policy.NextSkillPolicy;
import com.nanade.rashinban.domain.service.ProgressTransitionService;
import com.nanade.rashinban.infrastructure.persistence.SkillRepository;
import com.nanade.rashinban.infrastructure.persistence.StatusChangeLogRepository;
import com.nanade.rashinban.infrastructure.persistence.UserSkillProgressRepository;
import com.nanade.rashinban.web.dto.DashboardViewModel;
import com.nanade.rashinban.web.dto.RecentChangeView;
import com.nanade.rashinban.web.dto.SidebarProgressView;
import com.nanade.rashinban.web.dto.SortKey;

/**
 * ユースケースの調整役。Repository(永続化)・ProgressTransitionService(State)・
 * NextSkillPolicy(Strategy)・SkillStatusChangeNotifier(Observer)をここで束ね、
 * Controller からは「何をしたいか」だけを呼べるようにする。
 *
 * <p>ログイン中は{@code currentUser}に値が入り、進捗・お気に入り・タグは
 * {@link UserSkillProgress}(本人専用)を読み書きする。匿名({@code Optional.empty()})の場合は
 * 従来通りSkill自身のフィールド(共有デモデータ)を読み書きする。
 * {@link Skill#withProgress}で「カタログは共有・進捗だけ個人」のマージ結果を作るため、
 * NextSkillPolicyなど下流のロジックはSkillの型のまま変更なしで動く。
 *
 * <p>ロック(依存スキル未完了)は「NOT_STARTEDのスキルに、DONEでない前提が1つでもある」ときだけ
 * 成立する。すでにIN_PROGRESS/DONEのスキルは、あとから前提が壊れても遡ってロックし直さない。
 */
@Service
public class SkillService {

	private final SkillRepository skillRepository;
	private final UserSkillProgressRepository userSkillProgressRepository;
	private final StatusChangeLogRepository statusChangeLogRepository;
	private final ProgressTransitionService progressTransitionService;
	private final NextSkillPolicy nextSkillPolicy;
	private final SkillStatusChangeNotifier statusChangeNotifier;

	public SkillService(
			SkillRepository skillRepository,
			UserSkillProgressRepository userSkillProgressRepository,
			StatusChangeLogRepository statusChangeLogRepository,
			ProgressTransitionService progressTransitionService,
			NextSkillPolicy nextSkillPolicy,
			SkillStatusChangeNotifier statusChangeNotifier) {
		this.skillRepository = skillRepository;
		this.userSkillProgressRepository = userSkillProgressRepository;
		this.statusChangeLogRepository = statusChangeLogRepository;
		this.progressTransitionService = progressTransitionService;
		this.nextSkillPolicy = nextSkillPolicy;
		this.statusChangeNotifier = statusChangeNotifier;
	}

	@Transactional(readOnly = true)
	public List<Skill> findAllOrdered(Optional<User> currentUser) {
		List<Skill> merged = loadFullMerged(currentUser);
		applyLockState(merged);
		return merged;
	}

	@Transactional(readOnly = true)
	public List<Skill> search(
			String q, Category category, ProgressStatus status, String tag, Boolean favorite,
			Boolean excludeLocked, SortKey sortKey, Optional<User> currentUser) {
		String query = normalize(q);
		String tagFilter = normalize(tag);

		List<Skill> fullMerged = loadFullMerged(currentUser);
		Map<Long, LockInfo> lockInfoById = computeLockInfoById(fullMerged);

		List<Skill> results;
		if (currentUser.isEmpty()) {
			results = skillRepository.search(query, category, status, tagFilter, favorite);
		}
		else {
			// ログイン中は3テーブルJOINにせず、数十件規模(現状46件)を踏まえてアプリ側でマージ→絞り込みする
			results = fullMerged.stream()
					.filter(s -> matchesFilters(s, query, category, status, tagFilter, favorite))
					.collect(Collectors.toCollection(java.util.ArrayList::new));
		}
		results.forEach(s -> applyLockInfo(s, lockInfoById));
		// ロックはDBカラムではなく他スキルの状態から都度計算するため、絞り込みはmarkLocked後でないとできない
		if (Boolean.TRUE.equals(excludeLocked)) {
			results.removeIf(Skill::isLocked);
		}
		results.sort((sortKey == null ? SortKey.DEFAULT : sortKey).comparator());
		return results;
	}

	private boolean matchesFilters(Skill skill, String q, Category category, ProgressStatus status, String tag, Boolean favorite) {
		if (category != null && skill.getCategory() != category) {
			return false;
		}
		if (status != null && skill.getStatus() != status) {
			return false;
		}
		if (Boolean.TRUE.equals(favorite) && !skill.isFavorite()) {
			return false;
		}
		if (tag != null && !skill.getTags().contains(tag)) {
			return false;
		}
		if (q != null) {
			String lower = q.toLowerCase(Locale.ROOT);
			boolean matchesName = skill.getName().toLowerCase(Locale.ROOT).contains(lower);
			boolean matchesDescription = skill.getDescription().toLowerCase(Locale.ROOT).contains(lower);
			if (!matchesName && !matchesDescription) {
				return false;
			}
		}
		return true;
	}

	@Transactional(readOnly = true)
	public List<String> findAllTags(Optional<User> currentUser) {
		if (currentUser.isEmpty()) {
			return skillRepository.findAllTags();
		}
		return userSkillProgressRepository.findByUserId(currentUser.get().getId()).stream()
				.flatMap(p -> p.getTags().stream())
				.distinct()
				.sorted()
				.toList();
	}

	@Transactional(readOnly = true)
	public Optional<Skill> findById(Long id, Optional<User> currentUser) {
		Optional<Skill> skillOpt = skillRepository.findByIdWithArticles(id);
		if (skillOpt.isEmpty()) {
			return skillOpt;
		}
		Skill merged = currentUser.isEmpty() ? skillOpt.get() : mergeOne(skillOpt.get(), currentUser.get());
		Map<Long, LockInfo> lockInfoById = computeLockInfoById(loadFullMerged(currentUser));
		applyLockInfo(merged, lockInfoById);
		return Optional.of(merged);
	}

	/** REST API用。id版の{@link #findById}と同じ組み立てを、安定キーであるcodeで行う。 */
	@Transactional(readOnly = true)
	public Optional<Skill> findByCode(String code, Optional<User> currentUser) {
		Optional<Skill> skillOpt = skillRepository.findByCodeWithArticles(code);
		if (skillOpt.isEmpty()) {
			return skillOpt;
		}
		Skill merged = currentUser.isEmpty() ? skillOpt.get() : mergeOne(skillOpt.get(), currentUser.get());
		Map<Long, LockInfo> lockInfoById = computeLockInfoById(loadFullMerged(currentUser));
		applyLockInfo(merged, lockInfoById);
		return Optional.of(merged);
	}

	/** 詳細画面で「先に何を完了すればよいか」を表示するための、未完了の前提スキル一覧。 */
	@Transactional(readOnly = true)
	public List<Skill> incompletePrerequisites(Skill skill, Optional<User> currentUser) {
		if (skill.getRequiredSkillCodes().isEmpty()) {
			return List.of();
		}
		Map<String, Skill> byCode = loadFullMerged(currentUser).stream()
				.collect(Collectors.toMap(Skill::getCode, s -> s));
		return skill.getRequiredSkillCodes().stream()
				.map(byCode::get)
				.filter(Objects::nonNull)
				.filter(req -> req.getStatus() != ProgressStatus.DONE)
				.toList();
	}

	/** 詳細画面で「次に学ぶならこれ」を表示するための、このスキルを前提とするスキル一覧。 */
	@Transactional(readOnly = true)
	public List<Skill> findUnlockedNextSkills(Skill skill, Optional<User> currentUser) {
		if (skill.getStatus() != ProgressStatus.DONE) {
			return List.of();
		}
		List<Skill> allSkills = loadFullMerged(currentUser);
		Map<Long, LockInfo> lockInfoById = computeLockInfoById(allSkills);
		
		return allSkills.stream()
				.filter(s -> s.getRequiredSkillCodes().contains(skill.getCode()))
				.map(s -> {
					applyLockInfo(s, lockInfoById);
					return s;
				})
				// 前提となっている次スキルの中で、ロックされていない&未完了のものを探す
				.filter(s -> !s.isLocked() && s.getStatus() != ProgressStatus.DONE)
				.toList();
	}

	@Transactional
	public Skill updateFavorite(Long id, boolean favorite, Optional<User> currentUser) {
		Skill skill = skillRepository.findById(id)
				.orElseThrow(() -> new SkillNotFoundException(id));
		if (currentUser.isEmpty()) {
			skill.markFavorite(favorite);
			return skillRepository.save(skill);
		}
		UserSkillProgress progress = getOrCreateProgress(currentUser.get(), skill);
		progress.markFavorite(favorite);
		userSkillProgressRepository.save(progress);
		return skill.withProgress(progress);
	}

	@Transactional
	public Skill updateTags(Long id, String rawTags, Optional<User> currentUser) {
		Skill skill = skillRepository.findById(id)
				.orElseThrow(() -> new SkillNotFoundException(id));
		Set<String> tags = parseTags(rawTags);
		if (currentUser.isEmpty()) {
			skill.replaceTags(tags);
			return skillRepository.save(skill);
		}
		UserSkillProgress progress = getOrCreateProgress(currentUser.get(), skill);
		progress.replaceTags(tags);
		userSkillProgressRepository.save(progress);
		return skill.withProgress(progress);
	}

	/**
	 * 進捗を更新する。ロック検証→State(遷移可否の判定・適用)→保存→Observer(変更通知)の順で実行し、
	 * 同一トランザクション内で行うことで「更新はされたが履歴だけ残らない」ような不整合を防ぐ。
	 * 匿名かログイン中かでSkill/UserSkillProgressのどちらに書くかが分かれるだけで、
	 * ロック検証・遷移検証(ProgressTransitionService)・通知(Observer)のロジックは共通。
	 */
	@Transactional
	public Skill updateStatus(Long id, ProgressStatus target, Optional<User> currentUser) {
		Skill skill = skillRepository.findById(id)
				.orElseThrow(() -> new SkillNotFoundException(id));

		if (currentUser.isEmpty()) {
			ProgressStatus previousStatus = skill.getStatus();
			assertNotLocked(skill, previousStatus, currentUser);
			ProgressStatus validated = progressTransitionService.validate(previousStatus, target);
			skill.changeStatus(validated);
			Skill saved = skillRepository.save(skill);
			statusChangeNotifier.notifyStatusChanged(new SkillStatusChangedEvent(
					saved.getId(), saved.getCode(), saved.getName(), previousStatus, saved.getStatus(), null, Instant.now()));
			return saved;
		}

		User user = currentUser.get();
		UserSkillProgress progress = getOrCreateProgress(user, skill);
		ProgressStatus previousStatus = progress.getStatus();
		assertNotLocked(skill, previousStatus, currentUser);
		ProgressStatus validated = progressTransitionService.validate(previousStatus, target);
		progress.changeStatus(validated);
		userSkillProgressRepository.save(progress);
		statusChangeNotifier.notifyStatusChanged(new SkillStatusChangedEvent(
				skill.getId(), skill.getCode(), skill.getName(), previousStatus, validated, user.getId(), Instant.now()));
		return skill.withProgress(progress);
	}

	@Transactional(readOnly = true)
	/** 未着手以外は判定不要(一度始めたスキルを、前提が壊れたからと遡ってロックし直すことはしない)。 */
	private void assertNotLocked(Skill skill, ProgressStatus currentStatus, Optional<User> currentUser) {
		if (currentStatus != ProgressStatus.NOT_STARTED || skill.getRequiredSkillCodes().isEmpty()) {
			return;
		}
		List<String> incompleteNames = incompletePrerequisites(skill, currentUser).stream()
				.map(Skill::getName)
				.toList();
		if (!incompleteNames.isEmpty()) {
			throw new SkillLockedException(incompleteNames);
		}
	}

	@Transactional(readOnly = true)
	public DashboardViewModel buildDashboard(Optional<User> currentUser) {
		DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM/dd HH:mm").withZone(ZoneId.systemDefault());
		List<StatusChangeLog> logs = currentUser.isEmpty()
				? statusChangeLogRepository.findTop10ByUserIdIsNullOrderByChangedAtDescIdDesc()
				: statusChangeLogRepository.findTop10ByUserIdOrderByChangedAtDescIdDesc(currentUser.get().getId());
		List<RecentChangeView> recentChanges = logs.stream()
				.map(logEntry -> new RecentChangeView(
						logEntry.getSkillId(),
						logEntry.getSkillName(),
						logEntry.getFromStatus().getDisplayName(),
						logEntry.getToStatus().getDisplayName(),
						formatter.format(logEntry.getChangedAt())))
				.toList();

		List<Skill> all = findAllOrdered(currentUser);
		long total = all.size();
		long notStarted = all.stream().filter(s -> s.getStatus() == ProgressStatus.NOT_STARTED).count();
		long inProgress = all.stream().filter(s -> s.getStatus() == ProgressStatus.IN_PROGRESS).count();
		long done = all.stream().filter(s -> s.getStatus() == ProgressStatus.DONE).count();

		Map<Category, Long> countByCategory = new EnumMap<>(Category.class);
		Map<Category, Long> doneByCategory = new EnumMap<>(Category.class);
		for (Category c : Category.values()) {
			countByCategory.put(c, 0L);
			doneByCategory.put(c, 0L);
		}
		for (Skill s : all) {
			countByCategory.merge(s.getCategory(), 1L, Long::sum);
			if (s.getStatus() == ProgressStatus.DONE) {
				doneByCategory.merge(s.getCategory(), 1L, Long::sum);
			}
		}

		// 推薦(Strategy)はロック中のスキルを勧めても始められないため、除外してから渡す
		List<Skill> recommendable = all.stream().filter(s -> !s.isLocked()).toList();

		List<Skill> inProgressSkills = all.stream()
				.filter(s -> s.getStatus() == ProgressStatus.IN_PROGRESS)
				.limit(3)
				.toList();

		return new DashboardViewModel(
				total,
				notStarted,
				inProgress,
				done,
				nextSkillPolicy.recommend(recommendable),
				inProgressSkills,
				countByCategory,
				doneByCategory,
				recentChanges);
	}

	@Transactional(readOnly = true)
	public SidebarProgressView getSidebarProgress(Optional<User> currentUser) {
		List<Skill> all = findAllOrdered(currentUser);
		long total = all.size();
		long done = all.stream().filter(s -> s.getStatus() == ProgressStatus.DONE).count();
		int percent = total == 0 ? 0 : (int) Math.round(done * 100.0 / total);
		return new SidebarProgressView(total, done, percent);
	}


	/** ログインユーザーの初回変更時にのみ進捗レコードを作る(遅延生成)。unique制約が二重作成を防ぐ。 */
	private UserSkillProgress getOrCreateProgress(User user, Skill skill) {
		return userSkillProgressRepository.findByUserIdAndSkillId(user.getId(), skill.getId())
				.orElseGet(() -> userSkillProgressRepository.save(new UserSkillProgress(user, skill)));
	}

	/** アクティブなカタログ全件を、必要ならログインユーザーの進捗でマージして返す。ロック計算の土台。 */
	private List<Skill> loadFullMerged(Optional<User> currentUser) {
		List<Skill> all = skillRepository.findAllByActiveTrueOrderBySortOrderAsc();
		return currentUser.map(user -> mergeWithProgress(all, user)).orElse(all);
	}

	private List<Skill> mergeWithProgress(List<Skill> skills, User user) {
		Map<Long, UserSkillProgress> progressBySkillId = userSkillProgressRepository.findByUserId(user.getId()).stream()
				.collect(Collectors.toMap(p -> p.getSkill().getId(), p -> p));
		return skills.stream()
				.map(skill -> skill.withProgress(progressFor(skill, progressBySkillId)))
				.toList();
	}

	private Skill mergeOne(Skill skill, User user) {
		ProgressSnapshot snapshot = userSkillProgressRepository.findByUserIdAndSkillId(user.getId(), skill.getId())
				.map(p -> (ProgressSnapshot) p)
				.orElseGet(() -> new DefaultProgressSnapshot(skill.getCreatedAt()));
		return skill.withProgress(snapshot);
	}

	private ProgressSnapshot progressFor(Skill skill, Map<Long, UserSkillProgress> progressBySkillId) {
		UserSkillProgress progress = progressBySkillId.get(skill.getId());
		return progress != null ? progress : new DefaultProgressSnapshot(skill.getCreatedAt());
	}

	private void applyLockState(List<Skill> skills) {
		Map<Long, LockInfo> lockInfoById = computeLockInfoById(skills);
		skills.forEach(s -> applyLockInfo(s, lockInfoById));
	}

	private void applyLockInfo(Skill skill, Map<Long, LockInfo> lockInfoById) {
		LockInfo info = lockInfoById.getOrDefault(skill.getId(), LockInfo.UNLOCKED);
		skill.markLocked(info.locked(), info.incompleteNames());
	}

	/** ロック判定と「先に完了すべき前提スキル名」を同じループで同時に算出する(追加SQLなし)。 */
	private record LockInfo(boolean locked, List<String> incompleteNames) {
		static final LockInfo UNLOCKED = new LockInfo(false, List.of());
	}

	private Map<Long, LockInfo> computeLockInfoById(List<Skill> fullMergedSkills) {
		Map<String, Skill> byCode = fullMergedSkills.stream()
				.collect(Collectors.toMap(Skill::getCode, s -> s));
		Map<Long, LockInfo> result = new HashMap<>();
		for (Skill skill : fullMergedSkills) {
			List<Skill> incompletePrereqs = skill.getRequiredSkillCodes().stream()
					.map(byCode::get)
					.filter(Objects::nonNull)
					.filter(req -> req.getStatus() != ProgressStatus.DONE)
					.sorted(Comparator.comparingInt(Skill::getSortOrder))
					.toList();
			boolean locked = skill.getStatus() == ProgressStatus.NOT_STARTED
					&& !skill.getRequiredSkillCodes().isEmpty()
					&& !incompletePrereqs.isEmpty();
			List<String> names = locked ? incompletePrereqs.stream().map(Skill::getName).toList() : List.of();
			result.put(skill.getId(), new LockInfo(locked, names));
		}
		return result;
	}

	private Set<String> parseTags(String rawTags) {
		Set<String> tags = new LinkedHashSet<>();
		if (rawTags != null) {
			for (String tag : rawTags.split(",")) {
				String trimmed = tag.trim();
				if (!trimmed.isEmpty()) {
					tags.add(trimmed);
				}
			}
		}
		return tags;
	}

	private String normalize(String value) {
		return (value == null || value.isBlank()) ? null : value.trim();
	}
}
Repository

SkillRepository — 永続化の抽象

source-snippets/com/nanade/rashinban/infrastructure/persistence/SkillRepository.java

  • Controller/Serviceからは「どう検索するか」ではなく「何を検索するか」だけを見せ、JPQLの詳細をRepositoryに閉じ込めている。
  • search()はcategory/status/tag/favoriteのどれもnullなら無視する形で書かれており、フィルタの組み合わせが増えても呼び出し側のコードは変わらない。
コードを表示 / 折りたたむ
package com.nanade.rashinban.infrastructure.persistence;

import java.util.List;
import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.nanade.rashinban.domain.model.Category;
import com.nanade.rashinban.domain.model.ProgressStatus;
import com.nanade.rashinban.domain.model.Skill;

/**
 * Repository パターン — 永続化の抽象。
 */
public interface SkillRepository extends JpaRepository<Skill, Long> {

	boolean existsByCode(String code);

	Optional<Skill> findByCode(String code);

	List<Skill> findAllByActiveTrueOrderBySortOrderAsc();

	/** 退役済み(active=false)スキルは一覧・検索と同じく除外する(詳細直リンクでの露出防止)。 */
	@Query("""
			SELECT DISTINCT s FROM Skill s
			LEFT JOIN FETCH s.articleLinks
			WHERE s.id = :id AND s.active = true
			""")
	Optional<Skill> findByIdWithArticles(@Param("id") Long id);

	/** 退役済み(active=false)スキルは一覧・検索と同じく除外する(詳細直リンクでの露出防止)。 */
	@Query("""
			SELECT DISTINCT s FROM Skill s
			LEFT JOIN FETCH s.articleLinks
			WHERE s.code = :code AND s.active = true
			""")
	Optional<Skill> findByCodeWithArticles(@Param("code") String code);

	/**
	 * 一覧の絞り込み検索。各条件は「パラメータがnull(または空文字)なら無視する」形で
	 * 1本のクエリにまとめている。条件を組み合わせても呼び出し側(SkillService)は
	 * 分岐を書く必要がなく、絞り込み項目が増えてもこのメソッドの中だけ直せばよい。
	 */
	@Query("""
			SELECT DISTINCT s FROM Skill s
			WHERE s.active = true
			  AND (:category IS NULL OR s.category = :category)
			  AND (:status IS NULL OR s.status = :status)
			  AND (:favorite IS NULL OR s.favorite = :favorite)
			  AND (:tag IS NULL OR :tag = '' OR :tag MEMBER OF s.tags)
			  AND (
			    :q IS NULL OR :q = ''
			    OR LOWER(s.name) LIKE LOWER(CONCAT('%', :q, '%'))
			    OR LOWER(s.description) LIKE LOWER(CONCAT('%', :q, '%'))
			  )
			ORDER BY s.sortOrder ASC
			""")
	List<Skill> search(
			@Param("q") String q,
			@Param("category") Category category,
			@Param("status") ProgressStatus status,
			@Param("tag") String tag,
			@Param("favorite") Boolean favorite);

	long countByStatus(ProgressStatus status);

	@Query("SELECT DISTINCT t FROM Skill s JOIN s.tags t ORDER BY t")
	List<String> findAllTags();
}
Strategy

NextSkillPolicy — 「次の一手」推薦の差し替え口

source-snippets/com/nanade/rashinban/domain/policy/NextSkillPolicy.java

  • インターフェースはrecommend(List<Skill>)一本だけ。アルゴリズムを変えたければ実装クラスを差し替えるだけでよい。
コードを表示 / 折りたたむ
package com.nanade.rashinban.domain.policy;

import java.util.List;
import java.util.Optional;

import com.nanade.rashinban.domain.model.Skill;

/**
 * 「次に学ぶならこれ」の推薦戦略(Strategy パターン)。
 * メソッドを1本だけにしているのは、実装を差し替えるときに守るべき契約を最小限にするため。
 * 例えばカテゴリ優先や依存関係を考慮した推薦に変えたい場合も、この1メソッドを実装するだけでよい。
 */
public interface NextSkillPolicy {

	Optional<Skill> recommend(List<Skill> skills);
}
Strategy

SortOrderNextSkillPolicy — 現在の実装(sortOrder優先)

source-snippets/com/nanade/rashinban/domain/policy/SortOrderNextSkillPolicy.java

  • 「未着手をsortOrder順→なければ学習中」という具体ロジックはこのクラスだけが知っている。カテゴリ優先や依存関係を考慮した推薦に変えたい場合、このクラスの中身だけを差し替えればSkillServiceは無変更で済む。
コードを表示 / 折りたたむ
package com.nanade.rashinban.domain.policy;

import java.util.Comparator;
import java.util.List;
import java.util.Optional;

import org.springframework.stereotype.Component;

import com.nanade.rashinban.domain.model.ProgressStatus;
import com.nanade.rashinban.domain.model.Skill;

/**
 * 推薦アルゴリズム v1:
 * 1. NOT_STARTED を sortOrder 昇順
 * 2. なければ IN_PROGRESS を updatedAt が古い順
 * 3. どちらもなければ empty
 */
@Component
public class SortOrderNextSkillPolicy implements NextSkillPolicy {

	@Override
	public Optional<Skill> recommend(List<Skill> skills) {
		// 1. 未着手があれば表示順(sortOrder)が最も早いものを優先する
		Optional<Skill> notStarted = skills.stream()
				.filter(s -> s.getStatus() == ProgressStatus.NOT_STARTED)
				.min(Comparator.comparingInt(Skill::getSortOrder));
		if (notStarted.isPresent()) {
			return notStarted;
		}

		// 2. 未着手が無ければ、学習中の中で一番長く放置されているものを次点として出す
		return skills.stream()
				.filter(s -> s.getStatus() == ProgressStatus.IN_PROGRESS)
				.min(Comparator.comparing(Skill::getUpdatedAt, Comparator.nullsLast(Comparator.naturalOrder())));
		// 3. どちらも無ければ empty(呼び出し側のダッシュボードが「すべて完了」表示に切り替える)
	}
}
State

ProgressStatus — 状態が自分の遷移ルールを持つ

source-snippets/com/nanade/rashinban/domain/model/ProgressStatus.java

  • 「未着手→学習中→完了」のような遷移可否をif文で外側に書くのではなく、canTransitionTo()として状態(enum)自身に持たせている。
  • allowedTargets()はUIの選択肢生成にもそのまま使われており、ルールの記述場所が1箇所に集約されている。
コードを表示 / 折りたたむ
package com.nanade.rashinban.domain.model;

import java.util.EnumSet;
import java.util.Set;

/**
 * 学習進捗の状態。State パターンの「状態」として遷移可否を自身が知る。
 *
 * @see com.nanade.rashinban.domain.service.ProgressTransitionService
 */
public enum ProgressStatus {
	NOT_STARTED("未着手"),
	IN_PROGRESS("学習中"),
	DONE("完了");

	private final String displayName;

	ProgressStatus(String displayName) {
		this.displayName = displayName;
	}

	public String getDisplayName() {
		return displayName;
	}

	/**
	 * この状態から {@code target} への遷移が許されるか。
	 * 同一状態への「遷移」は更新不要のため false。
	 */
	public boolean canTransitionTo(ProgressStatus target) {
		if (target == null || this == target) {
			return false;
		}
		return allowedTargets().contains(target);
	}

	/**
	 * DONE から NOT_STARTED / IN_PROGRESS への「後退」も許可している。
	 * これは学習の「復習」「やり直し」を表現するための意図的な仕様で、バグではない。
	 */
	public Set<ProgressStatus> allowedTargets() {
		return switch (this) {
			case NOT_STARTED -> EnumSet.of(IN_PROGRESS, DONE);
			case IN_PROGRESS -> EnumSet.of(NOT_STARTED, DONE);
			case DONE -> EnumSet.of(IN_PROGRESS, NOT_STARTED);
		};
	}
}
State

ProgressTransitionService — 遷移の適用

source-snippets/com/nanade/rashinban/domain/service/ProgressTransitionService.java

  • 遷移ルールの判定自体はProgressStatusに委譲し、このクラスは「許可されなければ例外を投げる」という適用の責務だけを持つ。
コードを表示 / 折りたたむ
package com.nanade.rashinban.domain.service;

import org.springframework.stereotype.Service;

import com.nanade.rashinban.domain.model.ProgressStatus;

/**
 * 進捗状態の遷移ルールを適用する。State パターンにおける「遷移可否の判断」自体は
 * {@link ProgressStatus#canTransitionTo} が持ち、このクラスは「許可されなければ拒否する」検証の
 * 責務だけを持つ。特定のエンティティ型(Skill/UserSkillProgress)に依存しないよう、
 * ProgressStatusの受け渡しだけで完結させている。呼び出し側が返り値をentity.changeStatus()に渡して
 * 実際に反映する。
 */
@Service
public class ProgressTransitionService {

	public ProgressStatus validate(ProgressStatus current, ProgressStatus target) {
		if (!current.canTransitionTo(target)) {
			throw new InvalidProgressTransitionException(current, target);
		}
		return target;
	}
}
Builder

Skill.Builder — シードデータの組み立て

source-snippets/com/nanade/rashinban/domain/model/Skill.java

  • コンストラクタをprivateにし、Builder経由でのみSkillを生成できるようにしている。
  • build()で必須項目(code/name/description/category)が揃っているかまとめて検証し、生成後の不整合を防いでいる。
コードを表示 / 折りたたむ
package com.nanade.rashinban.domain.model;

import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;

import com.nanade.rashinban.domain.catalog.SkillDefinition;

@Entity
@Table(name = "skills")
public class Skill implements ProgressSnapshot {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column(nullable = false, unique = true, length = 80)
	private String code;

	@Column(nullable = false, length = 120)
	private String name;

	@Column(nullable = false, length = 2000)
	private String description;

	@Enumerated(EnumType.STRING)
	@Column(nullable = false, length = 40)
	private Category category;

	@Enumerated(EnumType.STRING)
	@Column(nullable = false, length = 40)
	private ProgressStatus status;

	@Column(nullable = false)
	private int sortOrder;

	@Column(length = 20)
	private String accentColor;

	/**
	 * サイト記事の series(例: "Javaを鍛える|OOP基礎編")。カタログ同期由来の分類で、
	 * {@link Category}より細かい単位の表示(「デザインパターン全23回の12/24完了」等)に使う。
	 * 手動スキル({@code rashinban-sample-app}等)はnull。
	 */
	@Column(length = 120)
	private String series;

	/** series内の連番。手動スキルはnull。 */
	@Column(name = "series_order")
	private Integer seriesOrder;

	/**
	 * サイトから記事が無くなった場合のソフト退役フラグ。カタログ同期は物理削除を行わない
	 * (既存ユーザーの{@link UserSkillProgress}を保持するため)ので、一覧・検索からは
	 * このフラグで除外する。
	 */
	@Column(nullable = false, columnDefinition = "boolean default true")
	private boolean active = true;

	@Column(nullable = false, columnDefinition = "boolean default false")
	private boolean favorite;

	@ElementCollection(fetch = FetchType.EAGER)
	@CollectionTable(name = "skill_tags", joinColumns = @JoinColumn(name = "skill_id"))
	@Column(name = "tag", length = 40)
	@OrderBy("tag ASC")
	private Set<String> tags = new LinkedHashSet<>();

	@Column(nullable = false)
	private Instant createdAt;

	@Column(nullable = false)
	private Instant updatedAt;

	@OneToMany(mappedBy = "skill", cascade = CascadeType.ALL, orphanRemoval = true)
	@OrderBy("sortOrder ASC")
	private List<ArticleLink> articleLinks = new ArrayList<>();

	/**
	 * 前提スキルのcode集合(カタログの一部=共有)。「〇〇を完了しないと解放されない」の〇〇にあたる。
	 * IDではなくcodeで持つのは、Skill.codeが既に「安定キー」として設計されているため。
	 */
	@ElementCollection(fetch = FetchType.EAGER)
	@CollectionTable(name = "skill_required_codes", joinColumns = @JoinColumn(name = "skill_id"))
	@Column(name = "required_skill_code", length = 80)
	private Set<String> requiredSkillCodes = new LinkedHashSet<>();

	/**
	 * ロック中かどうか。永続化しない(DBに保存する情報ではなく、表示のたびに他スキルの状態から
	 * 計算し直す値のため)。計算はSkillService側で、匿名/ログインどちらの進捗データを使うかに
	 * 応じて行う({@code markLocked}で書き込む)。
	 */
	@Transient
	private boolean locked;

	/** ロック中の場合、先に完了すべき前提スキル名(sortOrder順)。ロックされていなければ空。 */
	@Transient
	private List<String> incompletePrerequisiteNames = List.of();

	protected Skill() {
	}

	private Skill(Builder builder) {
		this.code = builder.code;
		this.name = builder.name;
		this.description = builder.description;
		this.category = builder.category;
		this.status = builder.status;
		this.sortOrder = builder.sortOrder;
		this.accentColor = builder.accentColor;
		this.series = builder.series;
		this.seriesOrder = builder.seriesOrder;
		this.active = builder.active;
		this.favorite = builder.favorite;
		this.tags = new LinkedHashSet<>(builder.tags);
		this.requiredSkillCodes = new LinkedHashSet<>(builder.requiredSkillCodes);
		for (ArticleLink link : builder.articleLinks) {
			addArticleLink(link);
		}
	}

	public static Builder builder() {
		return new Builder();
	}

	/**
	 * ログインユーザー個人の進捗で「差し替えた」表示専用コピーを作る(JPA非管理・保存されない)。
	 * カタログ部分(code/name/description/category/sortOrder/accentColor/articleLinks)は共有のまま、
	 * 進捗系フィールドだけ引数の値に置き換える。テンプレート側はskillをそのまま読むだけでよく、
	 * 匿名/ログインの分岐を意識しなくて済む。
	 */
	public Skill withProgress(ProgressSnapshot progress) {
		Skill copy = new Skill();
		copy.id = this.id;
		copy.code = this.code;
		copy.name = this.name;
		copy.description = this.description;
		copy.category = this.category;
		copy.sortOrder = this.sortOrder;
		copy.accentColor = this.accentColor;
		copy.series = this.series;
		copy.seriesOrder = this.seriesOrder;
		copy.active = this.active;
		copy.articleLinks = this.articleLinks;
		copy.requiredSkillCodes = this.requiredSkillCodes;
		copy.createdAt = this.createdAt;
		copy.status = progress.getStatus();
		copy.favorite = progress.isFavorite();
		copy.tags = new LinkedHashSet<>(progress.getTags());
		copy.updatedAt = progress.getUpdatedAt();
		return copy;
	}

	/**
	 * 進捗更新は「更新日」ソート({@code SortKey.UPDATED})の基準になるため updatedAt も進める。
	 * お気に入りの変更({@link #markFavorite})ではこれをやらない、という対比で読むとわかりやすい。
	 */
	public void changeStatus(ProgressStatus newStatus) {
		this.status = newStatus;
		this.updatedAt = Instant.now();
	}

	/**
	 * お気に入りは学習進捗ではないため、updatedAt(更新日ソートの基準)は変えない。
	 */
	public void markFavorite(boolean favorite) {
		this.favorite = favorite;
	}

	/**
	 * カタログ同期({@code CatalogSynchronizer})がsite由来の共有項目だけを上書きする。
	 * status / favorite / tags(既存行) / updatedAt には一切触れない契約
	 * (進捗はユーザーのものであり、カタログ同期のたびに消えてはならないため)。
	 */
	public void applyCatalog(SkillDefinition def) {
		this.name = def.name();
		this.description = def.description();
		this.category = def.category();
		this.series = def.series();
		this.seriesOrder = def.seriesOrder();
		this.sortOrder = def.sortOrder();
		this.accentColor = def.accentColor();
		this.requiredSkillCodes = new LinkedHashSet<>(def.requiredSkillCodes());
		this.active = true;
	}

	/**
	 * 記事リンクをカタログ同期の内容で置き換える。{@code articleLinks}は
	 * cascade=ALL/orphanRemoval=trueなので、参照を差し替えず同じコレクションを
	 * クリアしてから追加し直す(Hibernateの永続コレクション追跡を壊さないため)。
	 */
	public void replaceArticleLinks(List<ArticleLink> newLinks) {
		this.articleLinks.clear();
		for (ArticleLink link : newLinks) {
			addArticleLink(link);
		}
	}

	/**
	 * サイトから記事が無くなった等でカタログから外れたスキルを退役させる。
	 * 物理削除はしない(既存ユーザーの進捗データを保持するため)。
	 */
	public void retire() {
		this.active = false;
	}

	public void replaceTags(Set<String> newTags) {
		this.tags = new LinkedHashSet<>(newTags);
	}

	/** SkillServiceが他スキルの状態を見て計算した結果を書き込む(永続化はされない)。 */
	public void markLocked(boolean locked, List<String> incompletePrerequisiteNames) {
		this.locked = locked;
		this.incompletePrerequisiteNames = incompletePrerequisiteNames;
	}

	public void addArticleLink(ArticleLink link) {
		articleLinks.add(link);
		link.setSkill(this);
	}

	@PrePersist
	void onCreate() {
		Instant now = Instant.now();
		createdAt = now;
		updatedAt = now;
	}

	public Long getId() {
		return id;
	}

	public String getCode() {
		return code;
	}

	public String getName() {
		return name;
	}

	public String getDescription() {
		return description;
	}

	public Category getCategory() {
		return category;
	}

	public ProgressStatus getStatus() {
		return status;
	}

	public int getSortOrder() {
		return sortOrder;
	}

	public String getAccentColor() {
		return accentColor;
	}

	public String getSeries() {
		return series;
	}

	public Integer getSeriesOrder() {
		return seriesOrder;
	}

	public boolean isActive() {
		return active;
	}

	public boolean isFavorite() {
		return favorite;
	}

	public Set<String> getTags() {
		return Collections.unmodifiableSet(tags);
	}

	public Instant getCreatedAt() {
		return createdAt;
	}

	public Instant getUpdatedAt() {
		return updatedAt;
	}

	public List<ArticleLink> getArticleLinks() {
		return Collections.unmodifiableList(articleLinks);
	}

	public Set<String> getRequiredSkillCodes() {
		return Collections.unmodifiableSet(requiredSkillCodes);
	}

	public boolean isLocked() {
		return locked;
	}

	public List<String> getIncompletePrerequisiteNames() {
		return incompletePrerequisiteNames;
	}

	/**
	 * シード用 Builder(Builder パターン)。
	 */
	public static final class Builder {
		private String code;
		private String name;
		private String description;
		private Category category;
		private ProgressStatus status = ProgressStatus.NOT_STARTED;
		private int sortOrder;
		private String accentColor = "#6366f1";
		private String series;
		private Integer seriesOrder;
		private boolean active = true;
		private boolean favorite;
		private final Set<String> tags = new LinkedHashSet<>();
		private final Set<String> requiredSkillCodes = new LinkedHashSet<>();
		private final List<ArticleLink> articleLinks = new ArrayList<>();

		public Builder code(String code) {
			this.code = code;
			return this;
		}

		public Builder name(String name) {
			this.name = name;
			return this;
		}

		public Builder description(String description) {
			this.description = description;
			return this;
		}

		public Builder category(Category category) {
			this.category = category;
			return this;
		}

		public Builder status(ProgressStatus status) {
			this.status = status;
			return this;
		}

		public Builder sortOrder(int sortOrder) {
			this.sortOrder = sortOrder;
			return this;
		}

		public Builder series(String series) {
			this.series = series;
			return this;
		}

		public Builder seriesOrder(Integer seriesOrder) {
			this.seriesOrder = seriesOrder;
			return this;
		}

		public Builder active(boolean active) {
			this.active = active;
			return this;
		}

		public Builder accentColor(String accentColor) {
			this.accentColor = accentColor;
			return this;
		}

		public Builder favorite(boolean favorite) {
			this.favorite = favorite;
			return this;
		}

		public Builder tags(String... tags) {
			Collections.addAll(this.tags, tags);
			return this;
		}

		public Builder requires(String... skillCodes) {
			Collections.addAll(this.requiredSkillCodes, skillCodes);
			return this;
		}

		public Builder article(String title, String url) {
			this.articleLinks.add(new ArticleLink(title, url, this.articleLinks.size()));
			return this;
		}

		/**
		 * 必須項目の検証をここでまとめて行う。コンストラクタをprivateにしているのは、
		 * このチェックを経ずにSkillが生成される経路を無くすため。
		 */
		public Skill build() {
			if (code == null || name == null || description == null || category == null) {
				throw new IllegalStateException("code, name, description, category are required");
			}
			return new Skill(this);
		}
	}
}
Observer

SkillStatusChangeListener / Notifier — 更新通知の仕組み

source-snippets/com/nanade/rashinban/domain/event/SkillStatusChangeNotifier.java

  • Notifier(Subject)はSpringのDIで List<SkillStatusChangeListener> を丸ごと受け取るだけで、購読者が何個・何をするかは知らない。
  • 新しい通知先(例: メール送信)を増やしたいときは、SkillStatusChangeListenerを実装した@Componentを1つ追加するだけでよく、Notifier側の変更は不要。
コードを表示 / 折りたたむ
package com.nanade.rashinban.domain.event;

import java.util.List;

import org.springframework.stereotype.Component;

/**
 * Observer パターンの Subject。Spring が {@link SkillStatusChangeListener} の
 * 実装 Bean を全件自動注入するため、購読者を増やすときはクラスを1つ実装するだけでよい。
 */
@Component
public class SkillStatusChangeNotifier {

	private final List<SkillStatusChangeListener> listeners;

	public SkillStatusChangeNotifier(List<SkillStatusChangeListener> listeners) {
		this.listeners = listeners;
	}

	public void notifyStatusChanged(SkillStatusChangedEvent event) {
		for (SkillStatusChangeListener listener : listeners) {
			listener.onStatusChanged(event);
		}
	}
}