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 { username: Username }
// Projections
projection UsernameClaimed(username: Username): boolean = false {
on AccountRegistered => set true
}
// Commands
command RegisterAccount(username: Username) {
read claimed = UsernameClaimed(username)
require claimed is false
emit AccountRegistered { username }
scenario "Register account with claimed username" {
given AccountRegistered { username: "u1" }
when RegisterAccount { username: "u1" }
then rejected by claimed is false saw true
}
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 |
|---|---|---|
claimed | 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.
model "Unique username"
// Types
tag type Username = string
// Events
event AccountRegistered { username: Username }
event AccountClosed { username: Username }
// Projections
projection UsernameClaimed(username: Username): boolean = false {
on AccountRegistered => set true
on AccountClosed => set false
}
// Commands
command RegisterAccount(username: Username) {
read claimed = UsernameClaimed(username)
require claimed is false
emit AccountRegistered { username }
scenario "Register account with claimed username" {
given AccountRegistered { username: "u1" }
when RegisterAccount { username: "u1" }
then rejected by claimed is false saw true
}
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" }
given AccountClosed { username: "u1" }
when RegisterAccount { username: "u1" }
then AccountRegistered { username: "u1" }
}
}
RegisterAccount reads 2 types, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
claimed | 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 UsernameChanged Event is tagged with the old and the new username, so it is part of the Query for both (see the "Consistency boundary" tab). A declarative handler cannot tell which of the two usernames it is folding, so the UsernameClaimed projection is scripted from here on: the change releases the old username and claims the new one.
model "Unique username"
// Types
tag type Username = string
// Events
event AccountRegistered { username: Username }
event AccountClosed { username: Username }
event UsernameChanged { oldUsername: Username, newUsername: Username }
// Projections
projection UsernameClaimed: boolean {
script(username: Username)
tagFilter ["Username:{username}"]
initialState false
on AccountRegistered => ```true```
on AccountClosed => ```false```
on UsernameChanged => ```event.data.newUsername === args.username```
}
// Commands
command RegisterAccount(username: Username) {
read claimed = UsernameClaimed(username)
require claimed is false
emit AccountRegistered { username }
scenario "Register account with claimed username" {
given AccountRegistered { username: "u1" }
when RegisterAccount { username: "u1" }
then rejected by claimed is false saw true
}
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" }
given 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" }
given UsernameChanged { oldUsername: "u1", newUsername: "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" }
given UsernameChanged { oldUsername: "u1", newUsername: "u1changed" }
when RegisterAccount { username: "u1changed" }
then rejected by claimed is false saw true
}
}
RegisterAccount reads 3 types, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
claimed | AccountClosed, AccountRegistered, UsernameChanged | Username:{username} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: Username:{username}
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, changedOn). 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.
model "Unique username"
// Types
tag type Username = string
type Day = integer
// Events
event AccountRegistered { username: Username }
event AccountClosed { username: Username, closedOn: Day }
event UsernameChanged { oldUsername: Username, newUsername: Username, changedOn: Day }
// Projections
projection UsernameClaimed: boolean {
script(username: Username, today: Day)
tagFilter ["Username:{username}"]
initialState false
on AccountRegistered => ```true```
on AccountClosed => ```args.today - event.data.closedOn <= 3```
on UsernameChanged => ```event.data.newUsername === args.username || args.today - event.data.changedOn <= 3```
}
// Commands
command RegisterAccount(username: Username, today: Day) {
read claimed = UsernameClaimed(username, today)
require claimed is false
emit AccountRegistered { username }
scenario "Register account with claimed username" {
given AccountRegistered { username: "u1" }
when RegisterAccount { username: "u1", today: 10 }
then rejected by claimed is false saw true
}
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" }
given 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" }
given UsernameChanged { oldUsername: "u1", newUsername: "u1changed", changedOn: 1 }
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" }
given UsernameChanged { oldUsername: "u1", newUsername: "u1changed", changedOn: 1 }
when RegisterAccount { username: "u1changed", today: 10 }
then rejected by claimed is false saw true
}
scenario "Register username of closed account before retention period" {
given AccountRegistered { username: "u1" }
given AccountClosed { username: "u1", closedOn: 7 }
when RegisterAccount { username: "u1", today: 10 }
then rejected by claimed is false saw true
}
scenario "Register changed username before retention period" {
given AccountRegistered { username: "u1" }
given UsernameChanged { oldUsername: "u1", newUsername: "u1changed", changedOn: 7 }
when RegisterAccount { username: "u1", today: 10 }
then rejected by claimed is false saw true
}
scenario "Register username of closed account after retention period" {
given AccountRegistered { username: "u1" }
given 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" }
given UsernameChanged { oldUsername: "u1", newUsername: "u1changed", changedOn: 6 }
when RegisterAccount { username: "u1", today: 10 }
then AccountRegistered { username: "u1" }
}
}
RegisterAccount reads 3 types, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
claimed | AccountClosed, AccountRegistered, UsernameChanged | Username:{username} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: Username:{username}
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.
