A social knowledge tool for researchers built on ATProto
1# User Data Enrichment in DDD
2
3This document outlines Domain-Driven Design (DDD) patterns for handling data enrichment from external services, particularly for user profile data that needs to be combined with core domain data.
4
5## The Problem
6
7When displaying collections, cards, or other domain entities, we often need to show rich user information (names, avatars, etc.) that comes from a different bounded context or external service. The challenge is how to combine this data while maintaining clean domain boundaries.
8
9## DDD Patterns for Data Enrichment
10
11### 1. Application Service Orchestration (Recommended)
12
13This is the most common DDD pattern for cross-cutting concerns like user profile enrichment:
14
15```typescript
16// Application Service coordinates between bounded contexts
17export class CollectionApplicationService {
18 constructor(
19 private getMyCollectionsUseCase: GetMyCollectionsUseCase,
20 private userProfileService: IUserProfileService, // External service
21 ) {}
22
23 async getMyCollectionsWithProfiles(query: GetMyCollectionsQuery) {
24 // 1. Execute core use case
25 const collectionsResult = await this.getMyCollectionsUseCase.execute(query);
26
27 if (collectionsResult.isErr()) {
28 return collectionsResult;
29 }
30
31 // 2. Enrich with external data
32 const curatorIds = collectionsResult.value.collections.map(
33 (c) => c.createdBy.id,
34 );
35 const profiles = await this.userProfileService.getProfiles(curatorIds);
36
37 // 3. Merge data
38 const enrichedCollections = collectionsResult.value.collections.map(
39 (collection) => ({
40 ...collection,
41 createdBy: {
42 ...collection.createdBy,
43 ...profiles.get(collection.createdBy.id),
44 },
45 }),
46 );
47
48 return ok({
49 ...collectionsResult.value,
50 collections: enrichedCollections,
51 });
52 }
53}
54```
55
56### 2. Domain Service for Cross-Context Integration
57
58When the enrichment is a core business concern:
59
60```typescript
61// Domain Service in the Cards bounded context
62export class CollectionEnrichmentService implements DomainService {
63 constructor(
64 private userContextGateway: IUserContextGateway, // Anti-corruption layer
65 ) {}
66
67 async enrichCollectionsWithCreatorInfo(
68 collections: Collection[],
69 ): Promise<EnrichedCollectionData[]> {
70 const creatorIds = collections.map((c) => c.authorId.value);
71 const creatorProfiles =
72 await this.userContextGateway.getCreatorProfiles(creatorIds);
73
74 return collections.map((collection) => ({
75 collection,
76 creatorProfile: creatorProfiles.get(collection.authorId.value),
77 }));
78 }
79}
80```
81
82### 3. Anti-Corruption Layer Pattern
83
84Protect your domain from external service changes:
85
86```typescript
87// Interface in your domain
88export interface IUserContextGateway {
89 getCreatorProfiles(userIds: string[]): Promise<Map<string, CreatorProfile>>;
90}
91
92// Domain model for external data
93export interface CreatorProfile {
94 id: string;
95 displayName: string;
96 avatarUrl?: string;
97}
98
99// Implementation that adapts external service
100export class UserContextGateway implements IUserContextGateway {
101 constructor(private externalUserService: ExternalUserAPI) {}
102
103 async getCreatorProfiles(
104 userIds: string[],
105 ): Promise<Map<string, CreatorProfile>> {
106 const externalProfiles =
107 await this.externalUserService.getUsersByIds(userIds);
108
109 // Transform external format to domain format
110 const profiles = new Map<string, CreatorProfile>();
111 externalProfiles.forEach((profile) => {
112 profiles.set(profile.user_id, {
113 id: profile.user_id,
114 displayName: profile.full_name || profile.username,
115 avatarUrl: profile.profile_image_url,
116 });
117 });
118
119 return profiles;
120 }
121}
122```
123
124### 4. Event-Driven Enrichment (For High Performance)
125
126When you need to avoid real-time calls:
127
128```typescript
129// Read model that's kept in sync via events
130export interface EnrichedCollectionReadModel {
131 id: string;
132 name: string;
133 description?: string;
134 cardCount: number;
135 createdAt: Date;
136 updatedAt: Date;
137 createdBy: {
138 id: string;
139 name: string;
140 avatarUrl?: string;
141 };
142}
143
144// Event handler that updates read model when user profiles change
145export class UserProfileUpdatedHandler {
146 constructor(
147 private collectionReadModelRepo: ICollectionReadModelRepository,
148 ) {}
149
150 async handle(event: UserProfileUpdatedEvent) {
151 // Update all collections created by this user
152 await this.collectionReadModelRepo.updateCreatorInfo(event.userId, {
153 name: event.newDisplayName,
154 avatarUrl: event.newAvatarUrl,
155 });
156 }
157}
158```
159
160## Current Implementation Analysis
161
162Our current approach with `ICuratorEnrichmentService` follows good DDD principles:
163
164```typescript
165// This is a good application-level service
166export interface ICuratorEnrichmentService {
167 enrichCurators(curatorIds: string[]): Promise<Map<string, CuratorInfo>>;
168}
169```
170
171**Strengths:**
172
173- ✅ Keeps enrichment logic out of the domain
174- ✅ Single responsibility for user data enrichment
175- ✅ Easy to test and mock
176- ✅ Follows dependency inversion
177
178## Recommended Improvements
179
180### 1. Add Anti-Corruption Layer
181
182```typescript
183export class CuratorEnrichmentService implements ICuratorEnrichmentService {
184 constructor(private userContextGateway: IUserContextGateway) {}
185
186 async enrichCurators(
187 curatorIds: string[],
188 ): Promise<Map<string, CuratorInfo>> {
189 try {
190 return await this.userContextGateway.getCreatorProfiles(curatorIds);
191 } catch (error) {
192 // Graceful degradation - return minimal info
193 const fallbackMap = new Map<string, CuratorInfo>();
194 curatorIds.forEach((id) => {
195 fallbackMap.set(id, { id, name: 'Unknown User' });
196 });
197 return fallbackMap;
198 }
199 }
200}
201```
202
203### 2. Add Caching for Performance
204
205```typescript
206export class CachedCuratorEnrichmentService
207 implements ICuratorEnrichmentService
208{
209 constructor(
210 private userContextGateway: IUserContextGateway,
211 private cache: ICache,
212 ) {}
213
214 async enrichCurators(
215 curatorIds: string[],
216 ): Promise<Map<string, CuratorInfo>> {
217 const cached = await this.cache.getMany(curatorIds);
218 const uncachedIds = curatorIds.filter((id) => !cached.has(id));
219
220 if (uncachedIds.length > 0) {
221 const fresh =
222 await this.userContextGateway.getCreatorProfiles(uncachedIds);
223 await this.cache.setMany(fresh, { ttl: 300 }); // 5 min cache
224
225 // Merge cached and fresh data
226 fresh.forEach((value, key) => cached.set(key, value));
227 }
228
229 return cached;
230 }
231}
232```
233
234### 3. Move to Application Service (Optional)
235
236If you want to keep the use case purely focused on the core domain:
237
238```typescript
239export class CollectionQueryApplicationService {
240 constructor(
241 private getMyCollectionsUseCase: GetMyCollectionsUseCase,
242 private curatorEnrichmentService: ICuratorEnrichmentService,
243 ) {}
244
245 async getMyCollectionsWithProfiles(query: GetMyCollectionsQuery) {
246 // Execute core use case (returns minimal curator data)
247 const result = await this.getMyCollectionsUseCase.execute(query);
248
249 if (result.isErr()) return result;
250
251 // Enrich at application service level
252 const curatorIds = result.value.collections.map((c) => c.authorId);
253 const enrichedCurators =
254 await this.curatorEnrichmentService.enrichCurators(curatorIds);
255
256 // Transform to enriched DTOs
257 const enrichedCollections = result.value.collections.map((collection) => ({
258 ...collection,
259 createdBy: enrichedCurators.get(collection.authorId) || {
260 id: collection.authorId,
261 name: 'Unknown User',
262 },
263 }));
264
265 return ok({
266 ...result.value,
267 collections: enrichedCollections,
268 });
269 }
270}
271```
272
273## Best Practices
274
2751. **Separate Concerns**: Keep domain logic separate from external data enrichment
2762. **Graceful Degradation**: Always have fallbacks when external services fail
2773. **Performance**: Consider caching for frequently accessed user data
2784. **Anti-Corruption**: Protect your domain from external service changes
2795. **Testability**: Make enrichment services easy to mock and test
2806. **Consistency**: Use the same enrichment patterns across your application
281
282## When to Use Each Pattern
283
284- **Application Service Orchestration**: Most common case, good default choice
285- **Domain Service**: When enrichment is core business logic
286- **Anti-Corruption Layer**: When external services are unstable or have different models
287- **Event-Driven**: When you need high performance and can tolerate eventual consistency
288
289Our current implementation is solid and follows DDD principles well. The main opportunities for improvement are adding resilience (graceful degradation) and performance optimizations (caching).