← Back to list

Designing an Enterprise User Directory (ICF-Style Problem)

The “Enterprise User Directory” (ICF Style)

Mansi Manhas in Level Up Coding · 2026-04-06 17:08 · 44 claps · 3.9 min read paywalled
#data-structure-algorithm #algorithms #system-design-interview #system-design-concepts #icf
Open on Medium ↗
Wiki topics: 💻 · Programming 🎬 · Film & Television 👗 · Fashion

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:

  1. addUser(userId, email, metadata): Adds a new user.
  2. createGroup(groupId): Creates a security group.
  3. addUserToGroup(userId, groupId): Links a user to a group.
  4. 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 userId doesn’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 deleteGroup simply adds the Id to this set.
  • Lazy reconciliation means → the last_group_deleted logic 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 deletedGroups set 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