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

About に戻る