Skip to content

Unique username example

Enforcing globally unique values is simple with strong consistency (thanks to tools like unique constraint indexes), but it becomes significantly more challenging with eventual consistency.

Challenge

The goal is an application that allows users to subscribe with a username that uniquely identifies them.

As a bonus, this example is extended by adding the following features:

  • Allow usernames to be re-claimed when the account was suspended
  • Allow users to change their username
  • Only release unused usernames after a configurable delay

Traditional approaches

There are a couple of common strategies to achieve global uniqueness in event-driven systems:

  • Eventual consistency: Use a Read Model to check for uniqueness and handle a duplication due to race conditions after the fact (e.g. by deactivating the account or changing the username)

    This is of course a potential solution, with or without DCB, but it falls outside the scope of these examples

  • Dedicated storage: Create a dedicated storage for allocated usernames and make the write side insert a record when the corresponding Event is recorded

    This adds a source of error and potentially locked usernames unless Event and storage update can be done in a single transaction

  • Reservation Pattern: Use the Reservation Pattern to lock a username and only continue if the locking succeeded

    This works but adds quite a lot of complexity and additional Events and the need for Sagas or multiple writes in a single request

DCB approach

With DCB all Events that affect the unique constraint (the username in this example) can be tagged with the corresponding value (or a hash of it):

unique username example

Feature 1: Globally unique username

This example is the most simple one just checking whether a given username is claimed

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function AccountRegistered({ username }) {
  return {
    type: "AccountRegistered",
    data: { username },
    tags: [`username:${username}`],
  }
}

// projections for decision models:

function IsUsernameClaimedProjection(username) {
  return createProjection({
    initialState: false,
    handlers: {
      AccountRegistered: (state, event) => true,
    },
    tagFilter: [`username:${username}`],
  })
}

// command handlers:

class Api {
  eventStore
  constructor(eventStore) {
    this.eventStore = eventStore
  }

  registerAccount(command) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      isUsernameClaimed: IsUsernameClaimedProjection(command.username),
    })
    if (state.isUsernameClaimed) {
      throw new Error(`Username "${command.username}" is claimed`)
    }
    this.eventStore.append(
      AccountRegistered({
        username: command.username,
      }),
      appendCondition
    )
  }
}

// test cases:

const eventStore = new InMemoryDcbEventStore()
const api = new Api(eventStore)
runTests(api, eventStore, [
  {
    description: "Register account with claimed username",
    given: {
      events: [
        AccountRegistered({"username":"u1"}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedError: "Username \"u1\" is claimed",
    }
  }, 
  {
    description: "Register account with unused username",
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedEvent: AccountRegistered({"username":"u1"}),
    }
  }, 
])

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function AccountRegistered({
  username,
} : {
  username: string,
}) {
  return {
    type: "AccountRegistered" as const,
    data: { username },
    tags: [`username:${username}`],
  }
}

type EventTypes = ReturnType<typeof AccountRegistered>

// projections for decision models:

function IsUsernameClaimedProjection(username: string) {
  return createProjection<EventTypes, boolean>({
    initialState: false,
    handlers: {
      AccountRegistered: (state, event) => true,
    },
    tagFilter: [`username:${username}`],
  })
}

// command handlers:

class Api {
  constructor(private eventStore: EventStore) {}

  registerAccount(command: { username: string }) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      isUsernameClaimed: IsUsernameClaimedProjection(command.username),
    })
    if (state.isUsernameClaimed) {
      throw new Error(`Username "${command.username}" is claimed`)
    }
    this.eventStore.append(
      AccountRegistered({
        username: command.username,
      }),
      appendCondition
    )
  }
}
Experimental: 3rd party library

These Given/When/Then scenarios are visualized using an unofficial, work-in-progress, library

Feature 2: Release usernames

This example extends the previous one to show how a previously claimed username could be released when the corresponding account is suspended

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function AccountRegistered({ username }) {
  return {
    type: "AccountRegistered",
    data: { username },
    tags: [`username:${username}`],
  }
}

function AccountSuspended({ username }) {
  return {
    type: "AccountSuspended",
    data: { username },
    tags: [`username:${username}`],
  }
}

// projections for decision models:

function IsUsernameClaimedProjection(username) {
  return createProjection({
    initialState: false,
    handlers: {
      AccountRegistered: (state, event) => true,
      AccountSuspended: (state, event) => false,
    },
    tagFilter: [`username:${username}`],
  })
}

// command handlers:

class Api {
  eventStore
  constructor(eventStore) {
    this.eventStore = eventStore
  }

  registerAccount(command) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      isUsernameClaimed: IsUsernameClaimedProjection(command.username),
    })
    if (state.isUsernameClaimed) {
      throw new Error(`Username "${command.username}" is claimed`)
    }
    this.eventStore.append(
      AccountRegistered({
        username: command.username,
      }),
      appendCondition
    )
  }
}

// test cases:

const eventStore = new InMemoryDcbEventStore()
const api = new Api(eventStore)
runTests(api, eventStore, [
  {
    description: "Register account with claimed username",
    given: {
      events: [
        AccountRegistered({"username":"u1"}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedError: "Username \"u1\" is claimed",
    }
  }, 
  {
    description: "Register account with unused username",
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedEvent: AccountRegistered({"username":"u1"}),
    }
  }, 
  {
    description: "Register account with username of suspended account",
    given: {
      events: [
        AccountRegistered({"username":"u1"}),
        AccountSuspended({"username":"u1"}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedEvent: AccountRegistered({"username":"u1"}),
    }
  }, 
])

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function AccountRegistered({
  username,
} : {
  username: string,
}) {
  return {
    type: "AccountRegistered" as const,
    data: { username },
    tags: [`username:${username}`],
  }
}

function AccountSuspended({
  username,
} : {
  username: string,
}) {
  return {
    type: "AccountSuspended" as const,
    data: { username },
    tags: [`username:${username}`],
  }
}

type EventTypes = ReturnType<
  | typeof AccountRegistered,
  | typeof AccountSuspended,
>

// projections for decision models:

function IsUsernameClaimedProjection(username: string) {
  return createProjection<EventTypes, boolean>({
    initialState: false,
    handlers: {
      AccountRegistered: (state, event) => true,
      AccountSuspended: (state, event) => false,
    },
    tagFilter: [`username:${username}`],
  })
}

// command handlers:

class Api {
  constructor(private eventStore: EventStore) {}

  registerAccount(command: { username: string }) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      isUsernameClaimed: IsUsernameClaimedProjection(command.username),
    })
    if (state.isUsernameClaimed) {
      throw new Error(`Username "${command.username}" is claimed`)
    }
    this.eventStore.append(
      AccountRegistered({
        username: command.username,
      }),
      appendCondition
    )
  }
}
Experimental: 3rd party library

These Given/When/Then scenarios are visualized using an unofficial, work-in-progress, library

Feature 3: Allow changing of usernames

This example extends the previous one to show how the username of an active account could be changed

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function AccountRegistered({ username }) {
  return {
    type: "AccountRegistered",
    data: { username },
    tags: [`username:${username}`],
  }
}

function AccountSuspended({ username }) {
  return {
    type: "AccountSuspended",
    data: { username },
    tags: [`username:${username}`],
  }
}

function UsernameChanged({ oldUsername, newUsername }) {
  return {
    type: "UsernameChanged",
    data: { oldUsername, newUsername },
    tags: [`username:${oldUsername}`, `username:${newUsername}`],
  }
}

// projections for decision models:

function IsUsernameClaimedProjection(username) {
  return createProjection({
    initialState: false,
    handlers: {
      AccountRegistered: (state, event) => true,
      AccountSuspended: (state, event) => false,
      UsernameChanged: (state, event) => event.data.newUsername === username,
    },
    tagFilter: [`username:${username}`],
  })
}

// command handlers:

class Api {
  eventStore
  constructor(eventStore) {
    this.eventStore = eventStore
  }

  registerAccount(command) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      isUsernameClaimed: IsUsernameClaimedProjection(command.username),
    })
    if (state.isUsernameClaimed) {
      throw new Error(`Username "${command.username}" is claimed`)
    }
    this.eventStore.append(
      AccountRegistered({
        username: command.username,
      }),
      appendCondition
    )
  }
}

// test cases:

const eventStore = new InMemoryDcbEventStore()
const api = new Api(eventStore)
runTests(api, eventStore, [
  {
    description: "Register account with claimed username",
    given: {
      events: [
        AccountRegistered({"username":"u1"}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedError: "Username \"u1\" is claimed",
    }
  }, 
  {
    description: "Register account with unused username",
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedEvent: AccountRegistered({"username":"u1"}),
    }
  }, 
  {
    description: "Register account with username of suspended account",
    given: {
      events: [
        AccountRegistered({"username":"u1"}),
        AccountSuspended({"username":"u1"}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedEvent: AccountRegistered({"username":"u1"}),
    }
  }, 
  {
    description: "Register account with a username that was previously used and then changed",
    given: {
      events: [
        AccountRegistered({"username":"u1"}),
        UsernameChanged({"oldUsername":"u1","newUsername":"u1changed"}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedEvent: AccountRegistered({"username":"u1"}),
    }
  }, 
  {
    description: "Register account with a username that another username was changed to",
    given: {
      events: [
        AccountRegistered({"username":"u1"}),
        UsernameChanged({"oldUsername":"u1","newUsername":"u1changed"}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1changed"},
      }
    },
    then: {
      expectedError: "Username \"u1changed\" is claimed",
    }
  }, 
])

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function AccountRegistered({
  username,
} : {
  username: string,
}) {
  return {
    type: "AccountRegistered" as const,
    data: { username },
    tags: [`username:${username}`],
  }
}

function AccountSuspended({
  username,
} : {
  username: string,
}) {
  return {
    type: "AccountSuspended" as const,
    data: { username },
    tags: [`username:${username}`],
  }
}

function UsernameChanged({
  oldUsername,
  newUsername,
} : {
  oldUsername: string,
  newUsername: string,
}) {
  return {
    type: "UsernameChanged" as const,
    data: { oldUsername, newUsername },
    tags: [`username:${oldUsername}`, `username:${newUsername}`],
  }
}

type EventTypes = ReturnType<
  | typeof AccountRegistered,
  | typeof AccountSuspended,
  | typeof UsernameChanged,
>

// projections for decision models:

function IsUsernameClaimedProjection(username: string) {
  return createProjection<EventTypes, boolean>({
    initialState: false,
    handlers: {
      AccountRegistered: (state, event) => true,
      AccountSuspended: (state, event) => false,
      UsernameChanged: (state, event) => event.data.newUsername === username,
    },
    tagFilter: [`username:${username}`],
  })
}

// command handlers:

class Api {
  constructor(private eventStore: EventStore) {}

  registerAccount(command: { username: string }) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      isUsernameClaimed: IsUsernameClaimedProjection(command.username),
    })
    if (state.isUsernameClaimed) {
      throw new Error(`Username "${command.username}" is claimed`)
    }
    this.eventStore.append(
      AccountRegistered({
        username: command.username,
      }),
      appendCondition
    )
  }
}
Experimental: 3rd party library

These Given/When/Then scenarios are visualized using an unofficial, work-in-progress, library

Feature 4: Username retention

In the previous examples a username that is no longer claimed, can be used immediately again for new accounts. This example extends the previous one to show how the a username can be reserved for a configurable amount of time before it is released.

Note

The daysAgo property of the Event metadata is a simplification. Typically, a timestamp representing the Event's recording time is stored within the Event's payload or metadata. This timestamp can be compared to the current date to determine the Event's age in the decision model.

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function AccountRegistered({ username }) {
  return {
    type: "AccountRegistered",
    data: { username },
    tags: [`username:${username}`],
  }
}

function AccountSuspended({ username }) {
  return {
    type: "AccountSuspended",
    data: { username },
    tags: [`username:${username}`],
  }
}

function UsernameChanged({ oldUsername, newUsername }) {
  return {
    type: "UsernameChanged",
    data: { oldUsername, newUsername },
    tags: [`username:${oldUsername}`, `username:${newUsername}`],
  }
}

// projections for decision models:

function IsUsernameClaimedProjection(username) {
  return createProjection({
    initialState: false,
    handlers: {
      AccountRegistered: (state, event) => true,
      AccountSuspended: (state, event) => event.metadata?.daysAgo <= 3,
      UsernameChanged: (state, event) => event.data.newUsername === username || event.metadata?.daysAgo <= 3,
    },
    tagFilter: [`username:${username}`],
  })
}

// command handlers:

class Api {
  eventStore
  constructor(eventStore) {
    this.eventStore = eventStore
  }

  registerAccount(command) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      isUsernameClaimed: IsUsernameClaimedProjection(command.username),
    })
    if (state.isUsernameClaimed) {
      throw new Error(`Username "${command.username}" is claimed`)
    }
    this.eventStore.append(
      AccountRegistered({
        username: command.username,
      }),
      appendCondition
    )
  }
}

// test cases:

const eventStore = new InMemoryDcbEventStore()
const api = new Api(eventStore)
runTests(api, eventStore, [
  {
    description: "Register account with claimed username",
    given: {
      events: [
        AccountRegistered({"username":"u1"}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedError: "Username \"u1\" is claimed",
    }
  }, 
  {
    description: "Register account with unused username",
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedEvent: AccountRegistered({"username":"u1"}),
    }
  }, 
  {
    description: "Register account with username of suspended account",
    given: {
      events: [
        AccountRegistered({"username":"u1"}),
        AccountSuspended({"username":"u1"}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedEvent: AccountRegistered({"username":"u1"}),
    }
  }, 
  {
    description: "Register account with a username that was previously used and then changed",
    given: {
      events: [
        AccountRegistered({"username":"u1"}),
        UsernameChanged({"oldUsername":"u1","newUsername":"u1changed"}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedEvent: AccountRegistered({"username":"u1"}),
    }
  }, 
  {
    description: "Register account with a username that another username was changed to",
    given: {
      events: [
        AccountRegistered({"username":"u1"}),
        UsernameChanged({"oldUsername":"u1","newUsername":"u1changed"}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1changed"},
      }
    },
    then: {
      expectedError: "Username \"u1changed\" is claimed",
    }
  }, 
  {
    description: "Register username of suspended account before retention period",
    given: {
      events: [
        addEventMetadata(AccountRegistered({"username":"u1"}), {"daysAgo":4}),
        addEventMetadata(AccountSuspended({"username":"u1"}), {"daysAgo":3}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedError: "Username \"u1\" is claimed",
    }
  }, 
  {
    description: "Register changed username before retention period",
    given: {
      events: [
        addEventMetadata(AccountRegistered({"username":"u1"}), {"daysAgo":4}),
        addEventMetadata(UsernameChanged({"oldUsername":"u1","newUsername":"u1changed"}), {"daysAgo":3}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedError: "Username \"u1\" is claimed",
    }
  }, 
  {
    description: "Register username of suspended account after retention period",
    given: {
      events: [
        addEventMetadata(AccountRegistered({"username":"u1"}), {"daysAgo":4}),
        addEventMetadata(AccountSuspended({"username":"u1"}), {"daysAgo":4}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedEvent: AccountRegistered({"username":"u1"}),
    }
  }, 
  {
    description: "Register changed username after retention period",
    given: {
      events: [
        addEventMetadata(AccountRegistered({"username":"u1"}), {"daysAgo":4}),
        addEventMetadata(UsernameChanged({"oldUsername":"u1","newUsername":"u1changed"}), {"daysAgo":4}),
      ],
    },
    when: {
      command: {
        type: "registerAccount",
        data: {"username":"u1"},
      }
    },
    then: {
      expectedEvent: AccountRegistered({"username":"u1"}),
    }
  }, 
])

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function AccountRegistered({
  username,
} : {
  username: string,
}) {
  return {
    type: "AccountRegistered" as const,
    data: { username },
    tags: [`username:${username}`],
  }
}

function AccountSuspended({
  username,
} : {
  username: string,
}) {
  return {
    type: "AccountSuspended" as const,
    data: { username },
    tags: [`username:${username}`],
  }
}

function UsernameChanged({
  oldUsername,
  newUsername,
} : {
  oldUsername: string,
  newUsername: string,
}) {
  return {
    type: "UsernameChanged" as const,
    data: { oldUsername, newUsername },
    tags: [`username:${oldUsername}`, `username:${newUsername}`],
  }
}

type EventTypes = ReturnType<
  | typeof AccountRegistered,
  | typeof AccountSuspended,
  | typeof UsernameChanged,
>

// projections for decision models:

function IsUsernameClaimedProjection(username: string) {
  return createProjection<EventTypes, boolean>({
    initialState: false,
    handlers: {
      AccountRegistered: (state, event) => true,
      AccountSuspended: (state, event) => event.metadata?.daysAgo <= 3,
      UsernameChanged: (state, event) => event.data.newUsername === username || event.metadata?.daysAgo <= 3,
    },
    tagFilter: [`username:${username}`],
  })
}

// command handlers:

class Api {
  constructor(private eventStore: EventStore) {}

  registerAccount(command: { username: string }) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      isUsernameClaimed: IsUsernameClaimedProjection(command.username),
    })
    if (state.isUsernameClaimed) {
      throw new Error(`Username "${command.username}" is claimed`)
    }
    this.eventStore.append(
      AccountRegistered({
        username: command.username,
      }),
      appendCondition
    )
  }
}
Experimental: 3rd party library

These Given/When/Then scenarios are visualized using an unofficial, work-in-progress, library

Conclusion

This example demonstrates how to solve one of the Event Sourcing evergreens: Enforcing unique usernames. But it can be applied to any scenario that requires global uniqueness of some sort.