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 closed (see disclaimer!)
- 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):
Feature 1: Globally unique username
This example is the most simple one just checking whether a given username is claimed
model "Unique username"
// Types
tag type Username = string
// Events
event AccountRegistered { tag username: Username }
// Projections
projection UsernameTaken (tag username: Username): boolean = false {
on AccountRegistered => set true
}
// Commands
command RegisterAccount(username: Username) {
alias taken = UsernameTaken(username)
require taken is false
else reject "Username is already taken"
emit AccountRegistered { username }
2 scenarios
scenarios {
scenario "Register account with claimed username" {
given AccountRegistered { username: "u1" }
when RegisterAccount { username: "u1" }
then rejected "Username is already taken"
}
scenario "Register account with unused username" {
when RegisterAccount { username: "u1" }
then AccountRegistered { username: "u1" }
}
}}
RegisterAccount reads 1 type, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
taken | AccountRegistered | Username:{username} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: Username:{username}
Note
To keep the example simple, we use the username directly as value for the Tag (e.g. Username:u1). In a real implementation, you probably would want to hash the value. And, more importantly, normalize it such that the usernames jamesbond and JamesBond are considered equal
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 closed
Disclaimer
It's most probably not a good idea to allow new users to take over the username of a closed account! Part 4 introduces a potential remedy, on its own this is merely an oversimplified example.
5 unchanged lines
model "Unique username"
// Types
tag type Username = string
// Events
event AccountRegistered { tag username: Username }
event AccountClosed { tag username: Username }
// Projections
projection UsernameTaken (tag username: Username): boolean = false {
on AccountRegistered => set true
on AccountClosed => set false
}
7 unchanged lines
// Commands
command RegisterAccount(username: Username) {
alias taken = UsernameTaken(username)
require taken is false
else reject "Username is already taken"
emit AccountRegistered { username }
3 scenarios
scenarios {
scenario "Register account with claimed username" {
given AccountRegistered { username: "u1" }
when RegisterAccount { username: "u1" }
then rejected "Username is already taken"
}
scenario "Register account with unused username" {
when RegisterAccount { username: "u1" }
then AccountRegistered { username: "u1" }
}
scenario "Register account with username of closed account" {
given AccountRegistered { username: "u1" }
and AccountClosed { username: "u1" }
when RegisterAccount { username: "u1" }
then AccountRegistered { username: "u1" }
}
}}
RegisterAccount reads 2 types, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
taken | AccountClosed, AccountRegistered | Username:{username} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: Username:{username}
Feature 3: Allow changing of usernames
This example extends the previous one to show how the username of an active account could be changed.
The ChangeUsername command releases the old username and claims the new one, so it records two Events: UsernameReleased and UsernameClaimed. Each of them is about a single username, which keeps the UsernameTaken projection simple. The command reads that projection for both usernames, so a single decision covers both of them (see the "Consistency boundary" tab): if either username is claimed or released in the meantime, the change fails.
6 unchanged lines
model "Unique username"
// Types
tag type Username = string
// Eventsevent AccountRegistered { tag username: Username }
event AccountClosed { tag username: Username }
event UsernameReleased { tag username: Username }
event UsernameClaimed { tag username: Username }
// Projections
projection UsernameTaken (tag username: Username): boolean = false {
on AccountRegistered => set true
on AccountClosed => set false
on UsernameReleased => set false
on UsernameClaimed => set true
}
7 unchanged lines
// Commands
command RegisterAccount(username: Username) {
alias taken = UsernameTaken(username)
require taken is false
else reject "Username is already taken"
emit AccountRegistered { username }
5 scenarios
scenarios {
scenario "Register account with claimed username" {
given AccountRegistered { username: "u1" }
when RegisterAccount { username: "u1" }
then rejected "Username is already taken"
}
scenario "Register account with unused username" {
when RegisterAccount { username: "u1" }
then AccountRegistered { username: "u1" }
}
scenario "Register account with username of closed account" {
given AccountRegistered { username: "u1" }
and AccountClosed { username: "u1" }
when RegisterAccount { username: "u1" }
then AccountRegistered { username: "u1" }
}
scenario "Register account with a username that was previously used and then changed" {
given AccountRegistered { username: "u1" }
and UsernameReleased { username: "u1" }
and UsernameClaimed { username: "u1changed" }
when RegisterAccount { username: "u1" }
then AccountRegistered { username: "u1" }
}
scenario "Register account with a username that another username was changed to" {
given AccountRegistered { username: "u1" }
and UsernameReleased { username: "u1" }
and UsernameClaimed { username: "u1changed" }
when RegisterAccount { username: "u1changed" }
then rejected "Username is already taken"
}
}}
command ChangeUsername(username: Username, newUsername: Username) {
require UsernameTaken(username) is true
else reject "Username is not in use"
require UsernameTaken(newUsername) is false
else reject "Username is already taken"
emit UsernameReleased { username }
emit UsernameClaimed { username: newUsername }
3 scenarios
scenarios {
scenario "Change username" {
given AccountRegistered { username: "u1" }
when ChangeUsername { username: "u1", newUsername: "u1changed" }
then UsernameReleased { username: "u1" }
and UsernameClaimed { username: "u1changed" }
}
scenario "Change username to a claimed username" {
given AccountRegistered { username: "u1" }
and AccountRegistered { username: "u2" }
when ChangeUsername { username: "u1", newUsername: "u2" }
then rejected "Username is already taken"
}
scenario "Change a username that is not in use" {
when ChangeUsername { username: "u1", newUsername: "u1changed" }
then rejected "Username is not in use"
}
}}
RegisterAccount reads 4 types, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
taken | AccountClosed, AccountRegistered, UsernameClaimed, UsernameReleased | Username:{username} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: Username:{username}
ChangeUsername reads 4 types, 2 tags
| Query Item | Event Types | Tags |
|---|---|---|
UsernameTaken(username) | AccountClosed, AccountRegistered, UsernameClaimed, UsernameReleased | Username:{username} |
UsernameTaken(newUsername) | AccountClosed, AccountRegistered, UsernameClaimed, UsernameReleased | Username:{newUsername} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: Username:{username}, Username:{newUsername}
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 a username can be reserved for a configurable amount of time before it is released.
Note
The decision depends on the current date, so it is passed in with the command (today), and the Events record when they happened in their payload (closedOn, releasedOn). That keeps the decision model deterministic: it compares the two to determine the Event's age. Representing dates as day numbers is a simplification, typically this would be a timestamp.
The UsernameReleasedOn projection keeps the day a username was last released, and the derived projection UsernameRetainedUntil adds the retention period of three days to it. Both commands only accept a username once that day has passed.
model "Unique username"
// Types
tag type Username = string
type Day = integer
// Events
event AccountRegistered { tag username: Username }
event AccountClosed { tag username: Username, closedOn: Day }
event UsernameReleased { tag username: Username, releasedOn: Day }
event UsernameClaimed { tag username: Username }
7 unchanged lines
// Projections
projection UsernameTaken (tag username: Username): boolean = false {
on AccountRegistered => set true
on AccountClosed => set false
on UsernameReleased => set false
on UsernameClaimed => set true
}
projection UsernameReleasedOn (tag username: Username): Day = -1000 {
on AccountClosed => set event.data.closedOn
on UsernameReleased => set event.data.releasedOn
}
derived projection UsernameRetainedUntil (tag username: Username): Day {
UsernameReleasedOn(username) + 3
}
// Commands
command RegisterAccount(username: Username, today: Day) {
require UsernameTaken(username) is false
else reject "Username is already taken"
require UsernameRetainedUntil(username) < today
else reject "Username is already taken"
emit AccountRegistered { username }
9 scenarios
scenarios {
scenario "Register account with claimed username" {
given AccountRegistered { username: "u1" }
when RegisterAccount { username: "u1", today: 10 }
then rejected "Username is already taken"
}
scenario "Register account with unused username" {
when RegisterAccount { username: "u1", today: 10 }
then AccountRegistered { username: "u1" }
}
scenario "Register account with username of closed account" {
given AccountRegistered { username: "u1" }
and AccountClosed { username: "u1", closedOn: 1 }
when RegisterAccount { username: "u1", today: 10 }
then AccountRegistered { username: "u1" }
}
scenario "Register account with a username that was previously used and then changed" {
given AccountRegistered { username: "u1" }
and UsernameReleased { username: "u1", releasedOn: 1 }
and UsernameClaimed { username: "u1changed" }
when RegisterAccount { username: "u1", today: 10 }
then AccountRegistered { username: "u1" }
}
scenario "Register account with a username that another username was changed to" {
given AccountRegistered { username: "u1" }
and UsernameReleased { username: "u1", releasedOn: 1 }
and UsernameClaimed { username: "u1changed" }
when RegisterAccount { username: "u1changed", today: 10 }
then rejected "Username is already taken"
}
scenario "Register username of closed account before retention period" {
given AccountRegistered { username: "u1" }
and AccountClosed { username: "u1", closedOn: 7 }
when RegisterAccount { username: "u1", today: 10 }
then rejected "Username is already taken"
}
scenario "Register changed username before retention period" {
given AccountRegistered { username: "u1" }
and UsernameReleased { username: "u1", releasedOn: 7 }
and UsernameClaimed { username: "u1changed" }
when RegisterAccount { username: "u1", today: 10 }
then rejected "Username is already taken"
}
scenario "Register username of closed account after retention period" {
given AccountRegistered { username: "u1" }
and AccountClosed { username: "u1", closedOn: 6 }
when RegisterAccount { username: "u1", today: 10 }
then AccountRegistered { username: "u1" }
}
scenario "Register changed username after retention period" {
given AccountRegistered { username: "u1" }
and UsernameReleased { username: "u1", releasedOn: 6 }
and UsernameClaimed { username: "u1changed" }
when RegisterAccount { username: "u1", today: 10 }
then AccountRegistered { username: "u1" }
}
}}
command ChangeUsername(username: Username, newUsername: Username, today: Day) {
require UsernameTaken(username) is true
else reject "Username is not in use"
require UsernameTaken(newUsername) is false
else reject "Username is already taken"
require UsernameRetainedUntil(newUsername) < today
else reject "Username is already taken"
emit UsernameReleased { username, releasedOn: today }
emit UsernameClaimed { username: newUsername }
4 scenarios
scenarios {
scenario "Change username" {
given AccountRegistered { username: "u1" }
when ChangeUsername { username: "u1", newUsername: "u1changed", today: 10 }
then UsernameReleased { username: "u1", releasedOn: 10 }
and UsernameClaimed { username: "u1changed" }
}
scenario "Change username to a claimed username" {
given AccountRegistered { username: "u1" }
and AccountRegistered { username: "u2" }
when ChangeUsername { username: "u1", newUsername: "u2", today: 10 }
then rejected "Username is already taken"
}
scenario "Change a username that is not in use" {
when ChangeUsername { username: "u1", newUsername: "u1changed", today: 10 }
then rejected "Username is not in use"
}
scenario "Change username to one of a closed account before retention period" {
given AccountRegistered { username: "u1" }
and AccountRegistered { username: "u2" }
and AccountClosed { username: "u2", closedOn: 7 }
when ChangeUsername { username: "u1", newUsername: "u2", today: 10 }
then rejected "Username is already taken"
}
}}
RegisterAccount reads 4 types, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
UsernameTaken(username) | AccountClosed, AccountRegistered, UsernameClaimed, UsernameReleased | Username:{username} |
UsernameRetainedUntil(username) | AccountClosed, UsernameReleased | Username:{username} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: Username:{username}
ChangeUsername reads 4 types, 2 tags
| Query Item | Event Types | Tags |
|---|---|---|
UsernameTaken(username) | AccountClosed, AccountRegistered, UsernameClaimed, UsernameReleased | Username:{username} |
UsernameTaken(newUsername) | AccountClosed, AccountRegistered, UsernameClaimed, UsernameReleased | Username:{newUsername} |
UsernameRetainedUntil(newUsername) | AccountClosed, UsernameReleased | Username:{newUsername} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: Username:{username}, Username:{newUsername}
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.
