Commands and Events
Interaction in hlf-core is built on a command model rather than on a set of contract functions — this
model comes from @ts-core/common and stays the same whether we are calling the blockchain or an ordinary
service. A command is packed into an envelope and travels through a single contract method, and from there
its fate is decided not by a function name but by the contents of the envelope.
The base command class is HlfTransportCommandAsync<U, V> from @hlf-core/common, and it has an
isReadonly flag that splits commands into those that read and those that change state. A command is a small class
with a static NAME and a pair of DTOs — request and response; the request is run through TransformUtil.toClass
so that it arrives already typed and validatable:
import { TransformUtil } from '@ts-core/common';
import { Type } from 'class-transformer';
import { ValidateNested, IsDefined, IsOptional, IsEnum } from 'class-validator';
import { HlfTransportCommandAsync, ICryptoKey, CryptoKey, IInitiatedDto, InitiatedDto } from '@hlf-core/common';
import { CommandName } from './Command';
import { User, UserRole } from '../user';
export class UserAddCommand extends HlfTransportCommandAsync<IUserAddDto, User> {
public static readonly NAME = CommandName.USER_ADD;
constructor(request: IUserAddDto) {
super(UserAddCommand.NAME, TransformUtil.toClass(UserAddDto, request));
}
protected checkResponse(item: User): User {
return TransformUtil.toClass(User, item);
}
}
export interface IUserAddDto extends IInitiatedDto {
roles?: Array<UserRole>;
cryptoKey: ICryptoKey;
}
export class UserAddDto extends InitiatedDto implements IUserAddDto {
@IsDefined()
@Type(() => CryptoKey)
@ValidateNested()
public cryptoKey: CryptoKey;
@IsOptional()
@IsEnum(UserRole, { each: true })
public roles?: Array<UserRole>;
}
The request itself travels inside TransportFabricRequestPayload<U> — an envelope with the fields id, name, request,
options, isReadonly and isNeedReply. The response comes back in TransportFabricResponsePayload<V>, and
here there is one important detail: the result and the error live in the same response field. Thanks to this,
a chaincode domain error reaches the client not as a faceless transaction failure but as an ExtendedError
with a code and details — that is, in exactly the form the handler threw it.
Chaincode events are sent via stub.dispatch(event) and reach the client when the block is parsed. An
event is as simple as a command — a class with a static NAME, and the handler sends it after it has changed
the state:
import { TransformUtil, TransportEvent } from '@ts-core/common';
import { IInitiatedDto, InitiatedDto } from '@hlf-core/common';
import { IsDefined, ValidateNested } from 'class-validator';
import { Event } from './Event';
import { User } from '../user';
export class UserAddedEvent extends TransportEvent<IUserAddedEventDto> {
public static readonly NAME = Event.USER_ADDED;
constructor(data: IUserAddedEventDto) {
super(UserAddedEvent.NAME, TransformUtil.toClass(UserAddedEventDto, data));
}
}
export interface IUserAddedEventDto extends IInitiatedDto {
user: User;
}
// inside the service, after saving the user:
await holder.stub.dispatch(new UserAddedEvent({ user: item, initiatorUid: params.initiatorUid }));
The shared command and event classes are moved into the *-common packages for a good reason: the very same code lives
both on the client and in the chaincode, and this is the only reliable way to keep their representations from
drifting apart over time. How a command is executed by a handler is covered in the Command Handlers section.