In several previous articles, I discussed various challenges in implementing the Domain-Driven Design (DDD) software development methodology in a complex project with a long history. In this article, I will show how the signing of documents with an electronic signature can be organized within the DDD paradigm.
I implemented DDD in a project that automated procurement processes in the B2B segment. Using this system, buying companies could conduct online procedures such as tenders and auctions, while supplier companies could participate by submitting applications and proposals. Upon completion of the procedure, buyers selected the most suitable offer and signed a contract with the winning supplier.
To confirm legally binding actions of trading participants, the system used an electronic signature (ES). It was applied, for example, for:
ES operates using asymmetric encryption, meaning it uses a pair of keys, a private key for signing and a public key for verification.
A simple analogy to understand the principle of asymmetric encryption is the Caesar cipher. In this method, letters of the original message are replaced with letters of the same alphabet but shifted by a fixed value. In this case, the initial shift can be considered the private key. However, applying the same key again does not decrypt the message - another shift is required, which can be calculated as the difference between the alphabet size and the original shift. This new shift can be considered the public key. Although the algorithm itself is not cryptographically secure and does not qualify as a full-fledged asymmetric encryption algorithm, it clearly illustrates the basic principles used in more complex schemes such as RSA or ECIES.
System users apply for a signature from a certification authority, where a pair of cryptographic keys - private and public - is generated for them. The private key is kept by the owner and not shared with anyone, while the public key is published for signature verification.
The document signing process works as follows:
To verify the signature, another user must:
Thus, ES ensures data immutability and replaces handwritten signatures and seals, simplifying and accelerating deal execution.
Now, let's look at how the code for working with an electronic signature can be organized within DDD.
I managed the trading procedures domain, which included only the business logic of conducting the trades themselves. The functionality for signature verification and storage was maintained by another team, while a third team was responsible for the client-side functionality of signing documents. Therefore, I needed to create certain abstractions within my domain.
Let’s start with basic Value Objects to describe key entities: the identifier of a stored signature (SignId
), the description of a document to be signed (SignDoc
), and the signature itself (Sign
). These objects are simple internally but formalize and explicitly describe the existing items in the system.
final class SignId
{
public function __construct(private value: int)
{}
public static function getNull(): SignId
{
return new self(0)
}
public function getValue(): int
{
return this->value
}
public function isNull(): bool
{
return ! this->getValue()
}
public function equals(signId: self): bool
{
return this->value == signId->value
}
}
final class SignDoc
{
public function __construct(private value: string)
{}
public static function getNull(): self
{
return new self('')
}
public function getValue(): string
{
return this->value
}
public function isNull(): bool
{
return ! this->getValue()
}
public function equals(signDoc: self): bool
{
return this->value == signDoc->value
}
}
final class Sign
{
public function __construct(private value: string)
{}
public static function getNull(): self
{
return new self('')
}
public function getValue(): string
{
return this->value
}
public function isNull(): bool
{
return ! this->value
}
public function equals(sign: self): bool
{
return this->value == sign->value
}
}
The next step is to add an interface and implementation for the signature entity.
interface ISignEntity extends IEntity
{
public function getId(): SignId
public function getSignDoc(): SignDoc
public function getSign(): Sign
public function getUserId(): UserId
public function getDateSigned(): int
}
final class SignEntity extends AbstractEntity implements ISignEntity
{
private id: SignId
private signDoc: SignDoc
private sign: Sign
private userId: UserId
private dateSigned: int
public function __construct(
signDoc: SignDoc,
sign: Sign,
userId: UserId,
) {
this->signDoc = signDoc
this->sign = sign
this->userId = userId
this->id = SignId::getNull()
}
public function getId(): SignId
{
return this->id
}
public function getSignDoc(): SignDoc
{
return this->signDoc
}
public function getSign(): Sign
{
return this->sign
}
public function getUserId(): UserId
{
return this->userId
}
public function getDateSigned(): int
{
return this->date_signed
}
}
As I mentioned earlier, another team handled signature validation and storage. This functionality does not directly relate to the business logic of trading procedures, so we need to abstract our domain from the code of other areas. To achieve this, we will implement only the repository interface to save and retrieve the signature entity at the domain level. The implementation of the interface will be placed in the infrastructure layer, where we will integrate our domain code with the electronic signature domain code.
interface ISignRepository extends IRepository
{
/**
* @throws ValidationException
*/
public function save(sign: ISignEntity): void;
public function findById(signId: SignId): ISignEntity
}
In this case, validation and storage of signatures were combined into a single repository method, save
, since the external team’s code performed these actions only together. The ISignEntity
entity contains both the client’s signature and the original document that needs to be signed, allowing the system to verify the signature before saving it. After the signature is successfully stored in a persistent repository, the entity is assigned an identifier, which can then be stored in the domain entity that was signed.
To obtain detailed information about a signature, the findById
repository method can be used. In this case, the repository always returns an entity, but if it is not found in the storage, a Null Object will be returned.
abstract class AbstractNullEntity implements IEntity
{
public function isNull(): bool
{
return true
}
...
...
}
final class NullSignEntity extends AbstractNullEntity implements ISignEntity
{
public function getId(): SignId
{
return SignId::getNull()
}
public function getSignDoc(): SignDoc
{
return SignDoc::getNull()
}
public function getSign(): Sign
{
return Sign::getNull()
}
public function getUserId(): UserId
{
return UserId::getNull()
}
public function getDateSigned(): int
{
return 0
}
}
Now, let’s move on to the application layer that is the internal API of our domain. Consider an example of signing a trading procedure entity to ensure data immutability. First, we implement a command to obtain a document for signing.
final class GetTradeSignDocCommand extends AbstractCommand implements ICommandWithSecurity
{
public function __construct(
private userId: int,
private tradeId: int,
) {}
public function getSecurityCommand(): ICommand
{
return new EditTradeSecCommand(this->userId, this->tradeId)
}
public function execute(): string
{
if (! this->getSecurityCommand()->execute()) {
throw new SecurityException()
}
user = this->userRepository->findById(UserId::get(this->userId))
trade = this->tradeRepository->findById(TradeId::get(this->tradeId))
return (new TradeSignDocService())->getSignDoc(user, trade)->getValue()
}
}
The user retrieves the document for signing, generates its hash, encrypts it with their private key, and sends the resulting signature to the system. We will implement a command that accepts this signature, validates it, stores it in the repository, and associates it with the trading procedure entity.
final class SignTradeCommand extends AbstractCommand implements ICommandWithSecurity
{
public function __construct(
private userId: int,
private tradeId: int,
private sign: string,
) {}
public function getSecurityCommand(): ICommand
{
return new SignTradeSecCommand(this->userId, this->tradeId)
}
public function execute(): void
{
if (! this->getSecurityCommand()->execute()) {
throw new SecurityException()
}
user = this->userRepository->findById(UserId::get(this->userId))
trade = this->tradeRepository->findById(TradeId::get(this->tradeId))
signDoc = (new TradeSignDocService())->getSignDoc(user, trade)
sign = new SignEntity(
signDoc,
new Sign(this->sign),
user->getId()
);
this->signRepository->save(sign)
trade->sign(user, sign->getId())
this->tradeRepository->save(trade)
}
}
The final command we need is to retrieve signature details for display in the UI.
final class GetTradeSignCommand extends AbstractCommand
{
public function __construct(
private userId: int,
private signId: int,
) {}
public function execute(): SignDTO
{
sign = this->signRepository->save(new SignId(this->signId))
if (sign->isNull()) {
throw new NotFoundException()
}
return this->signMapper->getSignDTO(sign)
}
}
Thus, the complex mechanism of an electronic signature can be easily integrated into an external domain that is not directly related to signature functionality but uses it. This ensures the minimal necessary logical context in the trading procedures domain without introducing unnecessary complexity, encapsulating most details in external code.