DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Andrzej Krzywda
Andrzej Krzywda

Posted on

Authentication events and commands

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

We are hiring! Do you want to be our Senior Platform Engineer? We're hiring for a Senior Platform Engineer and would love for you to apply.

Head here to learn more about who we're looking for.