Designing an Enterprise User Directory (ICF-Style Problem)
The “Enterprise User Directory” (ICF Style)
Designing an Enterprise User Directory (ICF-Style Problem)
The “Enterprise User Directory” (ICF Style)
Focus: Object-Oriented Design, Data Structures, and System Trade-offs
The problem:
Design a DirectoryManager for a B2B SaaS system that manages users and groups.
You must support:
addUser(userId, email, metadata): Adds a new user.createGroup(groupId): Creates a security group.addUserToGroup(userId, groupId): Links a user to a group.getUsersInGroup(groupId, filterMetadata): Returns all users in a group who match a specific metadata key-value pair.
The Challenge: Implement deleteGroup(groupId). When a group is deleted, all users within it should have a last_group_deleted: true flag updated in their metadata, but only if they don't belong to any other groups.
Key Test: Can you handle the many-to-many relationship and the cleanup logic without O(N²) complexity?

Solution 1:
type Metadata = Record<string, string | boolean>;
interface User {
userId: string;
email: string;
metadata: Metadata;
groups: Set<string>;
}
class DirectoryManager {
private users: Map<string, User> = new Map();
private groups: Map<string, Set<string>> = new Map();
addUser(userId: string, email: string, metadata: Metadata): void {
if (this.users.has(userId)) {
return; // or throw new Error("User already exists");
}
this.users.set(userId, {
userId,
email,
metadata: { ...metadata }, // prevent external mutation
groups: new Set(),
});
}
createGroup(groupId: string): void {
if (!this.groups.has(groupId)) {
this.groups.set(groupId, new Set());
}
}
addUserToGroup(userId: string, groupId: string): void {
const user = this.users.get(userId);
const group = this.groups.get(groupId);
if (!user || !group) {
return; // or throw an error
}
user.groups.add(groupId);
group.add(userId);
}
getUsersInGroup(
groupId: string,
filterKey: string,
filterValue: string | boolean
): User[] {
const userIds = this.groups.get(groupId);
if (!userIds) {
return [];
}
const result: User[] = [];
for (const userId of userIds) {
const user = this.users.get(userId);
if (!user) {
continue;
}
if (user.metadata[filterKey] === filterValue) {
result.push(user);
}
}
return result;
}
deleteGroup(groupId: string): void {
const userIds = this.groups.get(groupId);
if (!userIds) {
return;
}
for (const userId of userIds) {
const user = this.users.get(userId);
if (!user) {
continue;
}
user.groups.delete(groupId);
if (user.groups.size === 0) {
user.metadata["last_group_deleted"] = true;
}
}
this.groups.delete(groupId);
}
}
Solution explanation:
- The “dual-map” pattern have two primary maps (users and groups). Maintaining these maps for groups and users both, makes the actions of O(K) time complexity where k = number of users in a specific group.
- Storing
userIdin two places → is a “normalization” startegy to optimize “read performance” at the cost of slight memory overhead. - Edge cases includes handling of non-existent entities, for example, what if
userIddoesn’t exist while we try to add a group to the user. Also, a case where a group exists but has no users.
How would you modify this system if you needed deleteGroup to be O(1)? (Hint: Think about "Lazy Deletion" or "Version Tracking").
Deleting a group with 1 million users shouldn’t block the system…
- Lazy deletion aka Tombstone strategy → O(1) strategy
- This means, instead of looping through users, we record the “event of deletion” and only reconcile the user’s state when that user is actually accessed.
- So, we maintain a Set of deleted groupIds (tombstones), and the function
deleteGroupsimply adds the Id to this set. - Lazy reconciliation means → the
last_group_deletedlogic is moved to an accessor method or computed on the fly during read action. Thus, making the entire solution O(1).
Updated solution:
type Metadata = Record<string, string | boolean>;
interface User {
userId: string;
email: string;
metadata: Metadata;
groups: Set<string>;
}
class DirectoryManager {
private users: Map<string, User> = new Map();
private groups: Map<string, Set<string>> = new Map();
private activeGroups: Set<string> = new Set();
private deletedGroups: Set<string> = new Set();
addUser(userId: string, email: string, metadata: Metadata): void {
if (this.users.has(userId)) {
return; // or throw new Error("User already exists");
}
this.users.set(userId, {
userId,
email,
metadata: { ...metadata },
groups: new Set(),
});
}
createGroup(groupId: string): void {
if (!this.groups.has(groupId)) {
this.groups.set(groupId, new Set());
}
this.activeGroups.add(groupId);
this.deletedGroups.delete(groupId);
}
addUserToGroup(userId: string, groupId: string): void {
const user = this.users.get(userId);
if (!user || !this.activeGroups.has(groupId)) {
return;
}
let groupMembers = this.groups.get(groupId);
if (!groupMembers) {
groupMembers = new Set<string>();
this.groups.set(groupId, groupMembers);
}
user.groups.add(groupId);
groupMembers.add(userId);
}
deleteGroup(groupId: string): void {
if (!this.activeGroups.has(groupId)) {
return;
}
this.activeGroups.delete(groupId);
this.deletedGroups.add(groupId);
}
getUser(userId: string): User | null {
const user = this.users.get(userId);
if (!user) {
return null;
}
this.reconcileUserGroups(user);
return user;
}
getUsersInGroup(
groupId: string,
filterKey: string,
filterValue: string | boolean
): User[] {
if (!this.activeGroups.has(groupId)) {
return [];
}
const userIds = this.groups.get(groupId);
if (!userIds) {
return [];
}
const result: User[] = [];
for (const userId of userIds) {
const user = this.users.get(userId);
if (!user) {
continue;
}
this.reconcileUserGroups(user);
if (
user.groups.has(groupId) &&
user.metadata[filterKey] === filterValue
) {
result.push(user);
}
}
return result;
}
private reconcileUserGroups(user: User): void {
let hadDeletedGroup = false;
for (const groupId of Array.from(user.groups)) {
if (this.deletedGroups.has(groupId)) {
user.groups.delete(groupId);
const groupMembers = this.groups.get(groupId);
groupMembers?.delete(user.userId);
hadDeletedGroup = true;
}
}
if (hadDeletedGroup && user.groups.size === 0) {
user.metadata["last_group_deleted"] = true;
}
}
}
- Read-heavy v/s write-heavy decisions → In authentication/identity systems, deleting a group is a high-impact write. Therefore, by deferring the work, we prevent “stop the world” pauses where the DB locks up while updating 1-million rows.
- Eventual consistency → This pattern acknowledges that “user metadata” doesn’t need to be updated until someone actually looks at the user. If a user never logs in again, we never waster CPU cycles updating their records.
- Space complexity → The trade-off is that the
deletedGroupsset grows over time. In a real Auth0 environment, we would need a “background cleanup” job that periodically prunes tombstones once they are “old enough” (like after 30 days).
Final Takeaway
This problem isn’t just about code, it evaluates:
- Modeling many-to-many relationships
- Time complexity awareness
- Trade-off thinking (consistency vs performance)
- System design maturity (lazy vs eager strategies)
The best solution isn’t always the most immediate one, it’s the one that scales.
메타데이터
- post_id
- fbbc0d2c4392
- slug
- designing-an-enterprise-user-directory-icf-style-problem-fbbc0d2c4392
- url
- https://levelup.gitconnected.com/designing-an-enterprise-user-directory-icf-style-problem-fbbc0d2c4392
- canonical_url
- https://levelup.gitconnected.com/designing-an-enterprise-user-directory-icf-style-problem-fbbc0d2c4392
- author_url
- https://medium.com/@mansimanhas
- status
- ok
- fetched_at
- 2026-07-11 11:50:14