Source
実装コードを見る
ここに表示しているのはコピーではなく、ビルド時に実際のソースファイルをそのまま複製したものです。 各ファイルの「ここがポイント」を先に読んでからコードを開くと、設計意図が追いやすくなります。
クラス相関図
Class Diagram矢印は依存の向き(A → B は「AがBを使う」)。破線+白抜き矢印はインターフェースの実装。 下の各カードで開けるコードと同じクラス名で対応しています。
classDiagram
class SkillController
class SkillApiController
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 : 呼び出す
SkillApiController --> 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.EnumMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
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.ProgressStatus;
import com.nanade.rashinban.domain.model.Skill;
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.web.dto.DashboardViewModel;
import com.nanade.rashinban.web.dto.RecentChangeView;
import com.nanade.rashinban.web.dto.SortKey;
/**
* ユースケースの調整役。Repository(永続化)・ProgressTransitionService(State)・
* NextSkillPolicy(Strategy)・SkillStatusChangeNotifier(Observer)をここで束ね、
* Controller からは「何をしたいか」だけを呼べるようにする。
*/
@Service
public class SkillService {
private final SkillRepository skillRepository;
private final StatusChangeLogRepository statusChangeLogRepository;
private final ProgressTransitionService progressTransitionService;
private final NextSkillPolicy nextSkillPolicy;
private final SkillStatusChangeNotifier statusChangeNotifier;
public SkillService(
SkillRepository skillRepository,
StatusChangeLogRepository statusChangeLogRepository,
ProgressTransitionService progressTransitionService,
NextSkillPolicy nextSkillPolicy,
SkillStatusChangeNotifier statusChangeNotifier) {
this.skillRepository = skillRepository;
this.statusChangeLogRepository = statusChangeLogRepository;
this.progressTransitionService = progressTransitionService;
this.nextSkillPolicy = nextSkillPolicy;
this.statusChangeNotifier = statusChangeNotifier;
}
@Transactional(readOnly = true)
public List<Skill> findAllOrdered() {
return skillRepository.findAllByOrderBySortOrderAsc();
}
@Transactional(readOnly = true)
public List<Skill> search(String q, Category category, ProgressStatus status, String tag, Boolean favorite, SortKey sortKey) {
String query = (q == null || q.isBlank()) ? null : q.trim();
String tagFilter = (tag == null || tag.isBlank()) ? null : tag.trim();
// 絞り込みはDB(JPQL)、並び替えはアプリ側でSortKeyのComparatorを適用する二段構え。
// 理由はSortKeyのJavadoc参照(ステータスのenum順序・日本語の並び順をDBに任せられないため)。
List<Skill> results = skillRepository.search(query, category, status, tagFilter, favorite);
results.sort((sortKey == null ? SortKey.DEFAULT : sortKey).comparator());
return results;
}
@Transactional(readOnly = true)
public List<String> findAllTags() {
return skillRepository.findAllTags();
}
@Transactional(readOnly = true)
public Optional<Skill> findById(Long id) {
return skillRepository.findByIdWithArticles(id);
}
@Transactional
public Skill updateFavorite(Long id, boolean favorite) {
Skill skill = skillRepository.findById(id)
.orElseThrow(() -> new SkillNotFoundException(id));
skill.markFavorite(favorite);
return skillRepository.save(skill);
}
@Transactional
public Skill updateTags(Long id, String rawTags) {
Skill skill = skillRepository.findById(id)
.orElseThrow(() -> new SkillNotFoundException(id));
Set<String> tags = new LinkedHashSet<>();
if (rawTags != null) {
for (String tag : rawTags.split(",")) {
String trimmed = tag.trim();
if (!trimmed.isEmpty()) {
tags.add(trimmed);
}
}
}
skill.replaceTags(tags);
return skillRepository.save(skill);
}
/**
* 進捗を更新する。State(遷移可否の判定・適用)→保存→Observer(変更通知)の順で実行し、
* 同一トランザクション内で行うことで「更新はされたが履歴だけ残らない」ような不整合を防ぐ。
*/
@Transactional
public Skill updateStatus(Long id, ProgressStatus target) {
Skill skill = skillRepository.findById(id)
.orElseThrow(() -> new SkillNotFoundException(id));
// 通知イベントに「変更前」の状態を含めたいので、遷移を適用する前に控えておく
ProgressStatus previousStatus = skill.getStatus();
progressTransitionService.apply(skill, target);
Skill saved = skillRepository.save(skill);
statusChangeNotifier.notifyStatusChanged(new SkillStatusChangedEvent(
saved.getId(), saved.getCode(), saved.getName(), previousStatus, saved.getStatus(), Instant.now()));
return saved;
}
@Transactional(readOnly = true)
public DashboardViewModel buildDashboard() {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM/dd HH:mm").withZone(ZoneId.systemDefault());
List<RecentChangeView> recentChanges = statusChangeLogRepository.findTop10ByOrderByChangedAtDescIdDesc().stream()
.map(logEntry -> new RecentChangeView(
logEntry.getSkillId(),
logEntry.getSkillName(),
logEntry.getFromStatus().getDisplayName(),
logEntry.getToStatus().getDisplayName(),
formatter.format(logEntry.getChangedAt())))
.toList();
List<Skill> all = skillRepository.findAllByOrderBySortOrderAsc();
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);
}
}
return new DashboardViewModel(
total,
notStarted,
inProgress,
done,
nextSkillPolicy.recommend(all),
countByCategory,
doneByCategory,
recentChanges);
}
}
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> findAllByOrderBySortOrderAsc();
@Query("""
SELECT DISTINCT s FROM Skill s
LEFT JOIN FETCH s.articleLinks
WHERE s.id = :id
""")
Optional<Skill> findByIdWithArticles(@Param("id") Long id);
/**
* 一覧の絞り込み検索。各条件は「パラメータがnull(または空文字)なら無視する」形で
* 1本のクエリにまとめている。条件を組み合わせても呼び出し側(SkillService)は
* 分岐を書く必要がなく、絞り込み項目が増えてもこのメソッドの中だけ直せばよい。
*/
@Query("""
SELECT DISTINCT s FROM Skill s
WHERE (: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();
}
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);
}
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(呼び出し側のダッシュボードが「すべて完了」表示に切り替える)
}
}
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);
};
}
}
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;
import com.nanade.rashinban.domain.model.Skill;
/**
* 進捗状態の遷移を司る。State パターンのルールを Skill に適用する。
*/
@Service
public class ProgressTransitionService {
public void apply(Skill skill, ProgressStatus target) {
ProgressStatus current = skill.getStatus();
// 遷移可否は「現在の状態(current)」自身に判断させる(State パターン)。
// このクラスは「許可されなければ拒否する」という適用の責務だけを持ち、ルールそのものは持たない。
if (!current.canTransitionTo(target)) {
throw new InvalidProgressTransitionException(current, target);
}
skill.changeStatus(target);
}
}
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.PreUpdate;
import jakarta.persistence.Table;
@Entity
@Table(name = "skills")
public class Skill {
@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;
@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<>();
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.favorite = builder.favorite;
this.tags = new LinkedHashSet<>(builder.tags);
for (ArticleLink link : builder.articleLinks) {
addArticleLink(link);
}
}
public static Builder builder() {
return new Builder();
}
/**
* 進捗更新は「更新日」ソート({@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;
}
public void replaceTags(Set<String> newTags) {
this.tags = new LinkedHashSet<>(newTags);
}
public void addArticleLink(ArticleLink link) {
articleLinks.add(link);
link.setSkill(this);
}
@PrePersist
void onCreate() {
Instant now = Instant.now();
createdAt = now;
updatedAt = now;
}
@PreUpdate
void onUpdate() {
updatedAt = Instant.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 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);
}
/**
* シード用 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 boolean favorite;
private final Set<String> tags = 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 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 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);
}
}
}
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);
}
}
}