Your First Command from Start to Finish
The Quick Start shows the skeleton of a contract, but leaves the main question open — how a real operation grows out of that skeleton. Here we assemble a single command in full, layer by layer, against the framework's real API rather than made-up wrappers. We will take a simple but complete operation — adding a user: it passes through every level at once, from an entity in state down to a signature on the client, and it shows exactly what an application on hlf-core is built from.
The command flow in hlf-core is the same for every operation, and it helps to keep it in mind from the very start:
Let's walk this chain from left to right, and at the end close it with a call from the client.
Step 1. The Entity
An entity is what gets written into state. In hlf-core you don't have to inherit it from a base
class: it is enough to implement the interface from @hlf-core/common and annotate the fields with
class-validator decorators, which the manager then uses to check the object on write and on read. A user
looks like this:
import { IsEnum, IsOptional, Matches, IsDate } from 'class-validator';
import { Type } from 'class-transformer';
import { IUser, UserUtil, CryptoKey, ICryptoKey } from '@hlf-core/common';
export enum UserStatus {
ACTIVE = 'ACTIVE',
NON_ACTIVE = 'NON_ACTIVE',
}
export enum UserRole {
ADMIN = 'ADMIN',
USER_MANAGER = 'USER_MANAGER',
}
export class User implements IUser<UserStatus, UserRole> {
public static PREFIX = 'user';
@Matches(UserUtil.UID_REG_EXP)
public uid: string;
@Type(() => Date)
@IsDate()
public created: Date;
@IsEnum(UserStatus)
public status: UserStatus;
@IsOptional()
@IsEnum(UserRole, { each: true })
public roles?: Array<UserRole>;
@IsOptional()
@Type(() => CryptoKey)
public cryptoKey?: ICryptoKey;
}
Two fields here matter especially. The static PREFIX defines the entity's key namespace in state —
it is what later becomes the prefix of the composite key. And uid is the record's address, and the regular
expression UserUtil.UID_REG_EXP makes sure nothing stray ends up in it. The entity lives in the
*-common package, because the same fields are needed both by the chaincode on write and by the client on
read — shared code keeps their representations from drifting apart. More on entities and keys in the
Entities and State Key sections.
Step 2. The Command and the DTO
A command is a small class with a static NAME and a pair of DTOs — a request and a response. The base
class is HlfTransportCommandAsync<U, V>, where U is the request type and V is the response type (in
our case the User entity itself). The request is run through TransformUtil.toClass right in the
constructor, so that it arrives inside already typed and ready for validation:
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 DTO inherits from InitiatedDto, and that is not a formality — it has an initiatorUid field, by
which events and the audit trail show who initiated the operation. The command name is taken not as a
string but from the CommandName enum: that way it cannot be mistyped in either the command or the handler
that bind it together. What travels in the command's envelope and how an error reaches the client with a
code — in the Commands and Events section.
Step 3. The Manager
The manager hides away all the routine of working with key-value state — building the key, serialization,
validation. The abstract EntityManagerImpl<T> already knows how to get, save, remove, has and do
paginated queries, so in the simple case a domain manager only needs to say two things — how to turn
raw data into a class (toEntity) and which key namespace it lives in (prefix):
import { User } from '@project/common/hlf/user';
import { EntityManagerImpl, IStub } from '@hlf-core/chaincode';
import { UID, getUid, ILogger, TransformUtil } from '@ts-core/common';
import { CryptoKey } from '@hlf-core/common';
import * as _ from 'lodash';
export class UserManager extends EntityManagerImpl<User> {
public toEntity(item: any): User {
return TransformUtil.toClass(User, item);
}
public get prefix(): string {
return User.PREFIX;
}
}
That is enough to read and write a user by its uid. When an entity gains a heavy or rarely needed field
such as cryptoKey, which is better kept under a separate key and loaded only on demand, the manager is
extended with loadDetails, save and remove; that technique is covered in the
Entities section. For a first command the short version is enough.
Step 4. The Service
Business logic is kept in a service, not in a handler — that way it can be reused and tested separately
from the transport. This is also where determinism first appears, and it is a fundamental point: a user is
created not from new Date() and not from a random identifier, but from stub.transaction.date and
stub.transaction.hash — values that are identical on all endorsing peers. Otherwise the result would
diverge between peers and the transaction would not pass validation:
public async add(holder: IUserStubHolder, params: IUserAddDto): Promise<User> {
let item = UserUtil.create(User, holder.stub.transaction.date, holder.stub.transaction.hash);
if (await holder.stub.hasState(item.uid)) {
throw new UserAlreadyExistsError(item);
}
item.roles = params.roles;
item.status = UserStatus.ACTIVE;
item.cryptoKey = TransformUtil.toClass(CryptoKey, params.cryptoKey);
await this.getManager(holder).save(item);
await holder.stub.dispatch(new UserAddedEvent({ user: item, initiatorUid: params.initiatorUid }));
return item;
}
private getManager(holder: IUserStubHolder): UserManager {
if (_.isNil(holder.manager)) {
holder.manager = new UserManager(this.logger, holder.stub);
}
return holder.manager;
}
After the write, the service broadcasts the UserAddedEvent through stub.dispatch — it will reach the
client when the block is parsed, and you can subscribe to it without polling the state. Why time and
uniqueness are taken specifically from the transaction, and what forgetting this threatens, is covered in
the Determinism section.
Step 5. The Handler
The handler is a thin layer between the transport and the service. It inherits
TransportCommandAsyncHandler<U, V, T> from @ts-core/common (the same base class as in ordinary
@ts-core services), binds to the command name in the constructor, and implements the single execute
method. All signature and nonce verification has already been done by the transport before it — the handler
receives a parsed command and the holder through which it works with state:
import { Injectable } from '@nestjs/common';
import { Logger, Transport, TransportCommandAsyncHandler } from '@ts-core/common';
import { UserAddCommand, IUserAddDto } from '@project/common/hlf/transport';
import { StubHolder } from '@hlf-core/transport-chaincode';
import { User, UserRole } from '@project/common/hlf/user';
import { UserService } from '../service';
import { IUserStubHolder, UserGuard, rolesCheck } from '@project/module/core/guard';
@Injectable()
export class UserAddHandler extends TransportCommandAsyncHandler<IUserAddDto, User, UserAddCommand> {
constructor(logger: Logger, transport: Transport, private service: UserService) {
super(logger, transport, UserAddCommand.NAME);
}
@UserGuard()
protected async execute(params: IUserAddDto, @StubHolder() holder: IUserStubHolder): Promise<User> {
await rolesCheck(holder, UserRole.USER_MANAGER);
return this.service.add(holder, params);
}
}
There are two levels of protection here, and they are different in nature. The @UserGuard() decorator runs
before execute and answers the question "who are you" — it loads the user by stub.user.id and matches
the key recorded in the ledger against the signing key, closing the link "this key belongs to this user".
And rolesCheck(holder, UserRole.USER_MANAGER) inside execute answers the question "are you allowed this
operation" — it checks the permission. The holder parameter comes in through the @StubHolder()
decorator, and the subtlety is that the holder and the command are one and the same object. The full
breakdown of the guard is in the Command Handlers and
Security sections.
Step 6. Registration in the Module
There is no centralized list of handlers in hlf-core, and this is an easily missed point: a handler
subscribes to the transport right in its own constructor, so "registration" is simply its creation. Hence
the rule — in NestJS command handlers are declared in controllers, not in providers. A provider is
created lazily, on first access, and without that a command simply won't be heard; whereas Nest instantiates
controllers immediately. Services, meanwhile, stay in providers:
import { Module } from '@nestjs/common';
import { UserService } from './service';
import { UserAddHandler, UserGetHandler, UserEditHandler } from './handler';
@Module({
controllers: [UserGetHandler, UserAddHandler, UserEditHandler],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
Next, the domain module is assembled into the chaincode's root module together with the others, and
service handlers such as metadata and nonce are registered in the root controllers:
imports: [
LoggerModule.forRoot(settings),
TransportFabricChaincodeModule.forRoot(settings),
UserModule,
CoinModule,
// ... the remaining domain modules
],
controllers: [MetadataGetHandler, NonceGetHandler],
A pleasant consequence of this scheme is that adding a new command does not require reinstalling the
contract. TransportFabricChaincodeReceiver finds the handler by the command name in the envelope, so it is
enough to declare one more handler under a new name, and the contract will start to understand the new
operation.
Step 7. The Call from the Client
It remains to close the chain from the client side. The command is created by the same class that lives in
*-common, is signed with the sender's key, and goes off to the chaincode through the same unified API as
in any @ts-core service — userId and signature travel in the command's options:
import { Ed25519, IKeyAsymmetric, ISignature, TransportCommand, TransportCryptoManagerEd25519 } from '@ts-core/common';
import { UserAddCommand } from '@project/common/hlf/transport';
const keys: IKeyAsymmetric = Ed25519.from(privateKey);
const command = new UserAddCommand({ cryptoKey: { value: publicKey }, roles: [UserRole.USER_MANAGER] });
const signature: ISignature = await TransportCommand.sign(
command,
new TransportCryptoManagerEd25519(),
keys,
);
const user = await transport.sendListen(command, { userId, signature });
With that the full path is complete: a command signed on the client reached the chaincode, passed the
signature and nonce checks in the transport, was authorized by the guard and the role check, was executed
by the service with deterministic time, was written into state by the manager and echoed back with an
event, and the client received a typed User entity in return.
Where to Go Next
The determinism rules we touched on in passing here are worth reading separately — whether a transaction passes at all rests on them: Determinism. And the common techniques on top of this skeleton — your own signing algorithm, issuing a token, moving an operation into a batch — are collected in the cookbook.