The last few days I didn't write too much new code on the Ecommerce project.
However, that's the beauty of open source! Regardless of me not being active, there were 2 new contributions, how lovely!
One of the new features which is being worked on is displaying a list of Shipments. At the moment of writing this, it's not yet merged.
I will focus on the Authentication feature.
This new code (a new bounded context) was implemented by Paweł Strzałkowski (thanks Paweł!) and this was his first contribution.
Overall the goal is to allow clients to properly log in.
Obviously, we could go with Devise and have it done in 10 minutes. However, we all know how it ends. It ends with a User class and its 100 attributes accumulated over time.
Given that the whole project is fully DDD, CQRS and Event Sourcing - it was part of the goal to have authentication implemented in this fashion.
The idea of Authentication as a separate Bounded Context first came to me from the "Implementing Domain Driven Design" book by Vaughn Vernon. It's commonly known as the red book.
Vaughn called it Identity & Access. We're going for Authentication
for now.
So far, we're implementing BCs as new directories and separate Ruby gems. They each have their own tests, their own Makefile, their own build system, their own mutation coverage.
OK, let's start with the input to the Authentication module - the commands:
At first, there's no account, so we need to register one:
module Authentication
class RegisterAccount < Infra::Command
attribute :account_id, Infra::Types::UUID
alias aggregate_id account_id
end
end
As you see, it doesn't really have username or password, it comes as separate commands:
module Authentication
class SetLogin < Infra::Command
attribute :account_id, Infra::Types::UUID
attribute :login, Infra::Types::String
alias aggregate_id account_id
end
end
module Authentication
class SetPasswordHash < Infra::Command
attribute :account_id, Infra::Types::UUID
attribute :password_hash, Infra::Types::String
alias aggregate_id account_id
end
end
In the end we can connect such an account to a Customer:
module Authentication
class ConnectAccountToClient < Infra::Command
attribute :account_id, Infra::Types::UUID
attribute :client_id, Infra::Types::UUID
alias aggregate_id account_id
end
end
Such a design is very flexible. We can have clients without an account. Also, we can have clients with multiple accounts.
We also have a similar set of events, also very granular, allowing for flexibility:
module Authentication
class AccountRegistered < Infra::Event
attribute :account_id, Infra::Types::UUID
end
class LoginSet < Infra::Event
attribute :account_id, Infra::Types::UUID
attribute :login, Infra::Types::String
end
class PasswordHashSet < Infra::Event
attribute :account_id, Infra::Types::UUID
attribute :password_hash, Infra::Types::String
end
class AccountConnectedToClient < Infra::Event
attribute :account_id, Infra::Types::UUID
attribute :client_id, Infra::Types::UUID
end
end
How does it happen that a command is issued and then the module issues an event?
We have a layer of command handlers:
module Authentication
class RegisterAccountHandler
def initialize(event_store)
@repository = Infra::AggregateRootRepository.new(event_store)
end
def call(command)
@repository.with_aggregate(Account, command.aggregate_id) do |account|
account.register
end
end
end
Here is the part where the infra part is injected - the event_store
. We need for retrieving the events, so that the object (here: Account) can be event sourced (loaded from events).
It all happens in the AggregateRootRepository.new
so we don't have to care about this.
The object that is loaded is called an aggregate.
We call a register
method on it.
Let's take a look at the whole aggregate class:
module Authentication
class Account
include AggregateRoot
AlreadyRegistered = Class.new(StandardError)
def initialize(id)
@id = id
end
def register
raise AlreadyRegistered if @registered
apply AccountRegistered.new(data: { account_id: @id })
end
def set_login(login)
apply LoginSet.new(data: { account_id: @id, login: login })
end
def set_password_hash(password_hash)
apply PasswordHashSet.new(data: { account_id: @id, password_hash: password_hash })
end
def connect_client(client_id)
apply AccountConnectedToClient.new(data: { account_id: @id, client_id: client_id })
end
on AccountRegistered do |event|
@registered = true
end
on LoginSet do |event|
@login = event.data[:login]
end
on PasswordHashSet do |event|
@password_hash = event.data[:password_hash]
end
on AccountConnectedToClient do |event|
@client_id = event.data[:client_id]
end
end
end
The pattern which is used here, is called AggregateRoot
- it mixed the business logic with the messaging (events) part. It's all simplified with our AggregateRoot
module which is injected.
As you can see, so far there's not much actual logic. The only rule is to not allow this Account to be registered more than once.
Keeping all this data in one aggregate is a one possible design. It's simple and very familiar to CRUD designs.
With the current requirements, it's good enough. Maybe in the future we can go with even smaller aggregates - once more fancy requirements come.
The domain level is always surrounded with application layer. In a way the app layer protects the domain layer. It knows how to use it.
Certain requirements can be kept at the app layer.
For example, looking at this code, we may ask:
- How are we protected against connecting the same account to multiple clients?
Let's keep in mind that the feature is still in progress and the requirements are created by us. It's a bit vague now.
At the moment, this code is only used in database seeds, from the app layer:
[
["BigCorp Ltd", "bigcorp", "12345"],
["MegaTron Gmbh", "megatron", "qwerty"],
["Arkency", 'arkency', 'qwe123']
].each do |name, login, password|
account_id = SecureRandom.uuid
customer_id = SecureRandom.uuid
password_hash = Digest::SHA256.hexdigest(password)
command_bus.call(
Crm::RegisterCustomer.new(customer_id: customer_id, name: name)
)
command_bus.call(
Authentication::RegisterAccount.new(account_id: account_id)
)
command_bus.call(
Authentication::SetLogin.new(account_id: account_id, login: login)
)
command_bus.call(
Authentication::SetPasswordHash.new(account_id: account_id, password_hash: password_hash)
)
command_bus.call(
Authentication::ConnectAccountToClient.new(account_id: account_id, client_id: customer_id)
)
end
There's no UI for that, so the accounts are not really used in any way.
Still, a nice step forward in this Authentication area. It opens the door to implementing the UI and the app layer (controllers, read models).
As you can see, apart from the Customer
reference (maybe we should rename it to User
to stick to the Ubiquitous Language of Authentication?) - it's mostly generic code. I hope that this BC together with read models which will be created can become a reusable library which could be a good alternative to Devise in those projects which want to go the DDD/CQRS path.
Top comments (0)