ADR-003: Meaningful Identifiers in Public APIs
Status: Accepted
- Story 1.16.1 (Meaningful IDs for Public-Facing URLs)
- Story 1.16.2 (Eliminate UUIDs from API)
Context
The initial BATbern platform design used UUIDs as primary identifiers exposed in public APIs and URLs. While UUIDs provide excellent technical properties (uniqueness, immutability, non-sequential), they create several problems for user experience and developer experience:
Problems with UUID-Based APIs
-
Poor User Experience:
- URLs like
/events/f47ac10b-58cc-4372-a567-0e02b2c3d479are not memorable or shareable - Users cannot type or verbally communicate URLs
- No semantic meaning - impossible to distinguish
BATbern #56from its UUID
- URLs like
-
Developer Experience Issues:
- API responses filled with cryptic UUID strings
- Difficult to debug and trace entities across systems
- API documentation examples using UUIDs are confusing
- Manual testing requires copy-pasting UUIDs constantly
-
SEO and Marketing Impact:
- Search engines prefer semantic URLs
- Marketing materials cannot use clean URLs
- Event codes like βBATbern142β are brandable identifiers
-
Business Requirements:
- Event organizers refer to events by number (e.g., βBATbern #142β)
- Companies have established short names (e.g., βGoogleZHβ, βUBSβ)
- Users expect profile URLs like
/users/john.doe, not UUID paths
Explored Alternatives
-
Keep UUIDs in APIs, Use Slugs Only in Frontend Routes
- Rejected: Requires frontend to maintain UUID/slug mappings
- Creates two-tier identifier system (public vs internal)
- Still exposes UUIDs in API responses
-
Use Numeric Sequential IDs
- Rejected for PKs: Exposes business metrics (event count, user count)
- Creates security risks (enumeration attacks)
- Does not solve human-readability for companies/users
-
Hybrid Approach: Database UUIDs + API Slugs
- Selected: Provides both technical benefits and UX benefits
- Aligns with industry best practices (GitHub, Stripe, etc.)
Decision
We have decided to implement a dual-identifier strategy where:
Database Layer (Internal - UUIDs for Own Entities Only)
Within-Service References (Same Microservice):
- UUID primary keys for all entities owned by this service
- UUID foreign keys ONLY for relationships within the same serviceβs database
- Performance characteristics of UUID PKs preserved:
- B-tree index efficiency
- Immutable identifiers (safe for caching)
- Distributed ID generation (no coordination needed)
Cross-Service References (Different Microservices):
- NEVER use UUID foreign keys to entities in other services
- ALWAYS store meaningful IDs (companyName, username, eventCode) for cross-service references
- NO database foreign key constraints across service boundaries
- Microservice isolation - each service owns its own database schema
API Layer (Public - Meaningful Identifiers)
-
Meaningful IDs exposed in all public APIs and URLs:
- Events:
eventCode(e.g., βBATbern56β) - Companies:
name(e.g., βGoogleZHβ) - Users:
username(e.g., βjohn.doeβ) - Sessions:
slug(e.g., βblockchain-security-101β)
- Events:
-
Meaningful IDs are unique alternate keys:
- Database unique constraints ensure uniqueness
- B-tree indexes for lookup performance
- Meaningful IDs are immutable after creation
-
DTOs use only meaningful IDs:
json{ "id": "BATbern56", "title": "Modern Architecture Symposium", "organizerId": "john.doe", "venueId": "UniBern" }
Identifier Format Standards
Event Codes
- Format:
BATbern{eventNumber} - Example:
BATbern142 - Generation: Auto-generated from sequential event number
- Pattern:
^BATbern[0-9]+$ - Rationale: Matches existing business naming convention
Company Names
- Format: Alphanumeric, 1-12 characters
- Example:
GoogleZH,UBS,Swisscom - Generation: User-provided during company creation
- Pattern:
^[a-zA-Z0-9]{1,12}$ - Case: Case-insensitive lookups (
GoogleZH=googlezh) - Rationale: Short, memorable, brandable identifiers
Usernames
- Format:
{firstname}.{lastname}[.{n}] - Example:
john.doe,peter.mueller.2 - Generation: Auto-generated from userβs name
- Pattern:
^[a-z]+\.[a-z]+(\.[0-9]+)?$ - German Characters:
Γ€βae,ΓΆβoe,ΓΌβue,Γβss - Collision Handling: Append
.2,.3, etc. for duplicates - Rationale: Professional, predictable, standard format
Session Slugs
- Format: Slugified session title
- Example:
blockchain-security-101 - Generation: Auto-generated from session title
- Scope: Unique within event (same slug allowed in different events)
- Pattern: Lowercase, hyphens, no special characters
- Rationale: SEO-friendly, human-readable session URLs
Implementation Architecture
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Frontend Layer β
β URLs: /events/BATbern56, /users/john.doe β
β API Calls: GET /api/v1/events/BATbern56 β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββ
β Meaningful IDs only
β
ββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββ
β API Gateway Layer β
β Routing: /api/v1/events/{eventCode} β
β JWT Validation, Rate Limiting β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββ
β Service Layer (Controllers) β
β @GetMapping("/events/{eventCode}") β
β β eventService.findByEventCode(eventCode) β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββ
β Repository Layer (JPA/JPQL) β
β findByEventCode(String eventCode) β Event entity β
β Query: SELECT e FROM Event e WHERE e.eventCode = :codeβ
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββ
β Database Layer (PostgreSQL) β
β events table: β
β - id UUID PRIMARY KEY (internal) β
β - event_code VARCHAR(20) UNIQUE (public API) β
β - organizer_id UUID β users.id (internal FK) β
β - title, description, etc. β
β β
β Index: CREATE UNIQUE INDEX ON events(event_code) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
DTO Projection Strategy
To avoid N+1 queries when resolving foreign keys to meaningful IDs, we use JPQL constructor projections:
@Query("""
SELECT new ch.batbern.events.dto.EventResponse(
e.eventCode,
e.title,
u.username
)
FROM Event e
LEFT JOIN User u ON e.organizerId = u.id
WHERE e.eventCode = :eventCode
""")
Optional<EventResponse> findEventResponseByCode(@Param("eventCode") String eventCode);
Benefits:
- Single database query (no N+1 problem)
- Automatic FK resolution (UUID β username)
- Type-safe DTO construction
- Performant (index-backed joins)
Microservice Isolation Rules
CRITICAL: Services MUST store meaningful IDs for cross-service references, NOT UUIDs.
Rule 1: Own Entities Use UUID PKs
// β
CORRECT: Event entity in event-management-service
@Entity
@Table(name = "events")
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id; // β
UUID PK for entity owned by this service
@Column(name = "event_code", nullable = false, unique = true)
private String eventCode; // β
Meaningful ID for public APIs
}
Rule 2: Cross-Service References Use Meaningful IDs (NOT UUIDs)
// β
CORRECT: Partner entity in partner-coordination-service
@Entity
@Table(name = "partners")
public class Partner {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id; // β
UUID PK for Partner (owned by this service)
// Cross-service reference to Company (different service)
@Column(name = "company_name", nullable = false, unique = true, length = 12)
private String companyName; // β
Meaningful ID, NOT companyId UUID
// NO: private UUID companyId; β WRONG - don't use UUID for other service's entity
}
// β
CORRECT: PartnerContact entity
@Entity
@Table(name = "partner_contacts")
public class PartnerContact {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@Column(name = "partner_id", nullable = false)
private UUID partnerId; // β
OK - Partner is in THIS service
// Cross-service reference to User (different service)
@Column(name = "username", nullable = false, length = 100)
private String username; // β
Meaningful ID, NOT userId UUID
// NO: private UUID userId; β WRONG - don't use UUID for other service's entity
}
Rule 3: NO Database Foreign Keys Across Services
-- β
CORRECT: partner-coordination-service database schema
CREATE TABLE partners (
id UUID PRIMARY KEY,
company_name VARCHAR(12) NOT NULL UNIQUE, -- β
Meaningful ID
-- NO FOREIGN KEY to companies.id β
Correct
);
CREATE TABLE partner_contacts (
id UUID PRIMARY KEY,
partner_id UUID NOT NULL REFERENCES partners(id), -- β
OK - same service
username VARCHAR(100) NOT NULL, -- β
Meaningful ID
-- NO FOREIGN KEY to users.id β
Correct
);
-- β WRONG: Don't do this
CREATE TABLE partners (
id UUID PRIMARY KEY,
company_id UUID NOT NULL, -- β WRONG - UUID reference
FOREIGN KEY (company_id) REFERENCES companies(id) -- β WRONG - cross-service FK
);
Rule 4: Cross-Service Data Access via HTTP APIs
// β
CORRECT: Partner service accesses Company data via HTTP
@Service
public class PartnerService {
private final CompanyServiceClient companyServiceClient;
private final UserServiceClient userServiceClient;
@Cacheable(value = "partners", key = "#companyName")
public PartnerResponse getPartner(String companyName) {
// 1. Get partner from OWN database (uses companyName)
Partner partner = partnerRepository.findByCompanyName(companyName)
.orElseThrow(() -> new PartnerNotFoundException(companyName));
// 2. Enrich with Company data via HTTP call
CompanyResponse company = companyServiceClient.getCompany(companyName);
return PartnerResponse.builder()
.companyName(partner.getCompanyName())
.companyDisplayName(company.getDisplayName()) // From HTTP call
.partnershipLevel(partner.getPartnershipLevel())
.build();
}
}
// HTTP client with JWT token propagation
@Component
public class CompanyServiceClient {
@Cacheable(value = "companies", key = "#companyName")
public CompanyResponse getCompany(String companyName) {
HttpHeaders headers = createHeadersWithJwtToken();
HttpEntity<Void> request = new HttpEntity<>(headers);
return restTemplate.exchange(
companyServiceUrl + "/api/v1/companies/" + companyName,
HttpMethod.GET,
request,
CompanyResponse.class
).getBody();
}
}
Rule 5: Within-Service Relationships Can Use UUID FKs
// β
CORRECT: TopicVote entity in partner-coordination-service
@Entity
@Table(name = "topic_votes")
public class TopicVote {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
// Within same service - UUID FK is OK
@Column(name = "partner_id", nullable = false)
private UUID partnerId; // β
OK - Partner is in THIS service
// Cross-service reference - use meaningful ID
@Column(name = "topic_id", nullable = false)
private String topicId; // β
Meaningful ID to Event Management Service topic
// NO: private UUID topicUuid; β WRONG
}
-- Database schema shows the pattern
CREATE TABLE topic_votes (
id UUID PRIMARY KEY,
partner_id UUID NOT NULL REFERENCES partners(id), -- β
Within-service FK OK
topic_id VARCHAR(100) NOT NULL, -- β
Cross-service: meaningful ID
-- NO FOREIGN KEY to topics.id β
Correct
);
Summary: UUID vs Meaningful ID Decision Tree
Does this field reference an entity?
ββ YES β Is the entity in THIS service's database?
β ββ YES β Use UUID FK β
β β Example: partner_id UUID REFERENCES partners(id)
β ββ NO β Use Meaningful ID β
β Example: company_name VARCHAR(12), username VARCHAR(100)
ββ NO β Use appropriate data type
Example: vote_value INTEGER, description TEXT
Shared Kernel Services
Created SlugGenerationService in shared-kernel for consistent slug generation:
@Service
public class SlugGenerationService {
public String generateUsername(String firstName, String lastName) {
String first = slugifyName(firstName);
String last = slugifyName(lastName);
return first + "." + last;
}
public String ensureUniqueUsername(String base, Function<String, Boolean> exists) {
if (!exists.apply(base)) return base;
int suffix = 2;
while (exists.apply(base + "." + suffix)) {
suffix++;
}
return base + "." + suffix;
}
public String slugifySessionTitle(String title) {
return title.toLowerCase()
.replaceAll("Γ€", "ae")
.replaceAll("ΓΆ", "oe")
.replaceAll("ΓΌ", "ue")
.replaceAll("Γ", "ss")
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("^-|-$", "");
}
}
Database Migrations
Each service required schema migrations to add meaningful ID columns:
Event Management Service (V3, V4):
-- V3__Add_event_code_and_organizer_username.sql
ALTER TABLE events
ADD COLUMN event_code VARCHAR(50) UNIQUE NOT NULL;
ALTER TABLE events
ADD COLUMN organizer_username VARCHAR(100) NOT NULL;
CREATE UNIQUE INDEX idx_events_event_code ON events(event_code);
Company-User Management Service (V3, V10):
-- V3__Create_companies_schema.sql (updated)
ALTER TABLE companies
ADD COLUMN name VARCHAR(12) UNIQUE NOT NULL;
ALTER TABLE companies
ADD COLUMN display_name VARCHAR(255) NOT NULL;
CREATE UNIQUE INDEX idx_companies_name_lower ON companies(LOWER(name));
-- V10__Create_logos_table.sql
-- Logos table references companies by name (not UUID FK)
ALTER TABLE logos
ADD COLUMN entity_name VARCHAR(255) NOT NULL;
Consequences
Positive
-
Improved User Experience:
- Shareable URLs:
batbern.ch/events/BATbern56 - Memorable identifiers for marketing and communication
- Professional appearance (
/users/john.doevs UUID)
- Shareable URLs:
-
Better Developer Experience:
- Clean, readable API responses
- Easy debugging and tracing
- Intuitive API documentation
- Faster manual testing (no UUID copy-paste)
-
SEO Benefits:
- Semantic URLs improve search engine ranking
- Event codes appear in search results
- Better social media link previews
-
Business Alignment:
- Event codes match physical conference naming (BATbern #142)
- Company short names match existing branding
- Username format matches professional standards
-
Performance Maintained:
- UUID PKs preserve join performance
- Unique indexes on meaningful IDs are fast (B-tree)
- Single-query DTO projections avoid N+1
Negative
-
Schema Complexity:
- Dual identifier strategy adds columns
- More indexes to maintain (storage overhead ~5-10%)
- Migration complexity for existing data (mitigated: greenfield)
-
API Backward Compatibility:
- Breaking change for existing API consumers
- Mitigation: Accept both UUID and meaningful ID during transition
- Status: Not applicable (greenfield system)
-
Collision Handling Required:
- Username collisions need suffix logic (
.2,.3) - Company name collisions require user intervention
- Mitigation: Unique constraints + clear error messages
- Username collisions need suffix logic (
-
ID Immutability Constraints:
- Meaningful IDs must be immutable (used in URLs, external refs)
- Cannot rename companies or change usernames easily
- Mitigation: Design UX to set meaningful IDs at creation time
-
Cross-Service Coordination:
- User service must provide username to event service
- Requires consistent slug generation across services
- Mitigation: SlugGenerationService in shared-kernel
Technical Debt
-
ID Wrapper Removal:
- Removed
EventId,UserId,CompanyIdwrapper classes - Simplified domain events to use
Stringdirectly - Benefit: Reduced complexity, cleaner DTOs
- Risk: Type safety reduced (String vs EventId)
- Mitigation: OpenAPI schemas enforce format validation
- Removed
-
No UUID in Domain Events:
- Domain events use meaningful IDs (
eventCode,username) - Event consumers must handle meaningful ID lookups
- Risk: Event processing slightly more complex
- Mitigation: Index-backed lookups are fast
- Domain events use meaningful IDs (
Implementation Status
Completed (Stories 1.16.1 + 1.16.2)
Testing Coverage
Integration Tests (PostgreSQL via Testcontainers):
- Event repository:
findByEventCode()tested - Company repository:
findByNameIgnoreCase()tested - User service: Username generation + collision handling tested
- Session repository: Slug uniqueness within event tested
E2E Tests (Bruno):
GET /api/v1/events/{eventCode}GET /api/v1/companies/{companyName}POST /api/v1/files/presigned-url(entity_name parameter)
Frontend Tests (Vitest):
- React Router with meaningful ID paths
- API service layer uses meaningful IDs
- 1277 passing tests (40 skipped for unimplemented backend features)
Alignment with Industry Best Practices
This decision aligns with how leading platforms handle identifiers:
| Platform | Internal ID | Public API ID | Example |
|---|---|---|---|
| GitHub | Numeric DB ID | Repository slug | github.com/facebook/react |
| Stripe | Numeric DB ID | Prefixed string | cus_1234567890 |
| Slack | UUID/Numeric | Channel name | #general, @john.doe |
| Medium | UUID | Article slug | medium.com/@author/article-title-abc123 |
| BATbern | UUID | Meaningful ID | batbern.ch/events/BATbern56 |
Alternatives Considered and Rejected
Alternative 1: Natural Keys as Primary Keys
Description: Use meaningful IDs as primary keys directly (no UUID PKs).
Rejected Because:
- Requires complex update cascade if ID changes (despite immutability goal)
- Distributed systems harder (need central ID generator)
- Larger index size (VARCHAR vs UUID)
- Foreign keys less efficient (string joins vs UUID joins)
Alternative 2: Composite Keys
Description: Use (tenant_id, sequential_id) composite keys.
Rejected Because:
- Over-engineering for single-tenant system
- More complex foreign key relationships
- No tenant concept in BATbern (single organization)
Alternative 3: Hashids/Short UUIDs
Description: Use shortened UUID variants (e.g., Base62 encoded).
Rejected Because:
- Still not human-meaningful (random characters)
- Does not match business requirements (event numbers, usernames)
- Adds encoding/decoding complexity
Future Considerations
API Versioning Strategy
If we need to support multiple ID formats in the future:
GET /api/v1/events/{eventCode} # Current
GET /api/v2/events/{eventCode} # Future (if breaking changes needed)
Meaningful ID Evolution
As the system grows:
- Event codes: Could evolve to include year (
BATbern2026-01) - Company names: Could support vanity URLs (
/companies/googleβ/companies/GoogleZH) - Usernames: Could add display names separate from login names
Performance Monitoring
Monitor these metrics:
- Lookup performance: UUID vs meaningful ID queries
- Index size: Storage overhead of dual identifiers
- Cache hit rates: Meaningful ID β Entity mappings
Related Documents
- Architecture:
docs/architecture/03-data-architecture.md(updated with meaningful ID columns) - API Design:
docs/architecture/04-api-design.md(URL patterns) - Stories:
docs/stories/1.16.1-meaningful-ids-public-urls.mddocs/stories/1.16.2-eliminate-uuids-from-api.md
- OpenAPI Specs:
docs/api/events-api.openapi.ymldocs/api/companies-api.openapi.yml
- E2E Tests:
bruno-tests/events-api/*bruno-tests/companies-api/*bruno-tests/file-upload-api/*
Revision History
| Date | Version | Changes | Author |
|---|---|---|---|
| 2025-11-02 | 1.0 | Initial ADR creation | Winston (Architect Agent) |
| 2025-11-08 | 1.1 | CRITICAL CLARIFICATION: Added comprehensive βMicroservice Isolation Rulesβ section with 5 rules and decision tree. Made it crystal clear that cross-service references MUST use meaningful IDs (companyName, username, eventCode), NOT UUIDs. Updated database layer description to distinguish within-service vs. cross-service references. | Claude Code |
This ADR documents a foundational architectural decision that affects all APIs, URLs, and external integrations in the BATbern platform. All microservices MUST follow the microservice isolation rules: use meaningful IDs for cross-service references, NEVER UUIDs.