Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion docs/domain-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,8 @@ U/D 표기: **U** = Upstream(상류, 공급자), **D** = Downstream(하류, 소
| Personalization Profile ↔ Activity | 행동 요약 입력 | `Personalization Profile` | `Activity` | **CS** | **OHS/PL** | 조회 포트 / `UserActivitySummary` 같은 Published Language | `PersonalizationProfileService`가 Activity repository를 직접 조회 | 이 seam은 현재는 Customer-Supplier에 가깝지만, 목표 상태는 “행동 요약 언어를 소비한다”는 OHS/PL이 더 자연스럽다. |
| Personalization Profile ↔ Post / Content | 게시글 관심 신호 입력 | `Personalization Profile` | `Post / Content` | **CS** | **OHS/PL** | 게시글 메타데이터 projection / 경량 포트 | `PersonalizationProfileService`가 `PostKeyword`와 게시글 제목을 직접 읽는다 | 개인화 프로필 생성에 필요한 게시글 신호를 소비한다. 장기적으로는 경량 projection/port로 정리 가능하다. |
| Personalization Profile ↔ Recommendation | 프로필 생성 완료 handoff | `Recommendation` | `Personalization Profile` | **강한 CS + 동기 직접 호출** | **OHS/PL** | 프로필 생성 완료 이벤트 | 현재는 개인화 프로필 생성 직후 추천 생성을 직접 호출 | 목표 상태는 `PersonalizedProfileGenerated` 이벤트로 추천 재생성을 트리거하는 것이다. |
| Search ↔ Post / Content | 검색용 게시글 projection | `Search` | `Post / Content` | **OHS/PL** | **OHS/PL** (Open Host Service / Published Language) | Read Model / Projection 소비 | `PostDocument`, `PostRepository` 참조 | Post가 `PostDocument` projection을 제공하고 Search가 이를 소비한다. |
| Search ↔ Post / Content | 검색용 게시글 projection | `Search` | `Post / Content` | **OHS/PL** | **OHS/PL** (Open Host Service / Published Language) | Read Model / Projection 소비 | `PostDocument` 참조 | Post가 `PostDocument` projection을 제공하고 Search가 이를 후보 탐색에 소비한다. |
| Search ↔ Post / Content | 검색 결과 metadata 조합 | `Search` | `Post / Content` | **CS** | **CS** 또는 경량 조회 포트 | Query Composition | `PostRepository`로 `viewCount` 등 metadata 조회 | 후보 탐색 seam과 metadata seam을 분리해서 본다. 다음 도메인 리팩터링에서도 Search가 Post aggregate 소유권을 가져간다고 해석하지 않도록 구분한다. |
| Search ↔ Personalization Profile | 개인화 리랭킹 입력 | `Search` | `Personalization Profile` | **OHS/PL** | **OHS/PL** (Open Host Service / Published Language) | Read Model / Projection 소비 | `PersonalizationProfileDocument` 참조 | Search는 `PersonalizationProfileDocument`를 소비해 개인화 리랭킹을 수행한다. |
| Search ↔ Activity | 북마크 여부 조회 | `Search` | `Activity` | **CS** | **CS** (Customer-Supplier) | Query Composition | `BookmarkRepository`로 북마크 여부 조회 | 검색 결과 응답 조립을 위한 조회 조합이다. |
| Recommendation ↔ User Account | 추천 대상 사용자 식별 | `Recommendation` | `User Account` | **직접 엔티티 참조** (의도상 최소 SK) | **SK** (최소 Shared Identity) | 추천 대상 사용자 식별 | `User` 직접 참조 | 추천 대상 사용자의 정체성과 상태를 알아야 한다. 장기적으로는 최소 사용자 식별자/상태 공유로 축소 가능하다. |
Expand All @@ -232,6 +233,19 @@ U/D 표기: **U** = Upstream(상류, 공급자), **D** = Downstream(하류, 소
| Search / Recommendation / Personalization Profile ↔ Elasticsearch | 읽기 모델 저장/조회 | `Search / Recommendation / Personalization Profile` | Elasticsearch | **읽기 모델 인프라 + ACL 성격** | **ACL** (Anti-Corruption Layer) | Projection / Search Read Model | `PostDocument`, `PersonalizationProfileDocument` | 검색/추천/개인화 프로필용 읽기 모델이다. ES 인덱스 구조 변경이 도메인 모델에 전파되지 않도록 차단한다. |
| Source / Ops ↔ Discord Webhook | 운영 알림 전송 | `Source / Ops` | Discord Webhook | **ACL** | **ACL** (Anti-Corruption Layer) | External Adapter | `WebhookNotificationService` | 운영 알림용 외부 통합이다. 도메인 핵심 모델과 분리되어야 한다. |

### 3.2 #382 phase decision notes

`Post` cross-context 정리 phase 에서는 아래 seam 을 **유지 계약** 으로 본다.

| Seam | 이번 phase 해석 | 유지 이유 | 후속 후보 |
|---|---|---|---|
| Activity → Post lookup | `PostLookupService`, `PostKeywordLookupService` 를 통한 임시 application seam | 다른 컨텍스트가 Post repository 직접 의존으로 다시 퍼지는 것을 막는다. 다만 아직 `Post` aggregate shape 를 그대로 누수한다. | lookup port / DTO 기반 published query 로 축소 |
| Search → `PostDocument` | aggregate 의존이 아니라 projection 소비 | 검색 후보 탐색과 MySQL metadata 조합을 분리해서 해석하도록 경계를 명확히 한다. | projection schema 안정화, metadata query port 분리 |
| Recommendation → `PostDocument` | aggregate 의존이 아니라 projection 소비 | 추천 후보 탐색 seam 과 추천 저장 seam 을 분리해 다른 도메인 작업 시 경계 혼동을 막는다. | projection schema 안정화, 저장용 최소 식별 공유 |
| Source → `Post.create(RssFeedItem, TechBlog)` | Source 가 정제한 현재 monolith 내부 handoff DTO 를 받는 생성 경계 | 외부 RSS raw schema를 직접 넘기지는 않지만, 여전히 Source DTO 를 Post 가 직접 참조하는 내부 결합이다. 현재 monolith 에서 허용 가능한 타협으로 본다. | Post 소유 handoff DTO, command object, 이벤트 handoff |

이번 phase 에서는 이벤트, ACL 재구성, 물리적 컨텍스트 분리보다 **현재 seam 의 의도와 한계 명시** 를 우선한다.

### 3.3 현재 통합 스타일 평가

현재 코드는 **이벤트 기반보다 동기 직접 호출과 JPA 엔티티 공유가 강한 모놀리스형 구조**다.
Expand Down
16 changes: 16 additions & 0 deletions docs/tactical-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,22 @@ createSocialUser() → PENDING
- `PersonalizationProfileService`는 현재 Personalization Profile 쪽 생성 책임을 맡는 Application Service다. `User`, `Activity`, `Recommendation` 등 여러 컨텍스트를 조합하지만, 각 Aggregate의 상태 변경은 해당 Aggregate의 도메인 메서드를 통해서만 수행해야 한다.
- 향후 컨텍스트 분리가 필요해지면 그 시점에 직접 참조를 ID 참조로 전환하고 ACL 또는 Anti-Corruption Layer를 도입한다.

### 3.4 #382 현재 phase 유지 계약

이번 phase 에서는 cross-context 결합을 모두 제거하지 않고, **유지할 seam** 과 **후속 분리 seam** 을 명시적으로 구분한다.

| Seam | 이번 phase 결정 | 현재 공개 계약 / 설명 | 후속 분리 후보 |
|---|---|---|---|
| Activity → Post lookup | 유지 | `PostLookupService`, `PostKeywordLookupService` 를 Activity 의 **임시 application seam / repository-access choke-point** 로 유지한다. Activity 는 Post repository 직접 의존 대신 이 seam 을 통해 게시글/키워드를 읽지만, 아직 `Post` aggregate shape 를 그대로 노출한다. | 경량 조회 포트, DTO 기반 published query |
| Search → Post | 유지 | Search 는 `PostDocument` 를 검색 후보 projection 으로 소비한다. `PostRepository` 사용은 후보 탐색이 아니라 `viewCount` 같은 metadata 조합으로 한정한다. | metadata 용 별도 read model / query port |
| Recommendation → Post | 유지 | Recommendation 은 후보 탐색에는 `PostDocument` projection 을 사용하고, 저장 시점에만 `Post` reference 를 확보한다. 후보 탐색 seam 과 저장 seam 을 분리해서 해석한다. | 추천 저장용 최소 식별 공유, 이벤트 기반 추천 재생성 |
| Source → Post 생성 | 유지 | `RssFeedItem` 은 Source 가 외부 RSS 를 정제해 만든 **현재 monolith 내부 handoff DTO** 로 보고, `Post.create(RssFeedItem, TechBlog)` 경계를 유지한다. 아직 published language 로 분리된 상태는 아니다. | Post 소유 command/published language, `TechnicalPostDiscovered` 이벤트 handoff |

명시적 제외:

- 이번 phase 에서는 이벤트 도입, `ManyToOne -> id reference` 전환, hexagonal port/adaptor 전환을 수행하지 않는다.
- 목표는 구조 대수술이 아니라 **현재 의존의 의도와 한계를 문서/코드/테스트로 고정하는 것**이다.

---

## 4. Domain Event 발행/구독 규약
Expand Down
4 changes: 4 additions & 0 deletions docs/ubiquitous-language/post-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
- 도메인/기획 문서에서는 `Post`를 **기술 게시글**로 부른다.
- `PostDocument`, `ContentChunk`는 aggregate가 아니라 **검색/추천용 projection**이다.
- `Post.company`는 Source 컨텍스트의 출처명을 복사한 조회용 스냅샷이다.
- Activity 컨텍스트가 게시글/키워드를 읽을 때의 현재 진입점은 `PostLookupService`, `PostKeywordLookupService`다. 다만 이것은 published query 라기보다 repository 직접 의존을 막는 임시 application seam 에 가깝다.
- Search 는 후보 탐색에 `PostDocument` 를 사용하고, `viewCount` 같은 응답 metadata 는 별도 query composition 으로 읽는다.
- `Post.create(RssFeedItem, TechBlog)`는 현재 Source 컨텍스트가 정제한 **monolith 내부 handoff DTO** 를 받는 생성 경계로 유지한다.
- `RssFeedItem` 직접 참조를 없애는 별도 published language, command object, 이벤트 handoff 는 후속 리팩토링 후보로 남긴다.
- production 경로에서는 `Post.incrementViewCount()` 같은 엔티티 필드 증가를 사용하지 않고 `PostViewCountCommandService`/`PostRepository`의 SQL atomic update를 canonical write path로 둔다.
- Activity 컨텍스트에서는 `first_read_posts(user_id, post_id)` dedupe ledger를 통과한 최초 읽기에서만 `PostViewCountCommandService.incrementViewCount()`를 호출한다.
- `PostViewCountCommandService.incrementViewCount()`는 DB 값을 원자적으로 증가시키지만, 이미 로드된 managed `Post`의 `viewCount`를 같은 트랜잭션 안에서 최신 상태로 동기화하지는 않는다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
import org.springframework.stereotype.Component;

/**
* RssFeedItem을 Post 엔티티로 변환하는 Processor
* Source 컨텍스트의 현재 monolith 내부 handoff DTO({@link RssFeedItem})를
* Post aggregate 생성 경계로 전달하는 processor.
*
* <p>현재 phase 에서는 Source → Post 동기 handoff 를 유지하며,
* 별도 published language / 이벤트 handoff 전환은 후속 작업으로 남긴다.</p>
*/
@Slf4j
@Component
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
/**
* Activity 와 Post query 조합 로직이 게시글 키워드를 읽기 전용으로 가져갈 때 사용하는
* 현재 phase의 임시 application seam.
*
* <p>키워드 조회를 PostKeywordRepository 직접 의존에서 분리해, 다른 컨텍스트가
* application seam을 통해 게시글 메타데이터를 소비하도록 고정한다.
* 다만 이것도 아직은 Post 컨텍스트 내부 모델에 밀접한 조회 seam 이며, 별도 published query 로
* 안정화된 상태는 아니다.</p>
*/
public class PostKeywordLookupService {

private final PostKeywordRepository postKeywordRepository;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
/**
* Activity 같은 다른 컨텍스트가 Post repository 직접 의존 없이 Post aggregate를 조회할 때 사용하는
* 현재 phase의 임시 application seam.
*
* <p>이 서비스는 Post repository를 외부 컨텍스트에 직접 노출하지 않기 위한
* repository-access choke-point이며, 아직은 Post aggregate 자체를 반환한다.
* 진짜 published query contract 가 되려면 후속 단계에서 DTO/port 로 더 좁혀야 한다.</p>
*/
public class PostLookupService {

private final PostRepository postRepository;
Expand Down
8 changes: 7 additions & 1 deletion src/main/java/com/techfork/post/domain/Post.java
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ public class Post extends BaseEntity {
this.techBlog = techBlog;
}

/**
* Source 컨텍스트가 정제한 현재 monolith 내부 handoff DTO 에서 기술 게시글 aggregate 를 생성한다.
*
* <p>DDD 순도 관점에서는 다른 컨텍스트 DTO 의 직접 참조를 줄이는 편이 더 이상적이지만,
* 현재 phase 에서는 {@link RssFeedItem} 직접 참조를 유지한다.
* 별도 published language, command object, 이벤트 handoff 전환은 후속 작업 범위다.</p>
*/
public static Post create(RssFeedItem item, TechBlog techBlog) {
return Post.builder()
.title(item.title())
Expand All @@ -106,7 +113,6 @@ public static Post create(RssFeedItem item, TechBlog techBlog) {
.build();
}


public void updateSummaries(String summary, String shortSummary) {
this.summary = summary;
this.shortSummary = shortSummary;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
/**
* 검색/추천용 PostDocument 내부에 포함되는 콘텐츠 청크 projection.
*
* <p>ContentChunk 는 RDB aggregate 나 독립 도메인 엔티티가 아니라, Elasticsearch read model
* 안에서만 사용하는 projection 값이다.</p>
*/
public class ContentChunk {

@Field(type = FieldType.Integer)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@JsonIgnoreProperties(ignoreUnknown = true)
/**
* Search / Recommendation 이 소비하는 게시글 projection.
*
* <p>PostDocument 는 Post aggregate 자체가 아니라 Elasticsearch 의 read model 이며,
* 검색/추천 컨텍스트가 후보 탐색과 리랭킹에 사용하는 published projection contract 이다.</p>
*/
public class PostDocument {

@Id
Expand Down
Loading