ADR-004: Factor User Fields from Domain Entities
Status: Accepted
Status: Accepted Date: 2025-11-02 Decision Makers: Development Team Related ADRs: ADR-003 (Meaningful Identifiers in Public APIs)
Context
The BATbern platform has a company-user-management-service that manages user profiles with comprehensive user information including personal details, company affiliation, bio, profile picture, preferences, and settings. Users can have multiple roles: ORGANIZER, SPEAKER, PARTNER, and ATTENDEE.
As we design domain-specific services (Speaker Coordination, Attendee Experience, Partner Coordination), we face a fundamental architectural question: Should domain entities (Speaker, Attendee, Partner) duplicate user profile fields or reference the User entity?
Current State
User Entity (company-user-management-service) contains:
- Personal identity:
firstName,lastName,email,username - Company affiliation:
companyId - Profile:
bio(TEXT),profilePictureUrl,profilePictureS3Key - Preferences: theme, language, notifications
- Settings: privacy, visibility, timezone
- Roles: Set of role assignments (ORGANIZER, SPEAKER, PARTNER, ATTENDEE)
Documented Domain Entities (from data architecture docs):
Speaker Entity currently documented with:
- β οΈ
email,firstName,lastName- DUPLICATES User - β οΈ
profile.shortBio- DUPLICATES User.bio - β οΈ
profile.profilePhotoUrl- DUPLICATES User.profilePictureUrl - β οΈ
position- Redundant field - β
expertiseAreas,speakingTopics,speakingHistory- Domain-specific - β
availability,workflowState- Domain-specific
Attendee Entity currently documented with:
- β οΈ
email,firstName,lastName- DUPLICATES User - β οΈ
position- Redundant field - β
eventRegistrations,engagementHistory- Domain-specific - β
newsletterSubscription,contentPreferences,gdprConsent- Domain-specific
Problem Statement
Duplicating user profile fields across domain entities creates multiple problems:
-
Data Inconsistency Risk:
- User updates email in User service, but Speaker entity has stale email
- Profile picture updated via User API, but Speaker API returns old photo
- Bio changed, but multiple domain entities have different versions
-
Violation of Single Source of Truth (SSOT):
- Which bio is correct: User.bio or Speaker.detailedBio?
- User changes name, must propagate to 4+ domain entities
- Synchronization complexity and potential race conditions
-
Storage Inefficiency:
- Same data stored in 5+ tables (users + 4 domain entities)
- Profile pictures referenced 5+ times
- Email addresses duplicated across entities
-
API Confusion:
- Should clients call
/users/{username}or/speakers/{username}to get speaker email? - Update bio via User API or Speaker API?
- Two APIs could return conflicting data
- Should clients call
-
Business Logic Duplication:
- Email validation in multiple services
- Profile picture upload logic duplicated
- Name formatting rules scattered across services
Explored Alternatives
Alternative 1: Duplicate Fields with Event-Driven Sync
Description: Keep duplicated fields in domain entities. Use domain events to synchronize changes from User service to domain services.
Example:
// Speaker entity has its own email, firstName, lastName
@Entity
public class Speaker {
private UUID id;
private String email; // Duplicated
private String firstName; // Duplicated
private String lastName; // Duplicated
// ... domain-specific fields
}
// Listen for UserUpdatedEvent and sync changes
@EventListener
public void onUserUpdated(UserUpdatedEvent event) {
speakerRepository.findByUserId(event.getUserId())
.ifPresent(speaker -> {
speaker.setEmail(event.getEmail());
speaker.setFirstName(event.getFirstName());
// ...
});
}
Rejected Because:
- Eventually consistent (temporary data inconsistency)
- Complex synchronization logic in every domain service
- Event ordering issues (what if events arrive out of order?)
- Increased storage and maintenance burden
- Still doesnβt solve βwhich is the source of truth?β question
Alternative 2: Read-Through Cache Pattern
Description: Domain entities reference User by ID. Services fetch User data on-demand and cache it.
Rejected Because:
- Adds caching complexity to every domain service
- Cache invalidation is hard (distributed cache coordination)
- N+1 query problem for lists (e.g., list of speakers)
- Doesnβt reduce API calls (still need to fetch user data)
Alternative 3: GraphQL Federation
Description: Use GraphQL to federate User and domain entities at query time.
Rejected Because:
- Requires GraphQL adoption (not current tech stack)
- Adds significant architectural complexity
- Still requires domain entities to store user IDs
- Doesnβt solve underlying data model question
Decision
We have decided to implement User Entity as Single Source of Truth with Foreign Key References:
Principle: Domain Entities Reference User, Never Duplicate
CRITICAL RULE (ADR-003 Microservice Isolation): Domain entities in different services MUST reference User by username (meaningful ID), NOT by userId UUID.
All domain entities (Speaker, Attendee, Partner) will:
- Store username (meaningful ID) for cross-service references to User entity
- NEVER store userId UUID when User is in a different service
- Expose username in APIs (public API layer per ADR-003)
- Never duplicate user profile fields (email, name, bio, photo)
- Only store domain-specific fields (expertiseAreas, engagementHistory, etc.)
- Use UUID PKs only for entities owned by this service
Database Layer: Meaningful ID Reference (ADR-003 Compliant)
CRITICAL: Domain entities in separate microservices MUST store username (meaningful ID), NOT userId UUID.
-- β
CORRECT: Speaker entity in speaker-coordination-service
CREATE TABLE speakers (
id UUID PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE, -- ADR-003: Meaningful ID, NOT userId UUID
availability VARCHAR(20), -- Domain-specific
workflow_state VARCHAR(50), -- Domain-specific
expertise_areas TEXT[], -- Domain-specific
speaking_topics TEXT[], -- Domain-specific
speaking_history JSONB, -- Domain-specific
-- NO email, firstName, lastName, bio, photo
-- NO FOREIGN KEY to users.id (different service)
);
CREATE UNIQUE INDEX idx_speakers_username ON speakers(username);
-- β WRONG: Don't use userId UUID for cross-service references
CREATE TABLE speakers (
id UUID PRIMARY KEY,
user_id UUID NOT NULL UNIQUE, -- β WRONG - UUID reference to different service
CONSTRAINT fk_speaker_user FOREIGN KEY (user_id) REFERENCES users(id) -- β WRONG - cross-service FK
);
Key Design Decisions:
usernameis UNIQUE (one-to-one relationship: one user = one speaker profile)- NO database foreign key to users table (different service, ADR-003)
- User data enriched via HTTP call to User Service API
- No duplicated user fields in speaker table
- Domain-specific fields only
API Layer: Username-Based Endpoints with HTTP Enrichment
Following ADR-003, APIs use username as the public identifier and enrich domain entity data with User data via HTTP calls:
GET /api/v1/speakers/{username}
Response DTO (combines User + Speaker via HTTP enrichment):
{
"username": "john.doe",
"email": "john.doe@example.com", // From User Service HTTP call
"firstName": "John", // From User Service HTTP call
"lastName": "Doe", // From User Service HTTP call
"company": "GoogleZH", // From User Service HTTP call
"bio": "Experienced architect...", // From User Service HTTP call
"profilePictureUrl": "https://cdn...", // From User Service HTTP call
"availability": "available", // From Speaker table
"workflowState": "ready", // From Speaker table
"expertiseAreas": ["Security", "Cloud"], // From Speaker table
"speakingTopics": ["Blockchain"] // From Speaker table
}
Service Layer: HTTP Client Integration (ADR-003 Compliant)
CRITICAL: Cross-service data access MUST use HTTP clients, NOT JPQL joins.
// β WRONG: Don't use JPQL joins across services
@Query("""
SELECT new SpeakerResponse(u.username, u.email, ...)
FROM Speaker s
INNER JOIN User u ON s.userId = u.id -- β Cross-service join
""")
// β
CORRECT: Use HTTP client for cross-service data
@Service
public class SpeakerService {
private final SpeakerRepository speakerRepository;
private final UserServiceClient userServiceClient;
@Transactional(readOnly = true)
public SpeakerResponse getSpeaker(String username) {
// 1. Get speaker from OWN database
Speaker speaker = speakerRepository.findByUsername(username)
.orElseThrow(() -> new SpeakerNotFoundException(username));
// 2. Enrich with User data via HTTP call
UserResponse user = userServiceClient.getUser(username);
// 3. Combine into response DTO
return SpeakerResponse.builder()
.username(user.getUsername())
.email(user.getEmail()) // From HTTP call
.firstName(user.getFirstName()) // From HTTP call
.lastName(user.getLastName()) // From HTTP call
.bio(user.getBio()) // From HTTP call
.profilePictureUrl(user.getProfilePictureUrl()) // From HTTP call
.availability(speaker.getAvailability()) // From Speaker table
.workflowState(speaker.getWorkflowState()) // From Speaker table
.expertiseAreas(speaker.getExpertiseAreas()) // From Speaker table
.build();
}
}
// HTTP Client with JWT token propagation and caching
@Component
public class UserServiceClient {
@Cacheable(value = "users", key = "#username")
public UserResponse getUser(String username) {
HttpHeaders headers = createHeadersWithJwtToken();
HttpEntity<Void> request = new HttpEntity<>(headers);
return restTemplate.exchange(
userServiceUrl + "/api/v1/users/" + username,
HttpMethod.GET,
request,
UserResponse.class
).getBody();
}
private HttpHeaders createHeadersWithJwtToken() {
// Extract JWT from SecurityContext and propagate
// See ADR-003 for full implementation
}
}
Repository Pattern (simple, no cross-service joins):
public interface SpeakerRepository extends JpaRepository<Speaker, UUID> {
Optional<Speaker> findByUsername(String username); // Uses username, not userId
}
Benefits:
- Microservice isolation (no cross-service database dependencies)
- Service can be deployed independently
- Clear service boundaries
- HTTP caching reduces latency (15-minute TTL)
- JWT token propagation for authentication
Domain Entity Design Patterns
Speaker Entity (Refactored - ADR-003 Compliant) β REMOVED per ADR-009 (historical)
REMOVED Fields (now in User):
- β
email,firstName,lastName - β
bio(use User.bio as single source) - β
profilePhotoUrl(use User.profilePictureUrl) - β
position(removed entirely as redundant)
KEPT Fields (domain-specific) β all dropped per ADR-009 Β§0.3 (the Speaker entity itself is deleted; these fields were not migrated to user_profiles because the platform does not use them):
β table deletedid(UUID primary key)β references to User byusername(VARCHAR - meaningful ID, NOT userId UUID)usernamenow live onspeaker_poolin event-management-serviceβ droppedavailabilityβ superseded byworkflowStatespeaker_pool.status(the 8-state ADR-009 workflow)β droppedexpertiseAreas,speakingTopicsβ droppedspeakingHistoryβ droppedlinkedInUrl,twitterHandleβ droppedcertifications,languagesβ moved toslotPreferencesspeaker_pool(per-event scope)
Attendee Entity (Refactored - ADR-003 Compliant)
REMOVED Fields (now in User):
- β
email,firstName,lastName - β
position(removed entirely)
KEPT Fields (domain-specific):
- β
id(UUID primary key) - β
username(VARCHAR - meaningful ID, NOT userId UUID) - β
eventRegistrations(many-to-many with events) - β
engagementHistory(session attendance, interactions) - β
newsletterSubscription(boolean + preferences) - β
contentPreferences(topics of interest - different from User.preferences) - β
gdprConsent(legal: consent timestamp, IP address)
Partner Entity (Refactored - ADR-003 Compliant)
Partners are company-centric (not person-centric):
// β
CORRECT: Partner entity in partner-coordination-service
@Entity
public class Partner {
private UUID id; // β
UUID PK for Partner (owned by this service)
private String companyName; // β
Meaningful ID, NOT companyId UUID
private PartnershipLevel level;
private LocalDate startDate;
private LocalDate endDate;
private boolean isActive;
// ...
}
// β
CORRECT: PartnerContact entity
@Entity
public class PartnerContact {
private UUID id;
private UUID partnerId; // β
OK - Partner is in THIS service
private String username; // β
Meaningful ID, NOT userId UUID
private ContactRole role; // Primary, Billing, Technical
private boolean isPrimary;
// NO: private UUID userId; β WRONG
}
// β WRONG: Don't use UUIDs for cross-service references
@Entity
public class Partner {
private UUID id;
private UUID companyId; // β WRONG - UUID reference to different service
}
Key Decision: PartnerContact references User by username (meaningful ID), NOT userId UUID (ADR-003 microservice isolation).
Cross-Service Communication (ADR-003 Microservice Isolation)
CRITICAL: Domain services access User information via HTTP API calls, NOT database joins.
When domain services need user information:
-
Synchronous HTTP calls (primary pattern):
- Use UserServiceClient with JWT token propagation
- Cache responses (15-minute TTL with Caffeine)
- Handle errors: HttpClientErrorException, HttpServerErrorException, ResourceAccessException
-
Asynchronous domain events (optional optimization):
- Listen to User domain events for cache warming
- NOT for maintaining duplicated data
Example - Speaker Service accesses User data:
// β
CORRECT: HTTP-based access
@Service
public class SpeakerService {
private final SpeakerRepository speakerRepository;
private final UserServiceClient userServiceClient;
public SpeakerResponse getSpeaker(String username) {
// 1. Get Speaker from OWN database
Speaker speaker = speakerRepository.findByUsername(username)
.orElseThrow(() -> new SpeakerNotFoundException(username));
// 2. Get User data via HTTP call (with caching)
UserResponse user = userServiceClient.getUser(username);
// 3. Combine into response
return buildSpeakerResponse(speaker, user);
}
}
// β WRONG: Don't use database joins across services
@Query("""
SELECT new SpeakerResponse(...)
FROM Speaker s
INNER JOIN User u ON s.userId = u.id -- β Cross-service join
""")
User Role Management
Superseded by ADR-009 (for the SPEAKER role): SPEAKER no longer triggers creation of a separate domain entity. The two sub-sections below (βUser Role Managementβ, βAPI Update Patternsβ) describe the original ADR-004 model where SPEAKER co-existed as a domain entity. The current model, per ADR-009, is: SPEAKER is a role grant on
role_assignments; there is noSpeakerentity. Per-event speaker data is onspeaker_pool; per-event content oncontent_submissions. Speaker-specific endpoints (/speakers/me,/speakers/{username}) are redesigned in Epic 11 Phase C around the User-as-identity model. The original text is retained as historical context for the ADR-004 decision.
User roles remain in User service:
@Entity
public class User {
// ...
@ElementCollection
@CollectionTable(name = "role_assignments")
@Enumerated(EnumType.STRING)
private Set<UserRole> roles; // ORGANIZER, SPEAKER, PARTNER, ATTENDEE
}
Important: Having roles = [SPEAKER] does NOT automatically create a Speaker entity.
Workflow (original ADR-004 model β superseded by ADR-009 for SPEAKER):
- User registers β User entity created with
roles = [ATTENDEE](default) - Organizer promotes user to Speaker β
roles += SPEAKER Speaker Coordination Service creates Speaker entity ββ REMOVED per ADR-009; no Speaker entity is created.Speaker(userId = user.id)createdNow user has both User profile + Speaker profileβ REMOVED per ADR-009; SPEAKER is a role, not a profile.
API Update Patterns
Reading Data (GET requests) β original ADR-004 model, superseded by ADR-009 for speaker endpoints:
- Always join User + domain entity
- Return flattened DTO to API consumers
Example:β endpoint redesigned in Epic 11 Phase C; no Speaker fields exist.GET /speakers/{username}returns User fields + Speaker fields
Updating User Fields (PUT/PATCH):
- User profile fields (name, bio, photo) β
/users/meendpoint Domain-specific fields ββ REMOVED per ADR-009 (no Speaker entity); the speaker-relevant fields (/speakers/meendpointbio,profile_picture_url) are updated via/users/me.
Example:
# Update bio (applies to all roles)
PATCH /api/v1/users/me
{
"bio": "Updated bio visible in Speaker, Attendee, Partner contexts"
}
# Update speaker-specific field
PATCH /api/v1/speakers/me
{
"availability": "busy",
"expertiseAreas": ["Security", "Cloud", "AI"]
}
Consequences
Positive
-
Single Source of Truth:
- User profile fields have exactly one storage location
- No synchronization logic needed
- No data inconsistency risk
-
Storage Efficiency:
- Email, name, bio, photo stored once (not 5+ times)
- Reduced database storage (estimated 40% reduction in duplicated data)
-
Simplified Business Logic:
- Email validation in one place
- Profile picture upload in one service
- Name formatting rules centralized
-
Clear API Boundaries:
- User API for user profile fields
- Domain APIs for domain-specific fields
- No confusion about which API to call
-
Referential Integrity:
- Database foreign keys enforce consistency
- Cascade deletes prevent orphaned records
- Type-safe relationships
-
Performance Benefits:
- JPQL joins are efficient (index-backed)
- Single query returns all data (no N+1)
- Caching simpler (cache complete DTOs)
-
Aligns with ADR-003:
- Internal: UUID foreign keys (performant joins)
- Public APIs: username identifiers (user-friendly)
- Consistent pattern across all services
Negative
-
Join Complexity:
- Queries require joins (User + domain entity)
- More complex JPQL queries
- Mitigation: Use constructor projections (established pattern)
-
Service Dependencies:
- Domain services depend on User service schema
- Schema changes in User entity affect domain services
- Mitigation: User entity is stable, managed by shared-kernel
-
Migration Complexity:
- If data already exists with duplicated fields, migration required
- Status: Not applicable (entities not yet implemented)
-
Cannot Have Domain-Specific Variations:
- Speaker cannot have different bio from User
- Cannot have speaker-specific email
- Mitigation: This is intentional design (SSOT principle)
-
Orphan Prevention Required:
- Deleting User cascades to Speaker/Attendee/Partner
- Must handle cleanup carefully
- Mitigation: ON DELETE CASCADE handles this automatically
Technical Debt
None Created: This refactoring eliminates technical debt by enforcing SSOT principle from the start.
Implementation Guidelines
For New Domain Services (ADR-003 Compliant)
When creating a new domain service (e.g., Speaker Coordination Service):
-
Entity Design (stores username, NOT userId UUID):
java@Entity @Table(name = "speakers") public class Speaker { @Id @GeneratedValue(strategy = GenerationType.AUTO) private UUID id; @Column(name = "username", nullable = false, unique = true, length = 100) private String username; // β Meaningful ID, NOT userId UUID // Domain-specific fields only @Enumerated(EnumType.STRING) private Availability availability; // NO email, firstName, lastName, bio, photo // NO userId UUID } -
Repository Pattern (simple, no cross-service joins):
javapublic interface SpeakerRepository extends JpaRepository<Speaker, UUID> { Optional<Speaker> findByUsername(String username); // Simple query } -
Service Pattern (HTTP-based enrichment):
java@Service public class SpeakerService { private final SpeakerRepository speakerRepository; private final UserServiceClient userServiceClient; public SpeakerResponse getSpeaker(String username) { Speaker speaker = speakerRepository.findByUsername(username) .orElseThrow(() -> new SpeakerNotFoundException(username)); UserResponse user = userServiceClient.getUser(username); return SpeakerResponse.builder() .username(user.getUsername()) .email(user.getEmail()) // From HTTP call .firstName(user.getFirstName()) // From HTTP call .availability(speaker.getAvailability()) // From Speaker table .build(); } } -
DTO Design:
java// Response DTO combines User + Speaker (via HTTP enrichment) public record SpeakerResponse( String username, // From User (HTTP) String email, // From User (HTTP) String firstName, // From User (HTTP) String bio, // From User (HTTP) String availability, // From Speaker table List<String> topics // From Speaker table ) {} -
Controller Pattern:
java@GetMapping("/speakers/{username}") public SpeakerResponse getSpeaker(@PathVariable String username) { return speakerService.getSpeaker(username); // Service handles HTTP enrichment }
Database Migration Template (ADR-003 Compliant)
For each domain entity:
-- V1__Create_speakers_table.sql
CREATE TABLE speakers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username VARCHAR(100) NOT NULL UNIQUE, -- β
Meaningful ID, NOT user_id UUID
-- Domain-specific fields
availability VARCHAR(20),
workflow_state VARCHAR(50),
expertise_areas TEXT[],
-- NO: email, first_name, last_name, bio, photo
-- NO: user_id UUID (cross-service reference)
-- NO: FOREIGN KEY to users.id (different service)
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_speakers_username ON speakers(username);
CREATE INDEX idx_speakers_availability ON speakers(availability);
CREATE INDEX idx_speakers_workflow_state ON speakers(workflow_state);
-- β WRONG: Don't do this for cross-service references
CREATE TABLE speakers (
id UUID PRIMARY KEY,
user_id UUID NOT NULL UNIQUE, -- β WRONG
CONSTRAINT fk_speaker_user FOREIGN KEY (user_id) REFERENCES users(id) -- β WRONG
);
Related Documents
- ADR-003: Meaningful Identifiers in Public APIs (dual-identifier pattern)
- Architecture:
docs/architecture/03-data-architecture.md(will be updated) - API Design:
docs/architecture/04-api-*.md(will be updated) - OpenAPI Specs:
docs/api/users-api.openapi.yml(already correct)docs/api/speakers-api.openapi.yml(will be updated)docs/api/attendees-api.openapi.yml(will be created)docs/api/partners-api.openapi.yml(will be updated)
Implementation Status
Current Phase: Documentation Refactoring
Future Phase: Code Implementation
-
Speaker entity implementation (speaker-coordination-service)β WITHDRAWN per ADR-009 (no Speaker entity; SPEAKER is a User role; per-event data onspeaker_pool
Alignment with Domain-Driven Design
This decision aligns with DDD principles:
-
Bounded Contexts:
- User context: Identity, authentication, profile
- Speaker context: Speaking workflow, expertise, availability
- Attendee context: Event registrations, engagement
- Clear boundaries with no duplication
-
Aggregates:
- User is the aggregate root for profile data
- Speaker is an aggregate root for speaker workflow data
- References between aggregates use IDs (userId)
-
Shared Kernel:
- User entity types published to all services
- Consistent ID patterns (ADR-003)
- Shared slug generation service
-
Anti-Corruption Layer:
- DTOs prevent domain entities from leaking across boundaries
- API layer translates between internal (UUID) and public (username) identifiers
Evolution: API-Based Access Pattern (2025-11-06)
Context
The original ADR recommended JPQL joins at the database layer to combine User and domain entity data. However, during implementation of Story 1.15a.1b (Session-User Many-to-Many), we encountered cross-service JPA entity scanning issues that caused test failures.
Problem
When event-management-service included User entity in @EntityScan, it triggered Hibernate schema validation expecting the user_profiles table to exist in the event-management-service test database, causing 101 test failures with βSchema-validation: missing table [user_profiles]β.
Solution: API-Based User Data Retrieval
Instead of direct database joins, we implemented API-based access to User Management Service:
// OLD APPROACH (Database Join):
@Query("""
SELECT new SessionSpeakerResponse(
u.username, u.firstName, u.lastName, ...
)
FROM SessionUser su
INNER JOIN User u ON su.userId = u.id
WHERE su.sessionId = :sessionId
""")
List<SessionSpeakerResponse> findBySessionId(@Param("sessionId") UUID sessionId);
// NEW APPROACH (API-Based):
@Service
public class SessionUserService {
private final UserApiClient userApiClient;
public SessionSpeakerResponse assignSpeakerToSession(...) {
// Validate user exists via API
UserProfileDTO user = userApiClient.getUserByUsername(username);
// Create SessionUser with both userId (FK) and username (API lookup)
SessionUser sessionUser = SessionUser.builder()
.userId(user.getId()) // Foreign key (backward compatibility)
.username(username) // For API lookups
.build();
}
}
Implementation Details
1. REST Client Infrastructure:
UserApiClientinterface withRestTemplateimplementation- JWT token propagation via
SecurityContextfor service-to-service auth - 15-minute Caffeine cache (expected 80-90% hit rate)
- Comprehensive exception handling (
UserNotFoundException,UserServiceException)
2. Database Schema Evolution:
-- Added username column to support API lookups
ALTER TABLE session_users
ADD COLUMN username VARCHAR(100);
-- Maintain userId for backward compatibility
-- Both userId (FK) and username coexist during transition
3. Configuration:
# application.yml
user-service:
base-url: ${USER_SERVICE_URL:http://localhost:8081}
# Environment variable
USER_SERVICE_URL=http://company-user-management-service:8080
Trade-offs
Advantages:
- β Service Isolation: event-management-service doesnβt need User entity in classpath
- β Test Independence: Integration tests donβt require user_profiles table
- β Clear Service Boundaries: API-first communication pattern
- β Caching: 15-minute cache reduces API calls
- β Type Safety: DTOs enforce API contract
Disadvantages:
- β οΈ Network Latency: API calls slower than database joins (mitigated by caching)
- β οΈ Service Dependency: event-management-service requires User Management Service availability
- β οΈ Fail-Fast: No graceful degradation if User Management Service is down
When to Use Each Pattern
Use API-Based Access When:
- Services deployed independently (microservices architecture)
- Service boundaries must be strictly enforced
- Test isolation is critical
- Services use different databases or deployment models
Use Database Joins When:
- Services share same database (monolithic or modular monolith)
- Performance is critical (high-frequency queries)
- Services are tightly coupled by design
- Strong consistency required
Current Status
event-management-service uses API-based access for SessionUser β User relationship:
- β
Implemented:
UserApiClientwith caching - β Tests passing: 12/12 unit tests, 9/11 integration tests (2 disabled for API Gateway context)
- β Migration complete: Cross-service entity scanning removed
Future services should evaluate both patterns based on deployment architecture and choose the approach that best fits their needs while maintaining the core ADR-004 principle: never duplicate user profile fields.
Revision History
| Date | Version | Changes | Author |
|---|---|---|---|
| 2025-11-02 | 1.0 | Initial ADR creation | Winston (Architect Agent) |
| 2025-11-06 | 1.1 | Added API-based access evolution section | Claude Code (Story 1.15a.1b) |
| 2025-11-08 | 1.2 | CRITICAL CLARIFICATION: Domain entities MUST store username (meaningful ID), NOT userId UUID for cross-service references. Updated all examples to use HTTP-based access with meaningful IDs (ADR-003 microservice isolation). | Claude Code |
| 2026-05-15 | 1.3 | Per ADR-009 (Unified Speaker Workflow): SPEAKER role does not require additional User columns. User.bio serves as short CV and User.profile_picture_url serves as speaker portrait. The speakers table is deleted; user_profiles is not extended. Per-event speaker data lives on speaker_pool; per-event content on content_submissions + session_users. See ADR-009 Β§0.3. |
Claude Code (Story 11.A.1) |
This ADR establishes the foundational principle: Domain entities reference User by meaningful ID (username) and never duplicate user profile fields. Implementation MUST use HTTP-based access for cross-service communication (ADR-003 microservices pattern).